From 7d0b85c75d9ce7b146f9b3e490d289690f1a4a75 Mon Sep 17 00:00:00 2001 From: rsashank Date: Thu, 27 Jun 2024 22:54:19 -0400 Subject: [PATCH 1/9] boxes/messages: Add support for spoiler header markup in MessageBox. This commit: * adds styles for spoiler header in themes * adds markup entry in `soup2markup` for spoilers. Tests added. Co-authored-by: Preet Mishra Co-authored-by: Ezio-Sarthak --- tests/ui_tools/test_messages.py | 46 +++++++++++++++++++++++++++ zulipterminal/config/themes.py | 1 + zulipterminal/themes/gruvbox_dark.py | 1 + zulipterminal/themes/gruvbox_light.py | 1 + zulipterminal/themes/zt_blue.py | 1 + zulipterminal/themes/zt_dark.py | 1 + zulipterminal/themes/zt_light.py | 1 + zulipterminal/ui_tools/messages.py | 41 +++++++++++++++++++++++- 8 files changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py index 08ba7d0661..890d2e2d72 100644 --- a/tests/ui_tools/test_messages.py +++ b/tests/ui_tools/test_messages.py @@ -669,6 +669,52 @@ def test_private_message_to_self(self, mocker): [("msg_emoji", ":github:")], id="custom_emoji", ), + case( + '
' + '
", + [ + "┌─", + "────────", + "─┬─", + "───────", + "─┐\n", + "│ ", + ("msg_spoiler", "Spoiler:"), + " │ ", + "Spoiler", + " │\n", + "└─", + "────────", + "─┴─", + "───────", + "─┘", + ], + id="spoiler_no_header", + ), + case( + '
' + '

Header

", + [ + "┌─", + "────────", + "─┬─", + "──────", + "─┐\n", + "│ ", + ("msg_spoiler", "Spoiler:"), + " │ ", + "Header", + " │\n", + "└─", + "────────", + "─┴─", + "──────", + "─┘", + ], + id="spoiler_with_header", + ), ], ) def test_soup2markup(self, content, expected_markup, mocker): diff --git a/zulipterminal/config/themes.py b/zulipterminal/config/themes.py index 7c6dfe56da..ec699177ac 100644 --- a/zulipterminal/config/themes.py +++ b/zulipterminal/config/themes.py @@ -50,6 +50,7 @@ 'msg_quote' : 'underline', 'msg_bold' : 'bold', 'msg_time' : 'bold', + 'msg_spoiler' : 'bold', 'footer' : 'standout', 'footer_contrast' : 'standout', 'starred' : 'bold', diff --git a/zulipterminal/themes/gruvbox_dark.py b/zulipterminal/themes/gruvbox_dark.py index 1abd77906e..781546d8b8 100644 --- a/zulipterminal/themes/gruvbox_dark.py +++ b/zulipterminal/themes/gruvbox_dark.py @@ -46,6 +46,7 @@ 'msg_quote' : (Color.NEUTRAL_YELLOW, Color.DARK0_HARD), 'msg_bold' : (Color.LIGHT2__BOLD, Color.DARK0_HARD), 'msg_time' : (Color.DARK0_HARD, Color.LIGHT2), + 'msg_spoiler' : (Color.BRIGHT_GREEN__BOLD, Color.DARK0_HARD), 'footer' : (Color.DARK0_HARD, Color.LIGHT4), 'footer_contrast' : (Color.LIGHT2, Color.DARK0_HARD), 'starred' : (Color.BRIGHT_RED__BOLD, Color.DARK0_HARD), diff --git a/zulipterminal/themes/gruvbox_light.py b/zulipterminal/themes/gruvbox_light.py index e477a9f086..f4e2d7e94c 100644 --- a/zulipterminal/themes/gruvbox_light.py +++ b/zulipterminal/themes/gruvbox_light.py @@ -45,6 +45,7 @@ 'msg_quote' : (Color.NEUTRAL_YELLOW, Color.LIGHT0_HARD), 'msg_bold' : (Color.DARK2__BOLD, Color.LIGHT0_HARD), 'msg_time' : (Color.LIGHT0_HARD, Color.DARK2), + 'msg_spoiler' : (Color.FADED_GREEN__BOLD, Color.LIGHT0_HARD), 'footer' : (Color.LIGHT0_HARD, Color.DARK4), 'footer_contrast' : (Color.DARK2, Color.LIGHT0_HARD), 'starred' : (Color.FADED_RED__BOLD, Color.LIGHT0_HARD), diff --git a/zulipterminal/themes/zt_blue.py b/zulipterminal/themes/zt_blue.py index eb99c8dc0a..c69b10c11f 100644 --- a/zulipterminal/themes/zt_blue.py +++ b/zulipterminal/themes/zt_blue.py @@ -40,6 +40,7 @@ 'msg_quote' : (Color.BROWN, Color.DARK_BLUE), 'msg_bold' : (Color.WHITE__BOLD, Color.DARK_BLUE), 'msg_time' : (Color.DARK_BLUE, Color.WHITE), + 'msg_spoiler' : (Color.LIGHT_GREEN__BOLD, Color.LIGHT_BLUE), 'footer' : (Color.WHITE, Color.DARK_GRAY), 'footer_contrast' : (Color.BLACK, Color.WHITE), 'starred' : (Color.LIGHT_RED__BOLD, Color.LIGHT_BLUE), diff --git a/zulipterminal/themes/zt_dark.py b/zulipterminal/themes/zt_dark.py index 69a5f4ad75..332521f468 100644 --- a/zulipterminal/themes/zt_dark.py +++ b/zulipterminal/themes/zt_dark.py @@ -40,6 +40,7 @@ 'msg_quote' : (Color.BROWN, Color.BLACK), 'msg_bold' : (Color.WHITE__BOLD, Color.BLACK), 'msg_time' : (Color.BLACK, Color.WHITE), + 'msg_spoiler' : (Color.LIGHT_GREEN__BOLD, Color.BLACK), 'footer' : (Color.BLACK, Color.LIGHT_GRAY), 'footer_contrast' : (Color.WHITE, Color.BLACK), 'starred' : (Color.LIGHT_RED__BOLD, Color.BLACK), diff --git a/zulipterminal/themes/zt_light.py b/zulipterminal/themes/zt_light.py index 6b0ee5709a..66d925c2f5 100644 --- a/zulipterminal/themes/zt_light.py +++ b/zulipterminal/themes/zt_light.py @@ -40,6 +40,7 @@ 'msg_quote' : (Color.BLACK, Color.BROWN), 'msg_bold' : (Color.WHITE__BOLD, Color.DARK_GRAY), 'msg_time' : (Color.WHITE, Color.DARK_GRAY), + 'msg_spoiler' : (Color.DARK_GREEN__BOLD, Color.WHITE), 'footer' : (Color.WHITE, Color.DARK_GRAY), 'footer_contrast' : (Color.BLACK, Color.WHITE), 'starred' : (Color.LIGHT_RED__BOLD, Color.WHITE), diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index b8552fdc92..71b4b85c40 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -31,7 +31,7 @@ from zulipterminal.config.ui_mappings import STATE_ICON, STREAM_ACCESS_TYPE from zulipterminal.helper import get_unused_fence from zulipterminal.server_url import near_message_url -from zulipterminal.ui_tools.tables import render_table +from zulipterminal.ui_tools.tables import render_table, row_with_only_border from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size @@ -632,6 +632,45 @@ def soup2markup( source_text = f"Original text was {tag_text.strip()}" metadata["time_mentions"].append((time_string, source_text)) + elif tag == "div" and "spoiler-block" in tag_classes: + # SPOILERS + header = element.find(class_="spoiler-header") + header.contents = [part for part in header.contents if part != "\n"] + + if not header.contents: + default = BeautifulSoup("

Spoiler

", "html.parser") + header.contents.append(default) + + processed_header = cls.soup2markup(header, metadata)[0] + + processed_header_text = "".join( + part[1] if isinstance(part, tuple) else part + for part in processed_header + ) + + # Limit to the first 10 characters and append "..." + if len(processed_header_text) > 10: + processed_header_text = processed_header_text[:10] + "..." + + processed_header_len = len(processed_header_text) + marker = "Spoiler:" + + widths = [len(marker), processed_header_len] + top_border = row_with_only_border("┌", "─", "┬", "┐", widths) + bottom_border = row_with_only_border( + "└", "─", "┴", "┘", widths, newline=False + ) + markup.extend(top_border) + markup.extend( + [ + "│ ", + ("msg_spoiler", marker), + " │ ", + processed_header_text, + " │\n", + ] + ) + markup.extend(bottom_border) else: markup.extend(cls.soup2markup(element, metadata)[0]) return markup, metadata["message_links"], metadata["time_mentions"] From 555dfdd5161320c5794f508c3779fc5271d56d1a Mon Sep 17 00:00:00 2001 From: rsashank Date: Fri, 28 Jun 2024 10:01:00 -0400 Subject: [PATCH 2/9] messages/views: Extract spoiler content in a variable in MessageBox. This commit handles content metadata in `soup2markup` for spoilers. Test amended. Co-authored-by: Preet Mishra Co-authored-by: Ezio-Sarthak --- tests/ui_tools/test_messages.py | 1 + zulipterminal/ui_tools/messages.py | 60 ++++++++++++++++++++++++++---- zulipterminal/ui_tools/views.py | 2 +- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py index 890d2e2d72..f2f7825256 100644 --- a/tests/ui_tools/test_messages.py +++ b/tests/ui_tools/test_messages.py @@ -726,6 +726,7 @@ def test_soup2markup(self, content, expected_markup, mocker): server_url=SERVER_URL, message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), bq_len=0, ) diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index 71b4b85c40..10c28de4bb 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -63,6 +63,7 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None: self.message_links: Dict[str, Tuple[str, int, bool]] = dict() self.topic_links: Dict[str, Tuple[str, int, bool]] = dict() self.time_mentions: List[Tuple[str, str]] = list() + self.spoilers: List[Tuple[int, List[Any], List[Any]]] = list() self.last_message = last_message # if this is the first message if self.last_message is None: @@ -371,12 +372,22 @@ def footlinks_view( @classmethod def soup2markup( cls, soup: Any, metadata: Dict[str, Any], **state: Any - ) -> Tuple[List[Any], Dict[str, Tuple[str, int, bool]], List[Tuple[str, str]]]: + ) -> Tuple[ + List[Any], + Dict[str, Tuple[str, int, bool]], + List[Tuple[str, str]], + List[Tuple[int, List[Any], List[Any]]], + ]: # Ensure a string is provided, in case the soup finds none # This could occur if eg. an image is removed or not shown markup: List[Union[str, Tuple[Optional[str], Any]]] = [""] if soup is None: # This is not iterable, so return promptly - return markup, metadata["message_links"], metadata["time_mentions"] + return ( + markup, + metadata["message_links"], + metadata["time_mentions"], + metadata["spoilers"], + ) unrendered_tags = { # In pairs of 'tag_name': 'text' # TODO: Some of these could be implemented "br": "", # No indicator of absence @@ -647,6 +658,10 @@ def soup2markup( part[1] if isinstance(part, tuple) else part for part in processed_header ) + header_len = sum( + len(part[1]) if isinstance(part, tuple) else len(part) + for part in processed_header + ) # Limit to the first 10 characters and append "..." if len(processed_header_text) > 10: @@ -671,9 +686,33 @@ def soup2markup( ] ) markup.extend(bottom_border) + # Spoiler content + content = element.find(class_="spoiler-content") + + # Remove surrounding newlines. + content_contents = content.contents + if len(content_contents) > 2: + if content_contents[-1] == "\n": + content.contents.pop(-1) + if content_contents[0] == "\n": + content.contents.pop(0) + if len(content_contents) == 1 and content_contents[0] == "\n": + content.contents.pop(0) + + # FIXME: Do not soup2markup content in the MessageBox as it + # will render 'sensitive' spoiler anchor tags in the footlinks. + processed_content = cls.soup2markup(content, metadata)[0] + metadata["spoilers"].append( + (header_len, processed_header, processed_content) + ) else: markup.extend(cls.soup2markup(element, metadata)[0]) - return markup, metadata["message_links"], metadata["time_mentions"] + return ( + markup, + metadata["message_links"], + metadata["time_mentions"], + metadata["spoilers"], + ) def main_view(self) -> List[Any]: # Recipient Header @@ -769,9 +808,12 @@ def main_view(self) -> List[Any]: ) # 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 - ) + ( + content, + self.message_links, + self.time_mentions, + self.spoilers, + ) = self.transform_content(self.message["content"], self.model.server_url) self.content.set_text(content) if self.message["id"] in self.model.index["edited_messages"]: @@ -858,6 +900,7 @@ def transform_content( Tuple[None, Any], Dict[str, Tuple[str, int, bool]], List[Tuple[str, str]], + List[Tuple[int, List[Any], List[Any]]], ]: soup = BeautifulSoup(content, "lxml") body = soup.find(name="body") @@ -866,13 +909,14 @@ def transform_content( server_url=server_url, message_links=dict(), time_mentions=list(), + spoilers=list(), ) # type: Dict[str, Any] if isinstance(body, Tag) and body.find(name="blockquote"): metadata["bq_len"] = cls.indent_quoted_content(soup, QUOTED_TEXT_MARKER) - markup, message_links, time_mentions = cls.soup2markup(body, metadata) - return (None, markup), message_links, time_mentions + markup, message_links, time_mentions, spoilers = cls.soup2markup(body, metadata) + return (None, markup), message_links, time_mentions, spoilers @staticmethod def indent_quoted_content(soup: Any, padding_char: str) -> int: diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index c2034b3ef7..fae7f280a2 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -1421,7 +1421,7 @@ def __init__(self, controller: Any, stream_id: int) -> None: title = f"{stream_marker} {stream['name']}" rendered_desc = stream["rendered_description"] - self.markup_desc, message_links, _ = MessageBox.transform_content( + self.markup_desc, message_links, *_ = MessageBox.transform_content( rendered_desc, self.controller.model.server_url, ) From 6d11f72ad5558f9835ba4016000f9c05a317d618 Mon Sep 17 00:00:00 2001 From: rsashank Date: Sun, 30 Jun 2024 14:19:43 -0400 Subject: [PATCH 3/9] core/views: Add SpoilerView class and corresponding show_* function. This commit adds popup to show hidden spoiler content. Tests added. Co-authored-by: Preet Mishra Co-authored-by: Ezio-Sarthak --- tests/ui_tools/test_popups.py | 40 +++++++++++++++++++++++++++++++++ zulipterminal/core.py | 6 +++++ zulipterminal/ui_tools/views.py | 8 +++++++ 3 files changed, 54 insertions(+) diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index d58b6c3353..aeed1f3e77 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -24,6 +24,7 @@ MsgInfoView, PopUpConfirmationView, PopUpView, + SpoilerView, StreamInfoView, StreamMembersView, UserInfoView, @@ -952,6 +953,45 @@ def test_keypress_exit_popup( assert self.controller.exit_popup.called +class TestSpoilerView: + @pytest.fixture(autouse=True) + def mock_external_classes(self, mocker: MockerFixture) -> None: + self.controller = mocker.Mock() + mocker.patch.object( + self.controller, "maximum_popup_dimensions", return_value=(64, 64) + ) + mocker.patch(MODULE + ".urwid.SimpleFocusListWalker", return_value=[]) + self.spoiler_view = SpoilerView(self.controller, "Spoiler View", "") + + def test_keypress_any_key( + self, widget_size: Callable[[Widget], urwid_Size] + ) -> None: + key = "a" + size = widget_size(self.spoiler_view) + self.spoiler_view.keypress(size, key) + assert not self.controller.exit_popup.called + + @pytest.mark.parametrize("key", {*keys_for_command("EXIT_POPUP")}) + def test_keypress_exit_popup( + self, key: str, widget_size: Callable[[Widget], urwid_Size] + ) -> None: + size = widget_size(self.spoiler_view) + self.spoiler_view.keypress(size, key) + assert self.controller.exit_popup.called + + def test_keypress_navigation( + self, + mocker: MockerFixture, + widget_size: Callable[[Widget], urwid_Size], + navigation_key_expected_key_pair: Tuple[str, str] = ("ENTER", "ENTER"), + ) -> None: + key, expected_key = navigation_key_expected_key_pair + size = widget_size(self.spoiler_view) + super_keypress = mocker.patch(MODULE + ".urwid.ListBox.keypress") + self.spoiler_view.keypress(size, key) + super_keypress.assert_called_once_with(size, expected_key) + + class TestMsgInfoView: @pytest.fixture(autouse=True) def mock_external_classes( diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 99d6cac23a..2976eede9b 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -42,6 +42,7 @@ MsgInfoView, NoticeView, PopUpConfirmationView, + SpoilerView, StreamInfoView, StreamMembersView, UserInfoView, @@ -481,6 +482,11 @@ def report_warning( """ self.view.set_footer_text(text, "task:warning", duration) + def show_spoiler(self, content: str) -> None: + self.show_pop_up( + SpoilerView(self, "Spoiler (up/down scrolls)", content), "area:msg" + ) + def show_media_confirmation_popup( self, func: Any, tool: str, media_path: str ) -> None: diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index fae7f280a2..c2143aaa17 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -1076,6 +1076,14 @@ def __init__( super().__init__(controller, widgets, "EXIT_POPUP", width, title) +class SpoilerView(PopUpView): + def __init__(self, controller: Any, title: str, content: str) -> None: + width, _ = controller.maximum_popup_dimensions() + widget = [urwid.Text(content)] + + super().__init__(controller, widget, "MSG_INFO", width, title) + + class AboutView(PopUpView): def __init__( self, From a577cef9035706bfe4353dcab6d65a0ff53adc5a Mon Sep 17 00:00:00 2001 From: rsashank Date: Sun, 30 Jun 2024 20:25:47 -0400 Subject: [PATCH 4/9] core/messages/views: Pass spoiler data to MsgInfoView popup helpers. Tests amended. Co-authored-by: Preet Mishra Co-authored-by: Ezio-Sarthak --- tests/ui_tools/test_popups.py | 19 +++++++++++++++++++ zulipterminal/core.py | 8 ++++++++ zulipterminal/ui_tools/messages.py | 6 +++++- zulipterminal/ui_tools/views.py | 14 ++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index aeed1f3e77..211a9629ae 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -504,6 +504,7 @@ def mock_external_classes(self, mocker: MockerFixture, msg_box: MessageBox) -> N topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), title="Full Rendered Message", ) @@ -514,6 +515,7 @@ def test_init(self, msg_box: MessageBox) -> None: assert self.full_rendered_message.topic_links == OrderedDict() assert self.full_rendered_message.message_links == OrderedDict() assert self.full_rendered_message.time_mentions == list() + assert self.full_rendered_message.spoilers == list() assert self.full_rendered_message.header.widget_list == msg_box.header assert self.full_rendered_message.footer.widget_list == msg_box.footer @@ -556,6 +558,7 @@ def test_keypress_show_msg_info( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @@ -580,6 +583,7 @@ def mock_external_classes(self, mocker: MockerFixture, msg_box: MessageBox) -> N topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), title="Full Raw Message", ) @@ -590,6 +594,7 @@ def test_init(self, msg_box: MessageBox) -> None: assert self.full_raw_message.topic_links == OrderedDict() assert self.full_raw_message.message_links == OrderedDict() assert self.full_raw_message.time_mentions == list() + assert self.full_raw_message.spoilers == list() assert self.full_raw_message.header.widget_list == msg_box.header assert self.full_raw_message.footer.widget_list == msg_box.footer @@ -632,6 +637,7 @@ def test_keypress_show_msg_info( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @@ -655,6 +661,7 @@ def mock_external_classes(self, mocker: MockerFixture) -> None: topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), title="Edit History", ) @@ -664,6 +671,7 @@ def test_init(self) -> None: assert self.edit_history_view.topic_links == OrderedDict() assert self.edit_history_view.message_links == OrderedDict() assert self.edit_history_view.time_mentions == list() + assert self.edit_history_view.spoilers == list() self.controller.model.fetch_message_history.assert_called_once_with( message_id=self.message["id"], ) @@ -703,6 +711,7 @@ def test_keypress_show_msg_info( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @pytest.mark.parametrize( @@ -1019,6 +1028,7 @@ def mock_external_classes( OrderedDict(), OrderedDict(), list(), + list(), ) def test_init(self, message_fixture: Message) -> None: @@ -1026,6 +1036,7 @@ def test_init(self, message_fixture: Message) -> None: assert self.msg_info_view.topic_links == OrderedDict() assert self.msg_info_view.message_links == OrderedDict() assert self.msg_info_view.time_mentions == list() + assert self.msg_info_view.spoilers == list() def test_pop_up_info_order(self, message_fixture: Message) -> None: topic_links = OrderedDict([("https://bar.com", ("topic", 1, True))]) @@ -1037,6 +1048,7 @@ def test_pop_up_info_order(self, message_fixture: Message) -> None: topic_links=topic_links, message_links=message_links, time_mentions=list(), + spoilers=list(), ) msg_links = msg_info_view.button_widgets assert msg_links == [message_links, topic_links] @@ -1085,6 +1097,7 @@ def test_keypress_edit_history( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) size = widget_size(msg_info_view) @@ -1096,6 +1109,7 @@ def test_keypress_edit_history( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) else: self.controller.show_edit_history.assert_not_called() @@ -1114,6 +1128,7 @@ def test_keypress_full_rendered_message( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) size = widget_size(msg_info_view) @@ -1124,6 +1139,7 @@ def test_keypress_full_rendered_message( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @pytest.mark.parametrize("key", keys_for_command("FULL_RAW_MESSAGE")) @@ -1140,6 +1156,7 @@ def test_keypress_full_raw_message( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) size = widget_size(msg_info_view) @@ -1150,6 +1167,7 @@ def test_keypress_full_raw_message( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @pytest.mark.parametrize( @@ -1251,6 +1269,7 @@ def test_height_reactions( OrderedDict(), OrderedDict(), list(), + list(), ) # 12 = 7 labels + 2 blank lines + 1 'Reactions' (category) # + 4 reactions (excluding 'Message Links'). diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 2976eede9b..3eb27a3416 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -265,6 +265,7 @@ def show_msg_info( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: msg_info_view = MsgInfoView( self, @@ -273,6 +274,7 @@ def show_msg_info( topic_links, message_links, time_mentions, + spoilers, ) self.show_pop_up(msg_info_view, "area:msg") @@ -343,6 +345,7 @@ def show_full_rendered_message( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: self.show_pop_up( FullRenderedMsgView( @@ -351,6 +354,7 @@ def show_full_rendered_message( topic_links, message_links, time_mentions, + spoilers, f"Full rendered message {SCROLL_PROMPT}", ), "area:msg", @@ -362,6 +366,7 @@ def show_full_raw_message( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: self.show_pop_up( FullRawMsgView( @@ -370,6 +375,7 @@ def show_full_raw_message( topic_links, message_links, time_mentions, + spoilers, f"Full raw message {SCROLL_PROMPT}", ), "area:msg", @@ -381,6 +387,7 @@ def show_edit_history( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: self.show_pop_up( EditHistoryView( @@ -389,6 +396,7 @@ def show_edit_history( topic_links, message_links, time_mentions, + spoilers, f"Edit History {SCROLL_PROMPT}", ), "area:msg", diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index 10c28de4bb..db0e88e62e 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -1204,7 +1204,11 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.model.controller.view.middle_column.set_focus("footer") elif is_command_key("MSG_INFO", key): self.model.controller.show_msg_info( - self.message, self.topic_links, self.message_links, self.time_mentions + self.message, + self.topic_links, + self.message_links, + self.time_mentions, + self.spoilers, ) elif is_command_key("ADD_REACTION", key): self.model.controller.show_emoji_picker(self.message) diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index c2143aaa17..509ab22e11 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -1584,11 +1584,13 @@ def __init__( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: self.msg = msg self.topic_links = topic_links self.message_links = message_links self.time_mentions = time_mentions + self.spoilers = spoilers self.server_url = controller.model.server_url date_and_time = controller.model.formatted_local_time( msg["timestamp"], show_seconds=True, show_year=True @@ -1733,6 +1735,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: topic_links=self.topic_links, message_links=self.message_links, time_mentions=self.time_mentions, + spoilers=self.spoilers, ) elif is_command_key("VIEW_IN_BROWSER", key): url = near_message_url(self.server_url[:-1], self.msg) @@ -1743,6 +1746,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: topic_links=self.topic_links, message_links=self.message_links, time_mentions=self.time_mentions, + spoilers=self.spoilers, ) return key elif is_command_key("FULL_RAW_MESSAGE", key): @@ -1751,6 +1755,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: topic_links=self.topic_links, message_links=self.message_links, time_mentions=self.time_mentions, + spoilers=self.spoilers, ) return key return super().keypress(size, key) @@ -1804,6 +1809,7 @@ def __init__( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], title: str, ) -> None: self.controller = controller @@ -1811,6 +1817,7 @@ def __init__( self.topic_links = topic_links self.message_links = message_links self.time_mentions = time_mentions + self.spoilers = spoilers width = 64 widgets: List[Any] = [] @@ -1909,6 +1916,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: topic_links=self.topic_links, message_links=self.message_links, time_mentions=self.time_mentions, + spoilers=self.spoilers, ) return key return super().keypress(size, key) @@ -1922,6 +1930,7 @@ def __init__( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], title: str, ) -> None: self.controller = controller @@ -1929,6 +1938,7 @@ def __init__( self.topic_links = topic_links self.message_links = message_links self.time_mentions = time_mentions + self.spoilers = spoilers max_cols, max_rows = controller.maximum_popup_dimensions() # Get rendered message @@ -1953,6 +1963,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: topic_links=self.topic_links, message_links=self.message_links, time_mentions=self.time_mentions, + spoilers=self.spoilers, ) return key return super().keypress(size, key) @@ -1966,6 +1977,7 @@ def __init__( topic_links: Dict[str, Tuple[str, int, bool]], message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], title: str, ) -> None: self.controller = controller @@ -1973,6 +1985,7 @@ def __init__( self.topic_links = topic_links self.message_links = message_links self.time_mentions = time_mentions + self.spoilers = spoilers max_cols, max_rows = controller.maximum_popup_dimensions() # Get rendered message header and footer @@ -2003,6 +2016,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: topic_links=self.topic_links, message_links=self.message_links, time_mentions=self.time_mentions, + spoilers=self.spoilers, ) return key return super().keypress(size, key) From 5ebab61180fe9df0614b7f887518ec3a1a64a636 Mon Sep 17 00:00:00 2001 From: rsashank Date: Sun, 30 Jun 2024 20:49:35 -0400 Subject: [PATCH 5/9] buttons: Add SpoilerButton class showing spoiler header. This commit adds a button that would allow user to view the spoiler content, i.e., onclick of button is SpoilerView popup. Tests added. Co-authored-by: Preet Mishra Co-authored-by: Ezio-Sarthak --- tests/ui_tools/test_buttons.py | 70 +++++++++++++++++++++++++++++++ zulipterminal/ui_tools/buttons.py | 30 +++++++++++++ 2 files changed, 100 insertions(+) diff --git a/tests/ui_tools/test_buttons.py b/tests/ui_tools/test_buttons.py index adc2faa127..51370625cd 100644 --- a/tests/ui_tools/test_buttons.py +++ b/tests/ui_tools/test_buttons.py @@ -14,6 +14,7 @@ MessageLinkButton, ParsedNarrowLink, PMButton, + SpoilerButton, StarredButton, StreamButton, TopButton, @@ -308,6 +309,75 @@ def test_keypress_USER_INFO( pop_up.assert_called_once_with(user_button.user_id) +class TestSpoilerButton: + @pytest.fixture(autouse=True) + def mock_external_classes(self, mocker: MockerFixture) -> None: + self.controller = mocker.Mock() + self.super_init = mocker.patch(MODULE + ".urwid.Button.__init__") + self.connect_signal = mocker.patch(MODULE + ".urwid.connect_signal") + + def spoiler_button( + self, + header_len: int = 0, + header: List[Any] = [""], + content: List[Any] = [""], + display_attr: Optional[str] = None, + ) -> SpoilerButton: + self.content = content + self.header_len = header_len + self.header = header + self.display_attr = display_attr + return SpoilerButton(self.controller, header_len, header, content, display_attr) + + def test_init(self, mocker: MockerFixture) -> None: + self.update_widget = mocker.patch(MODULE + ".SpoilerButton.update_widget") + + mocked_button = self.spoiler_button() + + assert mocked_button.controller == self.controller + assert mocked_button.content == self.content + self.super_init.assert_called_once_with("") + self.update_widget.assert_called_once_with( + self.header_len, self.header, self.display_attr + ) + assert self.connect_signal.called + + @pytest.mark.parametrize( + "header, header_len, expected_cursor_position", + [ + (["Test"], 4, 5), + (["Check"], 5, 6), + ], + ) + def test_update_widget( + self, + mocker: MockerFixture, + header: List[Any], + header_len: int, + expected_cursor_position: int, + display_attr: Optional[str] = None, + ) -> None: + self.selectable_icon = mocker.patch(MODULE + ".urwid.SelectableIcon") + + # The method update_widget() is called in SpoilerButton's init. + mocked_button = self.spoiler_button( + header=header, header_len=header_len, display_attr=display_attr + ) + self.selectable_icon.assert_called_once_with( + header, cursor_position=expected_cursor_position + ) + assert isinstance(mocked_button._w, AttrMap) + + def test_show_spoiler(self) -> None: + mocked_button = self.spoiler_button() + + mocked_button.show_spoiler() + + mocked_button.controller.show_spoiler.assert_called_once_with( + mocked_button.content + ) + + class TestEmojiButton: @pytest.mark.parametrize( "emoji_unit, to_vary_in_message, count", diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 91c428a2b7..93cf79916e 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -317,6 +317,36 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: return super().keypress(size, key) +class SpoilerButton(urwid.Button): + def __init__( + self, + controller: Any, + header_len: int, + header: List[Any], + content: List[Any], + display_attr: Optional[str], + ) -> None: + self.controller = controller + self.content = content + + super().__init__("") + self.update_widget(header_len, header, display_attr) + urwid.connect_signal(self, "click", callback=self.show_spoiler) + + def update_widget( + self, header_len: int, header: List[Any], display_attr: Optional[str] = None + ) -> None: + """ + Overrides the existing button widget for custom styling. + """ + # Set cursor position next to header_len to avoid the cursor. + icon = urwid.SelectableIcon(header, cursor_position=header_len + 1) + self._w = urwid.AttrMap(icon, display_attr, focus_map="selected") + + def show_spoiler(self, *_: Any) -> None: + self.controller.show_spoiler(self.content) + + class TopicButton(TopButton): def __init__( self, From 7469e693f4cc6d533f218cc82ed4dd5d305ff7e6 Mon Sep 17 00:00:00 2001 From: rsashank Date: Mon, 1 Jul 2024 18:14:28 -0400 Subject: [PATCH 6/9] views: Show SpoilerButton in message info view if present. This commit appends the SpoilerButton to message info view if present. Co-authored-by: Preet Mishra Co-authored-by: Ezio-Sarthak --- zulipterminal/ui_tools/views.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 509ab22e11..62fb917ef7 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -51,6 +51,7 @@ MentionedButton, MessageLinkButton, PMButton, + SpoilerButton, StarredButton, StreamButton, TopicButton, @@ -1646,6 +1647,8 @@ def __init__( msg_info.append(("Topic Links", [])) if time_mentions: msg_info.append(("Time mentions", time_mentions)) + if spoilers: + msg_info.append(("Spoilers", [])) if msg["reactions"]: reactions = sorted( (reaction["emoji_name"], reaction["user"]["full_name"]) @@ -1699,6 +1702,26 @@ def __init__( widgets = widgets[:slice_index] + topic_link_widgets + widgets[slice_index:] popup_width = max(popup_width, topic_link_width) + if spoilers: + spoiler_buttons = [] + spoiler_width = 0 + for index, (header_len, header, content) in enumerate(spoilers): + spoiler_width = max(header_len, spoiler_width) + display_attr = None if index % 2 else "popup_contrast" + spoiler_buttons.append( + SpoilerButton(controller, header_len, header, content, display_attr) + ) + + # slice_index = Number of labels before message links + 1 newline + # + 1 'Spoilers' category label. + # + 2 for Viewing Actions category label and its newline + slice_index = len(msg_info[0][1]) + len(msg_info[1][1]) + 2 + 2 + slice_index += sum([len(w) + 2 for w in self.button_widgets]) + self.button_widgets.append(spoiler_buttons) + + widgets = widgets[:slice_index] + spoiler_buttons + widgets[slice_index:] + popup_width = max(popup_width, spoiler_width) + super().__init__(controller, widgets, "MSG_INFO", popup_width, title) @staticmethod From 909f04204618a5040963de7ec4f72edbbc6ee32d Mon Sep 17 00:00:00 2001 From: rsashank Date: Tue, 2 Jul 2024 00:27:21 -0400 Subject: [PATCH 7/9] buttons/core/views: Pass message info data to spoiler classes. This commit: * Pass message info data to SpoilerButton and show_spoiler method. * Pass message info data to SpoilerView and return to message info view popup upon `Esc` and `Enter`. * Extract spoiler buttons processing to a create_spoiler_buttons. Tests amended. --- tests/ui_tools/test_buttons.py | 30 ++++++++++++- tests/ui_tools/test_popups.py | 20 ++++++++- zulipterminal/core.py | 22 +++++++++- zulipterminal/ui_tools/buttons.py | 19 +++++++- zulipterminal/ui_tools/views.py | 73 +++++++++++++++++++++++++++---- 5 files changed, 148 insertions(+), 16 deletions(-) diff --git a/tests/ui_tools/test_buttons.py b/tests/ui_tools/test_buttons.py index 51370625cd..644bc7d811 100644 --- a/tests/ui_tools/test_buttons.py +++ b/tests/ui_tools/test_buttons.py @@ -321,13 +321,34 @@ def spoiler_button( header_len: int = 0, header: List[Any] = [""], content: List[Any] = [""], + message: Message = {}, + topic_links: Dict[str, Tuple[str, int, bool]] = {}, + message_links: Dict[str, Tuple[str, int, bool]] = {}, + time_mentions: List[Tuple[str, str]] = [], + spoilers: List[Tuple[int, List[Any], List[Any]]] = [], display_attr: Optional[str] = None, ) -> SpoilerButton: self.content = content self.header_len = header_len self.header = header + self.message = message + self.topic_links = topic_links + self.message_links = message_links + self.time_mentions = time_mentions + self.spoilers = spoilers self.display_attr = display_attr - return SpoilerButton(self.controller, header_len, header, content, display_attr) + return SpoilerButton( + self.controller, + header_len, + header, + content, + message, + topic_links, + message_links, + time_mentions, + spoilers, + display_attr, + ) def test_init(self, mocker: MockerFixture) -> None: self.update_widget = mocker.patch(MODULE + ".SpoilerButton.update_widget") @@ -374,7 +395,12 @@ def test_show_spoiler(self) -> None: mocked_button.show_spoiler() mocked_button.controller.show_spoiler.assert_called_once_with( - mocked_button.content + mocked_button.content, + mocked_button.message, + mocked_button.topic_links, + mocked_button.message_links, + mocked_button.time_mentions, + mocked_button.spoilers, ) diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index 211a9629ae..1fe9247608 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -970,7 +970,17 @@ def mock_external_classes(self, mocker: MockerFixture) -> None: self.controller, "maximum_popup_dimensions", return_value=(64, 64) ) mocker.patch(MODULE + ".urwid.SimpleFocusListWalker", return_value=[]) - self.spoiler_view = SpoilerView(self.controller, "Spoiler View", "") + self.message = Message(id=1) + self.spoiler_view = SpoilerView( + self.controller, + "Spoiler View", + "", + self.message, + OrderedDict(), + OrderedDict(), + list(), + list(), + ) def test_keypress_any_key( self, widget_size: Callable[[Widget], urwid_Size] @@ -986,7 +996,13 @@ def test_keypress_exit_popup( ) -> None: size = widget_size(self.spoiler_view) self.spoiler_view.keypress(size, key) - assert self.controller.exit_popup.called + self.controller.show_msg_info.assert_called_once_with( + msg=self.message, + topic_links=OrderedDict(), + message_links=OrderedDict(), + time_mentions=list(), + spoilers=list(), + ) def test_keypress_navigation( self, diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 3eb27a3416..8215f52041 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -490,9 +490,27 @@ def report_warning( """ self.view.set_footer_text(text, "task:warning", duration) - def show_spoiler(self, content: str) -> None: + def show_spoiler( + self, + content: str, + message: Message, + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], + time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], + ) -> None: self.show_pop_up( - SpoilerView(self, "Spoiler (up/down scrolls)", content), "area:msg" + SpoilerView( + self, + "Spoiler (up/down scrolls)", + content, + message, + topic_links, + message_links, + time_mentions, + spoilers, + ), + "area:msg", ) def show_media_confirmation_popup( diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 93cf79916e..19a8295cb6 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -324,10 +324,20 @@ def __init__( header_len: int, header: List[Any], content: List[Any], + message: Message, + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], + time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], display_attr: Optional[str], ) -> None: self.controller = controller self.content = content + self.message = message + self.topic_links = topic_links + self.message_links = message_links + self.time_mentions = time_mentions + self.spoilers = spoilers super().__init__("") self.update_widget(header_len, header, display_attr) @@ -344,7 +354,14 @@ def update_widget( self._w = urwid.AttrMap(icon, display_attr, focus_map="selected") def show_spoiler(self, *_: Any) -> None: - self.controller.show_spoiler(self.content) + self.controller.show_spoiler( + self.content, + self.message, + self.topic_links, + self.message_links, + self.time_mentions, + self.spoilers, + ) class TopicButton(TopButton): diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 62fb917ef7..1df1461156 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -1078,12 +1078,39 @@ def __init__( class SpoilerView(PopUpView): - def __init__(self, controller: Any, title: str, content: str) -> None: + def __init__( + self, + controller: Any, + title: str, + content: str, + message: Message, + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], + time_mentions: List[Tuple[str, str]], + spoilers: List[Tuple[int, List[Any], List[Any]]], + ) -> None: + self.message = message + self.topic_links = topic_links + self.message_links = message_links + self.time_mentions = time_mentions + self.spoilers = spoilers width, _ = controller.maximum_popup_dimensions() widget = [urwid.Text(content)] super().__init__(controller, widget, "MSG_INFO", width, title) + def keypress(self, size: urwid_Size, key: str) -> str: + if is_command_key("EXIT_POPUP", key) or is_command_key("ACTIVATE_BUTTON", key): + self.controller.show_msg_info( + msg=self.message, + topic_links=self.topic_links, + message_links=self.message_links, + time_mentions=self.time_mentions, + spoilers=self.spoilers, + ) + return key + return super().keypress(size, key) + class AboutView(PopUpView): def __init__( @@ -1703,14 +1730,9 @@ def __init__( popup_width = max(popup_width, topic_link_width) if spoilers: - spoiler_buttons = [] - spoiler_width = 0 - for index, (header_len, header, content) in enumerate(spoilers): - spoiler_width = max(header_len, spoiler_width) - display_attr = None if index % 2 else "popup_contrast" - spoiler_buttons.append( - SpoilerButton(controller, header_len, header, content, display_attr) - ) + spoiler_buttons, spoiler_width = self.create_spoiler_buttons( + controller, spoilers + ) # slice_index = Number of labels before message links + 1 newline # + 1 'Spoilers' category label. @@ -1751,6 +1773,39 @@ def create_link_buttons( return link_widgets, link_width + def create_spoiler_buttons( + self, controller: Any, spoilers: List[Tuple[int, List[Any], List[Any]]] + ) -> Tuple[List[SpoilerButton], int]: + spoiler_buttons = [] + spoiler_width = 0 + + for index, (header_len, header, content) in enumerate(spoilers): + spoiler_width = max(header_len, spoiler_width) + + display_attr = None if index % 2 else "popup_contrast" + + processed_header = [f"{index+1}: "] + header + processed_header_len = sum( + len(part[1]) if isinstance(part, tuple) else len(part) + for part in processed_header + ) + + spoiler_buttons.append( + SpoilerButton( + controller, + processed_header_len, + processed_header, + header + ["\n\n"] + content, + self.msg, + self.topic_links, + self.message_links, + self.time_mentions, + self.spoilers, + display_attr, + ) + ) + return spoiler_buttons, spoiler_width + def keypress(self, size: urwid_Size, key: str) -> str: if is_command_key("EDIT_HISTORY", key) and self.show_edit_history_label: self.controller.show_edit_history( From 8c13265a279ec0e7daa1bd676b23d16f940a8eb3 Mon Sep 17 00:00:00 2001 From: rsashank Date: Thu, 11 Jul 2024 12:38:31 -0400 Subject: [PATCH 8/9] messages: Hide footlink for links in spoiler blocks. This commit sets show_footlink to False for links in spoiler blocks. --- zulipterminal/ui_tools/messages.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index db0e88e62e..274b485474 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -503,6 +503,8 @@ def soup2markup( # Do not show as a footlink as the text is sufficient # to represent the link. show_footlink = False + if element.find_parent("div", class_="spoiler-block"): + show_footlink = False # Detect duplicate links to save screen real estate. if link not in metadata["message_links"]: From 6f7fbdf8da1c52532221942f9c74ee328ad21d2d Mon Sep 17 00:00:00 2001 From: rsashank Date: Thu, 11 Jul 2024 17:18:29 -0400 Subject: [PATCH 9/9] buttons/core/messages/view: Add spoiler_link to metadata. This commit: * Adds a boolean value spoiler_link to metadata, indicating whether a link is part of spoiler content. * Adds [spoiler] to link caption if spoiler_link is True. Fixes #688. --- tests/ui_tools/test_buttons.py | 4 ++-- tests/ui_tools/test_messages.py | 25 ++++++++++++++----------- tests/ui_tools/test_popups.py | 18 +++++++++--------- zulipterminal/core.py | 20 ++++++++++---------- zulipterminal/ui_tools/buttons.py | 4 ++-- zulipterminal/ui_tools/messages.py | 27 ++++++++++++++++++--------- zulipterminal/ui_tools/views.py | 28 ++++++++++++++++------------ 7 files changed, 71 insertions(+), 55 deletions(-) diff --git a/tests/ui_tools/test_buttons.py b/tests/ui_tools/test_buttons.py index 644bc7d811..380ce821e0 100644 --- a/tests/ui_tools/test_buttons.py +++ b/tests/ui_tools/test_buttons.py @@ -322,8 +322,8 @@ def spoiler_button( header: List[Any] = [""], content: List[Any] = [""], message: Message = {}, - topic_links: Dict[str, Tuple[str, int, bool]] = {}, - message_links: Dict[str, Tuple[str, int, bool]] = {}, + topic_links: Dict[str, Tuple[str, int, bool, bool]] = {}, + message_links: Dict[str, Tuple[str, int, bool, bool]] = {}, time_mentions: List[Tuple[str, str]] = [], spoilers: List[Tuple[int, List[Any], List[Any]]] = [], display_attr: Optional[str] = None, diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py index f2f7825256..2c0c999fc0 100644 --- a/tests/ui_tools/test_messages.py +++ b/tests/ui_tools/test_messages.py @@ -1881,7 +1881,7 @@ def test_reactions_view( [ ( "https://github.com/zulip/zulip-terminal/pull/1", - ("#T1", 1, True), + ("#T1", 1, True, False), ), ] ), @@ -1893,8 +1893,8 @@ def test_reactions_view( case( OrderedDict( [ - ("https://foo.com", ("Foo!", 1, True)), - ("https://bar.com", ("Bar!", 2, True)), + ("https://foo.com", ("Foo!", 1, True, False)), + ("https://bar.com", ("Bar!", 2, True, False)), ] ), "1: https://foo.com\n2: https://bar.com", @@ -1913,8 +1913,11 @@ def test_reactions_view( case( OrderedDict( [ - ("https://example.com", ("https://example.com", 1, False)), - ("http://example.com", ("http://example.com", 2, False)), + ( + "https://example.com", + ("https://example.com", 1, False, False), + ), + ("http://example.com", ("http://example.com", 2, False, False)), ] ), None, @@ -1925,8 +1928,8 @@ def test_reactions_view( case( OrderedDict( [ - ("https://foo.com", ("https://foo.com, Text", 1, True)), - ("https://bar.com", ("Text, https://bar.com", 2, True)), + ("https://foo.com", ("https://foo.com, Text", 1, True, False)), + ("https://bar.com", ("Text, https://bar.com", 2, True, False)), ] ), "1: https://foo.com\n2: https://bar.com", @@ -1945,9 +1948,9 @@ def test_reactions_view( case( OrderedDict( [ - ("https://foo.com", ("Foo!", 1, True)), - ("http://example.com", ("example.com", 2, False)), - ("https://bar.com", ("Bar!", 3, True)), + ("https://foo.com", ("Foo!", 1, True, False)), + ("http://example.com", ("example.com", 2, False, False)), + ("https://bar.com", ("Bar!", 3, True, False)), ] ), "1: https://foo.com\n3: https://bar.com", @@ -1994,7 +1997,7 @@ def test_footlinks_view( def test_footlinks_limit(self, maximum_footlinks, expected_instance): message_links = OrderedDict( [ - ("https://github.com/zulip/zulip-terminal", ("ZT", 1, True)), + ("https://github.com/zulip/zulip-terminal", ("ZT", 1, True, False)), ] ) diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index 1fe9247608..9041a3767a 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -1054,9 +1054,9 @@ def test_init(self, message_fixture: Message) -> None: assert self.msg_info_view.time_mentions == list() assert self.msg_info_view.spoilers == list() - def test_pop_up_info_order(self, message_fixture: Message) -> None: - topic_links = OrderedDict([("https://bar.com", ("topic", 1, True))]) - message_links = OrderedDict([("image.jpg", ("image", 1, True))]) + def test_popup_info_order(self, message_fixture: Message) -> None: + topic_links = OrderedDict([("https://bar.com", ("topic", 1, True, False))]) + message_links = OrderedDict([("image.jpg", ("image", 1, True, False))]) msg_info_view = MsgInfoView( self.controller, message_fixture, @@ -1302,14 +1302,14 @@ def test_height_reactions( ], [ ( - OrderedDict([("https://bar.com", ("Foo", 1, True))]), + OrderedDict([("https://bar.com", ("Foo", 1, True, False))]), "1: Foo\nhttps://bar.com", {None: "popup_contrast"}, {None: "selected"}, 15, ), ( - OrderedDict([("https://foo.com", ("", 1, True))]), + OrderedDict([("https://foo.com", ("", 1, True, False))]), "1: https://foo.com", {None: "popup_contrast"}, {None: "selected"}, @@ -1323,7 +1323,7 @@ def test_height_reactions( ) def test_create_link_buttons( self, - initial_link: "OrderedDict[str, Tuple[str, int, bool]]", + initial_link: "OrderedDict[str, Tuple[str, int, bool, bool]]", expected_text: str, expected_attr_map: Dict[None, str], expected_focus_map: Dict[None, str], @@ -1588,8 +1588,8 @@ def test_markup_description( ( OrderedDict( [ - ("https://example.com", ("Example", 1, True)), - ("https://generic.com", ("Generic", 2, True)), + ("https://example.com", ("Example", 1, True, False)), + ("https://generic.com", ("Generic", 2, True, False)), ] ), "1: https://example.com\n2: https://generic.com", @@ -1608,7 +1608,7 @@ def test_markup_description( ) def test_footlinks( self, - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + message_links: "OrderedDict[str, Tuple[str, int, bool, bool]]", expected_text: str, expected_attrib: List[Tuple[Optional[str], int]], expected_footlinks_width: int, diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 8215f52041..6f313b72c5 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -262,8 +262,8 @@ def show_topic_edit_mode(self, button: Any) -> None: def show_msg_info( self, msg: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: @@ -342,8 +342,8 @@ def show_msg_sender_info(self, user_id: int) -> None: def show_full_rendered_message( self, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: @@ -363,8 +363,8 @@ def show_full_rendered_message( def show_full_raw_message( self, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: @@ -384,8 +384,8 @@ def show_full_raw_message( def show_edit_history( self, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: @@ -494,8 +494,8 @@ def show_spoiler( self, content: str, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 19a8295cb6..7003d230d6 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -325,8 +325,8 @@ def __init__( header: List[Any], content: List[Any], message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], display_attr: Optional[str], diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index 274b485474..e695dbd4cf 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -60,8 +60,8 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None: self.topic_name = "" self.email = "" # FIXME: Can we remove this? self.user_id: Optional[int] = None - self.message_links: Dict[str, Tuple[str, int, bool]] = dict() - self.topic_links: Dict[str, Tuple[str, int, bool]] = dict() + self.message_links: Dict[str, Tuple[str, int, bool, bool]] = dict() + self.topic_links: Dict[str, Tuple[str, int, bool, bool]] = dict() self.time_mentions: List[Tuple[str, str]] = list() self.spoilers: List[Tuple[int, List[Any], List[Any]]] = list() self.last_message = last_message @@ -77,6 +77,7 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None: link["text"], len(self.topic_links) + 1, True, + False, ) self.stream_name = self.message["display_recipient"] @@ -314,7 +315,7 @@ def reactions_view( @staticmethod def footlinks_view( - message_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], *, maximum_footlinks: int, padded: bool, @@ -331,7 +332,7 @@ def footlinks_view( footlinks = [] counter = 0 footlinks_width = 0 - for link, (text, index, show_footlink) in message_links.items(): + for link, (text, index, show_footlink, spoiler_link) in message_links.items(): if counter == maximum_footlinks: break if not show_footlink: @@ -374,7 +375,7 @@ def soup2markup( cls, soup: Any, metadata: Dict[str, Any], **state: Any ) -> Tuple[ List[Any], - Dict[str, Tuple[str, int, bool]], + Dict[str, Tuple[str, int, bool, bool]], List[Tuple[str, str]], List[Tuple[int, List[Any], List[Any]]], ]: @@ -503,8 +504,11 @@ def soup2markup( # Do not show as a footlink as the text is sufficient # to represent the link. show_footlink = False + + spoiler_link = False if element.find_parent("div", class_="spoiler-block"): show_footlink = False + spoiler_link = True # Detect duplicate links to save screen real estate. if link not in metadata["message_links"]: @@ -512,18 +516,23 @@ def soup2markup( text, len(metadata["message_links"]) + 1, show_footlink, + spoiler_link, ) else: # Append the text if its link already exist with a # different text. - saved_text, saved_link_index, saved_footlink_status = metadata[ - "message_links" - ][link] + ( + saved_text, + saved_link_index, + saved_footlink_status, + spoiler_link, + ) = metadata["message_links"][link] if saved_text != text: metadata["message_links"][link] = ( f"{saved_text}, {text}", saved_link_index, show_footlink or saved_footlink_status, + spoiler_link, ) markup.extend( @@ -900,7 +909,7 @@ def transform_content( cls, content: Any, server_url: str ) -> Tuple[ Tuple[None, Any], - Dict[str, Tuple[str, int, bool]], + Dict[str, Tuple[str, int, bool, bool]], List[Tuple[str, str]], List[Tuple[int, List[Any], List[Any]]], ]: diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 1df1461156..2b209e8236 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -1084,8 +1084,8 @@ def __init__( title: str, content: str, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: @@ -1609,8 +1609,8 @@ def __init__( controller: Any, msg: Message, title: str, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], ) -> None: @@ -1748,17 +1748,21 @@ def __init__( @staticmethod def create_link_buttons( - controller: Any, links: Dict[str, Tuple[str, int, bool]] + controller: Any, links: Dict[str, Tuple[str, int, bool, bool]] ) -> Tuple[List[MessageLinkButton], int]: link_widgets = [] link_width = 0 for index, link in enumerate(links): - text, link_index, _ = links[link] + text, link_index, _, spoiler_link = links[link] if text: caption = f"{link_index}: {text}\n{link}" + if spoiler_link: + caption = f"{link_index} [spoiler]: {text}\n{link}" else: caption = f"{link_index}: {link}" + if spoiler_link: + caption = f"{link_index} [spoiler]: {link}" link_width = max(link_width, len(max(caption.split("\n"), key=len))) display_attr = None if index % 2 else "popup_contrast" @@ -1884,8 +1888,8 @@ def __init__( self, controller: Any, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], title: str, @@ -2005,8 +2009,8 @@ def __init__( self, controller: Any, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], title: str, @@ -2052,8 +2056,8 @@ def __init__( self, controller: Any, message: Message, - topic_links: Dict[str, Tuple[str, int, bool]], - message_links: Dict[str, Tuple[str, int, bool]], + topic_links: Dict[str, Tuple[str, int, bool, bool]], + message_links: Dict[str, Tuple[str, int, bool, bool]], time_mentions: List[Tuple[str, str]], spoilers: List[Tuple[int, List[Any], List[Any]]], title: str,