diff --git a/tests/ui_tools/test_buttons.py b/tests/ui_tools/test_buttons.py index adc2faa127..380ce821e0 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,101 @@ 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] = [""], + message: Message = {}, + 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, + ) -> 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, + 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") + + 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, + mocked_button.message, + mocked_button.topic_links, + mocked_button.message_links, + mocked_button.time_mentions, + mocked_button.spoilers, + ) + + class TestEmojiButton: @pytest.mark.parametrize( "emoji_unit, to_vary_in_message, count", diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py index 08ba7d0661..2c0c999fc0 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): @@ -680,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, ) @@ -1834,7 +1881,7 @@ def test_reactions_view( [ ( "https://github.com/zulip/zulip-terminal/pull/1", - ("#T1", 1, True), + ("#T1", 1, True, False), ), ] ), @@ -1846,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", @@ -1866,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, @@ -1878,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", @@ -1898,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", @@ -1947,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 d58b6c3353..9041a3767a 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, @@ -503,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", ) @@ -513,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 @@ -555,6 +558,7 @@ def test_keypress_show_msg_info( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @@ -579,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", ) @@ -589,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 @@ -631,6 +637,7 @@ def test_keypress_show_msg_info( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @@ -654,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", ) @@ -663,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"], ) @@ -702,6 +711,7 @@ def test_keypress_show_msg_info( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @pytest.mark.parametrize( @@ -952,6 +962,61 @@ 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.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] + ) -> 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) + 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, + 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( @@ -979,6 +1044,7 @@ def mock_external_classes( OrderedDict(), OrderedDict(), list(), + list(), ) def test_init(self, message_fixture: Message) -> None: @@ -986,10 +1052,11 @@ 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))]) - 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, @@ -997,6 +1064,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] @@ -1045,6 +1113,7 @@ def test_keypress_edit_history( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) size = widget_size(msg_info_view) @@ -1056,6 +1125,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() @@ -1074,6 +1144,7 @@ def test_keypress_full_rendered_message( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) size = widget_size(msg_info_view) @@ -1084,6 +1155,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")) @@ -1100,6 +1172,7 @@ def test_keypress_full_raw_message( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) size = widget_size(msg_info_view) @@ -1110,6 +1183,7 @@ def test_keypress_full_raw_message( topic_links=OrderedDict(), message_links=OrderedDict(), time_mentions=list(), + spoilers=list(), ) @pytest.mark.parametrize( @@ -1211,6 +1285,7 @@ def test_height_reactions( OrderedDict(), OrderedDict(), list(), + list(), ) # 12 = 7 labels + 2 blank lines + 1 'Reactions' (category) # + 4 reactions (excluding 'Message Links'). @@ -1227,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"}, @@ -1248,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], @@ -1513,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", @@ -1533,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/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/core.py b/zulipterminal/core.py index 99d6cac23a..6f313b72c5 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -42,6 +42,7 @@ MsgInfoView, NoticeView, PopUpConfirmationView, + SpoilerView, StreamInfoView, StreamMembersView, UserInfoView, @@ -261,9 +262,10 @@ 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: msg_info_view = MsgInfoView( self, @@ -272,6 +274,7 @@ def show_msg_info( topic_links, message_links, time_mentions, + spoilers, ) self.show_pop_up(msg_info_view, "area:msg") @@ -339,9 +342,10 @@ 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: self.show_pop_up( FullRenderedMsgView( @@ -350,6 +354,7 @@ def show_full_rendered_message( topic_links, message_links, time_mentions, + spoilers, f"Full rendered message {SCROLL_PROMPT}", ), "area:msg", @@ -358,9 +363,10 @@ 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: self.show_pop_up( FullRawMsgView( @@ -369,6 +375,7 @@ def show_full_raw_message( topic_links, message_links, time_mentions, + spoilers, f"Full raw message {SCROLL_PROMPT}", ), "area:msg", @@ -377,9 +384,10 @@ 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: self.show_pop_up( EditHistoryView( @@ -388,6 +396,7 @@ def show_edit_history( topic_links, message_links, time_mentions, + spoilers, f"Edit History {SCROLL_PROMPT}", ), "area:msg", @@ -481,6 +490,29 @@ def report_warning( """ self.view.set_footer_text(text, "task:warning", duration) + def show_spoiler( + self, + content: str, + message: Message, + 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: + self.show_pop_up( + SpoilerView( + self, + "Spoiler (up/down scrolls)", + content, + message, + topic_links, + message_links, + time_mentions, + spoilers, + ), + "area:msg", + ) + def show_media_confirmation_popup( self, func: Any, tool: str, media_path: str ) -> None: 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/buttons.py b/zulipterminal/ui_tools/buttons.py index 91c428a2b7..7003d230d6 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -317,6 +317,53 @@ 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], + message: Message, + 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: + 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) + 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, + self.message, + self.topic_links, + self.message_links, + self.time_mentions, + self.spoilers, + ) + + class TopicButton(TopButton): def __init__( self, diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index b8552fdc92..e695dbd4cf 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 @@ -60,9 +60,10 @@ 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 # if this is the first message if self.last_message is None: @@ -76,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"] @@ -313,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, @@ -330,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: @@ -371,12 +373,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, 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 @@ -493,24 +505,34 @@ def soup2markup( # 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"]: metadata["message_links"][link] = ( 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( @@ -632,9 +654,76 @@ 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 + ) + 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: + 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) + # 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 @@ -730,9 +819,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"]: @@ -817,8 +909,9 @@ 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]]], ]: soup = BeautifulSoup(content, "lxml") body = soup.find(name="body") @@ -827,13 +920,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: @@ -1121,7 +1215,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 c2034b3ef7..2b209e8236 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -51,6 +51,7 @@ MentionedButton, MessageLinkButton, PMButton, + SpoilerButton, StarredButton, StreamButton, TopicButton, @@ -1076,6 +1077,41 @@ def __init__( super().__init__(controller, widgets, "EXIT_POPUP", width, title) +class SpoilerView(PopUpView): + def __init__( + self, + controller: Any, + title: str, + content: str, + message: Message, + 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: + 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__( self, @@ -1421,7 +1457,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, ) @@ -1573,14 +1609,16 @@ 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: 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 @@ -1636,6 +1674,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"]) @@ -1689,21 +1729,40 @@ 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 = self.create_spoiler_buttons( + controller, spoilers + ) + + # 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 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" @@ -1718,6 +1777,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( @@ -1725,6 +1817,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) @@ -1735,6 +1828,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): @@ -1743,6 +1837,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) @@ -1793,9 +1888,10 @@ 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, ) -> None: self.controller = controller @@ -1803,6 +1899,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] = [] @@ -1901,6 +1998,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) @@ -1911,9 +2009,10 @@ 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, ) -> None: self.controller = controller @@ -1921,6 +2020,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 @@ -1945,6 +2045,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) @@ -1955,9 +2056,10 @@ 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, ) -> None: self.controller = controller @@ -1965,6 +2067,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 @@ -1995,6 +2098,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)