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