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(
+ '",
+ [
+ "┌─",
+ "────────",
+ "─┬─",
+ "──────",
+ "─┐\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)