diff --git a/docs/hotkeys.md b/docs/hotkeys.md
index 43bbf6125a..02f8824350 100644
--- a/docs/hotkeys.md
+++ b/docs/hotkeys.md
@@ -62,6 +62,7 @@
|Toggle star status of the current message|Ctrl + s / *|
|Show/hide message information|i|
|Show/hide message sender information|u|
+|Show/hide poll voter list|v|
## Stream list actions
|Command|Key Combination|
diff --git a/tests/widget/test_widget.py b/tests/widget/test_widget.py
index ee2636e820..7d8098a576 100644
--- a/tests/widget/test_widget.py
+++ b/tests/widget/test_widget.py
@@ -3,7 +3,12 @@
import pytest
from pytest import param as case
-from zulipterminal.widget import Submessage, find_widget_type, process_todo_widget
+from zulipterminal.widget import (
+ Submessage,
+ find_widget_type,
+ process_poll_widget,
+ process_todo_widget,
+)
@pytest.mark.parametrize(
@@ -343,3 +348,318 @@ def test_process_todo_widget(
assert title == expected_title
assert tasks == expected_tasks
+
+
+@pytest.mark.parametrize(
+ "submessage, expected_poll_question, expected_options",
+ [
+ case(
+ [
+ {
+ "id": 12082,
+ "message_id": 1957499,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": (
+ '{"widget_type": "poll", "extra_data": {'
+ '"question": "Do polls work on ZT?", "options": ["Yes", "No"]}}'
+ ),
+ },
+ {
+ "id": 12083,
+ "message_id": 1957499,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":1}',
+ },
+ {
+ "id": 12084,
+ "message_id": 1957499,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":-1}',
+ },
+ {
+ "id": 12085,
+ "message_id": 1957499,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,1","vote":1}',
+ },
+ {
+ "id": 12086,
+ "message_id": 1957499,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":1}',
+ },
+ {
+ "id": 12087,
+ "message_id": 1957499,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,1","vote":-1}',
+ },
+ ],
+ "Do polls work on ZT?",
+ {
+ "canned,0": {"option": "Yes", "votes": [27294]},
+ "canned,1": {"option": "No", "votes": []},
+ },
+ id="poll_widget_with_votes",
+ ),
+ case(
+ [
+ {
+ "id": 12089,
+ "message_id": 1957662,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": (
+ '{"widget_type": "poll", "extra_data": {"question": "Is '
+ 'this a poll with options added later?", '
+ '"options": ["Yes", "No"]}}'
+ ),
+ },
+ {
+ "id": 12090,
+ "message_id": 1957662,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"new_option","idx":1,"option":"Maybe"}',
+ },
+ {
+ "id": 12091,
+ "message_id": 1957662,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,1","vote":1}',
+ },
+ {
+ "id": 12092,
+ "message_id": 1957662,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,1","vote":-1}',
+ },
+ {
+ "id": 12093,
+ "message_id": 1957662,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"27294,1","vote":1}',
+ },
+ {
+ "id": 12094,
+ "message_id": 1957662,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":1}',
+ },
+ ],
+ "Is this a poll with options added later?",
+ {
+ "canned,0": {"option": "Yes", "votes": [27294]},
+ "canned,1": {"option": "No", "votes": []},
+ "27294,1": {"option": "Maybe", "votes": [27294]},
+ },
+ id="poll_widget_with_new_option_and_votes",
+ ),
+ case(
+ [
+ {
+ "id": 12095,
+ "message_id": 1957682,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": (
+ '{"widget_type": "poll", "extra_data": {"question": '
+ '"Let\'s change this question later?", "options": ["Yes"]}}'
+ ),
+ },
+ {
+ "id": 12096,
+ "message_id": 1957682,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":1}',
+ },
+ {
+ "id": 12097,
+ "message_id": 1957682,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"new_option","idx":1,"option":"No"}',
+ },
+ {
+ "id": 12098,
+ "message_id": 1957682,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":-1}',
+ },
+ {
+ "id": 12099,
+ "message_id": 1957682,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"question",'
+ '"question":"Has this question stayed the same?"}',
+ },
+ {
+ "id": 12100,
+ "message_id": 1957682,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"27294,1","vote":1}',
+ },
+ ],
+ "Has this question stayed the same?",
+ {
+ "canned,0": {"option": "Yes", "votes": []},
+ "27294,1": {"option": "No", "votes": [27294]},
+ },
+ id="poll_widget_with_new_question_and_votes",
+ ),
+ case(
+ [
+ {
+ "id": 12101,
+ "message_id": 1957693,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": (
+ '{"widget_type": "poll", "extra_data": {"question": "",'
+ ' "options": ["Yes", "No"]}}'
+ ),
+ }
+ ],
+ "",
+ {
+ "canned,0": {"option": "Yes", "votes": []},
+ "canned,1": {"option": "No", "votes": []},
+ },
+ id="poll_widget_with_empty_question",
+ ),
+ case(
+ [
+ {
+ "id": 12102,
+ "message_id": 1957700,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": (
+ '{"widget_type": "poll", "extra_data": {'
+ '"question": "Does this poll have options?", "options": []}}'
+ ),
+ }
+ ],
+ "Does this poll have options?",
+ {},
+ id="poll_widget_with_empty_options",
+ ),
+ case(
+ [
+ {
+ "id": 12112,
+ "message_id": 1957722,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": (
+ '{"widget_type": "poll", "extra_data": {"question": "",'
+ ' "options": []}}'
+ ),
+ }
+ ],
+ "",
+ {},
+ id="poll_widget_with_empty_question_and_options",
+ ),
+ case(
+ [
+ {
+ "id": 12103,
+ "message_id": 1957719,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": (
+ '{"widget_type": "poll", "extra_data": {"question": "Does'
+ ' this poll have multiple voters?", "options": ["Yes", "No"]}}'
+ ),
+ },
+ {
+ "id": 12104,
+ "message_id": 1957719,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":1}',
+ },
+ {
+ "id": 12105,
+ "message_id": 1957719,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,1","vote":1}',
+ },
+ {
+ "id": 12106,
+ "message_id": 1957719,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":-1}',
+ },
+ {
+ "id": 12107,
+ "message_id": 1957719,
+ "sender_id": 32159,
+ "msg_type": "widget",
+ "content": '{"type":"new_option","idx":1,"option":"Maybe"}',
+ },
+ {
+ "id": 12108,
+ "message_id": 1957719,
+ "sender_id": 32159,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"32159,1","vote":1}',
+ },
+ {
+ "id": 12109,
+ "message_id": 1957719,
+ "sender_id": 32159,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":1}',
+ },
+ {
+ "id": 12110,
+ "message_id": 1957719,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,1","vote":-1}',
+ },
+ {
+ "id": 12111,
+ "message_id": 1957719,
+ "sender_id": 27294,
+ "msg_type": "widget",
+ "content": '{"type":"vote","key":"canned,0","vote":1}',
+ },
+ ],
+ "Does this poll have multiple voters?",
+ {
+ "canned,0": {"option": "Yes", "votes": [32159, 27294]},
+ "canned,1": {"option": "No", "votes": []},
+ "32159,1": {"option": "Maybe", "votes": [32159]},
+ },
+ id="poll_widget_with_multiple_voters",
+ ),
+ ],
+)
+def test_process_poll_widget(
+ submessage: List[Submessage],
+ expected_poll_question: str,
+ expected_options: Dict[str, Dict[str, Union[str, List[str]]]],
+) -> None:
+ poll_question, options = process_poll_widget(submessage)
+
+ assert poll_question == expected_poll_question
+ assert options == expected_options
diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py
index a86420a364..b34d0904a0 100644
--- a/zulipterminal/config/keys.py
+++ b/zulipterminal/config/keys.py
@@ -300,6 +300,11 @@ class KeyBinding(TypedDict):
'help_text': 'Show/hide message sender information',
'key_category': 'msg_actions',
},
+ 'SHOW_POLL_VOTES': {
+ 'keys': ['v'],
+ 'help_text': 'Show/hide poll voter list',
+ 'key_category': 'msg_actions',
+ },
'EDIT_HISTORY': {
'keys': ['e'],
'help_text': 'Show/hide edit history',
diff --git a/zulipterminal/core.py b/zulipterminal/core.py
index 61c5f79922..3540f527cb 100644
--- a/zulipterminal/core.py
+++ b/zulipterminal/core.py
@@ -43,6 +43,7 @@
MarkdownHelpView,
MsgInfoView,
NoticeView,
+ PollResultsView,
PopUpConfirmationView,
StreamInfoView,
StreamMembersView,
@@ -281,6 +282,34 @@ def show_msg_info(
)
self.show_pop_up(msg_info_view, "area:msg")
+ def show_poll_vote(
+ self,
+ poll_question: str,
+ options: Dict[str, Dict[str, Any]],
+ ) -> None:
+ options_with_names = {}
+ for option_key, option_data in options.items():
+ option_text = option_data["option"]
+ voter_ids = option_data["votes"]
+
+ voter_names = []
+ for voter_id in voter_ids:
+ voter_names.append(self.model.user_name_from_id(voter_id))
+
+ options_with_names[option_key] = {
+ "option": option_text,
+ "votes": voter_names if voter_names else [],
+ }
+
+ self.show_pop_up(
+ PollResultsView(
+ self,
+ poll_question,
+ options_with_names,
+ ),
+ "area:msg",
+ )
+
def show_emoji_picker(self, message: Message) -> None:
all_emoji_units = [
(emoji_name, emoji["code"], emoji["aliases"])
diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py
index 2b67ae6e71..1ea76bd561 100644
--- a/zulipterminal/ui_tools/messages.py
+++ b/zulipterminal/ui_tools/messages.py
@@ -33,7 +33,11 @@
from zulipterminal.server_url import near_message_url
from zulipterminal.ui_tools.tables import render_table
from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size
-from zulipterminal.widget import find_widget_type, process_todo_widget
+from zulipterminal.widget import (
+ find_widget_type,
+ process_poll_widget,
+ process_todo_widget,
+)
if typing.TYPE_CHECKING:
@@ -65,6 +69,7 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None:
self.topic_links: Dict[str, Tuple[str, int, bool]] = dict()
self.time_mentions: List[Tuple[str, str]] = list()
self.last_message = last_message
+ self.widget_type: str = ""
# if this is the first message
if self.last_message is None:
self.last_message = defaultdict(dict)
@@ -731,9 +736,9 @@ def main_view(self) -> List[Any]:
)
if self.message.get("submessages"):
- widget_type = find_widget_type(self.message.get("submessages", []))
+ self.widget_type = find_widget_type(self.message.get("submessages", []))
- if widget_type == "todo":
+ if self.widget_type == "todo":
title, tasks = process_todo_widget(self.message.get("submessages", []))
todo_widget = "To-do\n" + f"{title}"
@@ -755,6 +760,39 @@ def main_view(self) -> List[Any]:
# though it's not very useful.
self.message["content"] = todo_widget
+ elif self.widget_type == "poll":
+ self.poll_question, self.poll_options = process_poll_widget(
+ self.message.get("submessages", [])
+ )
+
+ poll_widget = (
+ f"Poll\n{self.poll_question}"
+ if self.poll_question
+ else "No poll question provided. Please add one via the web app."
+ )
+
+ if not self.poll_question:
+ # If no poll question is provided, set a message to display
+ # in Poll Results popup.
+ self.poll_question = "No poll question provided."
+
+ if self.poll_options:
+ max_votes_len = max(
+ len(str(len(option["votes"])))
+ for option in self.poll_options.values()
+ )
+
+ for option_info in self.poll_options.values():
+ padded_votes = f"{len(option_info['votes']):>{max_votes_len}}"
+ poll_widget += f"\n[ {padded_votes} ] {option_info['option']}"
+ else:
+ poll_widget += "\nNo options provided."
+ "Please add them via the web app."
+
+ # Update the message content with the latest poll_widget,
+ # similar to the todo_widget above.
+ self.message["content"] = poll_widget
+
# Transform raw message content into markup (As needed by urwid.Text)
content, self.message_links, self.time_mentions = self.transform_content(
self.message["content"], self.model.server_url
@@ -1153,4 +1191,6 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]:
self.model.controller.show_emoji_picker(self.message)
elif is_command_key("MSG_SENDER_INFO", key):
self.model.controller.show_msg_sender_info(self.message["sender_id"])
+ elif is_command_key("SHOW_POLL_VOTES", key) and self.widget_type == "poll":
+ self.model.controller.show_poll_vote(self.poll_question, self.poll_options)
return key
diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py
index 00158f88e2..e31d6e7eca 100644
--- a/zulipterminal/ui_tools/views.py
+++ b/zulipterminal/ui_tools/views.py
@@ -23,6 +23,7 @@
from zulipterminal.config.symbols import (
CHECK_MARK,
COLUMN_TITLE_BAR_LINE,
+ INVALID_MARKER,
PINNED_STREAMS_DIVIDER,
SECTION_DIVIDER_LINE,
)
@@ -2172,3 +2173,37 @@ def keypress(self, size: urwid_Size, key: str) -> str:
self.controller.exit_popup()
return key
return super().keypress(size, key)
+
+
+class PollResultsView(PopUpView):
+ def __init__(
+ self,
+ controller: Any,
+ poll_question: str,
+ poll_options: Dict[str, Dict[str, Any]],
+ ) -> None:
+ poll_results_content: List[Tuple[str, List[Tuple[str, str]]]] = [("", [])]
+
+ for option_key, option_data in poll_options.items():
+ option_text = option_data["option"]
+ if len(option_text) >= 13:
+ option_text = option_text[:10] + "…"
+ voter_names = option_data["votes"]
+
+ voters_display = (
+ "\n".join(map(str, voter_names))
+ if voter_names
+ else f"{INVALID_MARKER} No votes yet"
+ )
+
+ poll_results_content[0][1].append((option_text, voters_display))
+
+ popup_width, column_widths = self.calculate_table_widths(
+ poll_results_content, len(poll_question)
+ )
+
+ widgets = self.make_table_with_categories(poll_results_content, column_widths)
+
+ super().__init__(
+ controller, widgets, "SHOW_POLL_VOTES", popup_width, poll_question
+ )
diff --git a/zulipterminal/widget.py b/zulipterminal/widget.py
index 05e80c7c97..c1eac2aff8 100644
--- a/zulipterminal/widget.py
+++ b/zulipterminal/widget.py
@@ -74,3 +74,45 @@ def process_todo_widget(
title = widget["title"]
return title, tasks
+
+
+def process_poll_widget(
+ poll_content: List[Submessage],
+) -> Tuple[str, Dict[str, Dict[str, Union[str, List[str]]]]]:
+ poll_question = ""
+ options = {}
+
+ for entry in poll_content:
+ content = entry["content"]
+ sender_id = entry["sender_id"]
+ msg_type = entry["msg_type"]
+
+ if msg_type == "widget" and isinstance(content, str):
+ widget = json.loads(content)
+
+ if widget.get("widget_type") == "poll":
+ poll_question = widget["extra_data"]["question"]
+ for i, option in enumerate(widget["extra_data"]["options"]):
+ option_id = f"canned,{i}"
+ options[option_id] = {"option": option, "votes": []}
+
+ elif widget.get("type") == "question":
+ poll_question = widget["question"]
+
+ elif widget.get("type") == "vote":
+ option_id = widget["key"]
+ vote_type = widget["vote"]
+
+ if option_id in options:
+ if vote_type == 1 and sender_id not in options[option_id]["votes"]:
+ options[option_id]["votes"].append(sender_id)
+ elif vote_type == -1 and sender_id in options[option_id]["votes"]:
+ options[option_id]["votes"].remove(sender_id)
+
+ elif widget.get("type") == "new_option":
+ idx = widget["idx"]
+ new_option = widget["option"]
+ option_id = f"{sender_id},{idx}"
+ options[option_id] = {"option": new_option, "votes": []}
+
+ return poll_question, options