From 88487e64ebecf119dd48796d1c1d29b527c684ae Mon Sep 17 00:00:00 2001 From: Vishwesh Pillai Date: Fri, 7 Jul 2023 18:46:07 +0530 Subject: [PATCH 01/12] buttons/views: Replace stream view with stream panel on left sidebar. This commit reworks the stream view section in the left sidebar by replacing it with the new Stream Panel widget. The stream panel consists of the newly added stream messages button (StreamPanelButton), and the existing stream/topic view section. Test added, updated and renamed. --- tests/ui/test_ui_tools.py | 8 +++- tests/ui_tools/test_buttons.py | 12 ++++++ zulipterminal/ui_tools/buttons.py | 12 ++++++ zulipterminal/ui_tools/views.py | 69 ++++++++++++++++--------------- 4 files changed, 66 insertions(+), 35 deletions(-) diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py index 0a4d9ec030..d45ce19c29 100644 --- a/tests/ui/test_ui_tools.py +++ b/tests/ui/test_ui_tools.py @@ -1156,6 +1156,7 @@ def test_menu_view(self, mocker): starred_button = mocker.patch(VIEWS + ".StarredButton") mocker.patch(VIEWS + ".urwid.ListBox") mocker.patch(VIEWS + ".urwid.SimpleFocusListWalker") + mocker.patch(VIEWS + ".urwid.LineBox") mocker.patch(VIEWS + ".StreamButton.mark_muted") left_col_view = LeftColumnView(self.view) home_button.assert_called_once_with( @@ -1167,16 +1168,21 @@ def test_menu_view(self, mocker): ) @pytest.mark.parametrize("pinned", powerset([1, 2, 99, 999, 1000])) - def test_streams_view(self, mocker, streams, pinned): + def test_stream_panel(self, mocker, streams, pinned): self.view.unpinned_streams = [s for s in streams if s["id"] not in pinned] self.view.pinned_streams = [s for s in streams if s["id"] in pinned] stream_button = mocker.patch(VIEWS + ".StreamButton") + stream_panel_button = mocker.patch(VIEWS + ".StreamPanelButton") mocker.patch(VIEWS + ".StreamsView") mocker.patch(VIEWS + ".urwid.LineBox") divider = mocker.patch(VIEWS + ".StreamsViewDivider") LeftColumnView(self.view) + stream_panel_button.assert_called_once_with( + controller=self.view.controller, count=mocker.ANY + ) + if pinned: assert divider.called else: diff --git a/tests/ui_tools/test_buttons.py b/tests/ui_tools/test_buttons.py index adc2faa127..9efe034cce 100644 --- a/tests/ui_tools/test_buttons.py +++ b/tests/ui_tools/test_buttons.py @@ -16,6 +16,7 @@ PMButton, StarredButton, StreamButton, + StreamPanelButton, TopButton, TopicButton, UserButton, @@ -198,6 +199,17 @@ def test_count_style_init_argument_value( assert starred_button.suffix_style == "starred_count" +class TestStreamPanelButton: + def test_button_text_length(self, mocker: MockerFixture, count: int = 10) -> None: + stream_panel_button = StreamPanelButton(controller=mocker.Mock(), count=count) + assert len(stream_panel_button.label_text) == 20 + + def test_button_text_title(self, mocker: MockerFixture, count: int = 10) -> None: + stream_panel_button = StreamPanelButton(controller=mocker.Mock(), count=count) + title_text = stream_panel_button.label_text[:-3].strip() + assert title_text == "Stream messages" + + class TestStreamButton: def test_mark_muted( self, mocker: MockerFixture, stream_button: StreamButton diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 91c428a2b7..65a8a0bf0d 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -157,6 +157,18 @@ def __init__(self, *, controller: Any, count: int) -> None: ) +class StreamPanelButton(TopButton): + def __init__(self, *, controller: Any, count: int) -> None: + button_text = "Stream messages " + super().__init__( + controller=controller, + label_markup=(None, button_text), + suffix_markup=("unread_count", ""), + show_function=lambda: None, + count=count, + ) + + class MentionedButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: button_text = ( diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index c2034b3ef7..3b1ec410c5 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -53,6 +53,7 @@ PMButton, StarredButton, StreamButton, + StreamPanelButton, TopicButton, UserButton, ) @@ -318,6 +319,21 @@ def __init__(self) -> None: super().__init__(div_char=PINNED_STREAMS_DIVIDER) +class StreamPanel(urwid.Pile): + def __init__(self, submenu_view: List[Any], view: Any) -> None: + self.view = view + self.view.stream_button = StreamPanelButton( + controller=self.view.controller, count=0 + ) + self._contents = [ + ("pack", self.view.stream_button), + ("pack", urwid.Divider(div_char=SECTION_DIVIDER_LINE)), + submenu_view, + ] + + super().__init__(self.contents, focus_item=2) + + class StreamsView(urwid.Frame): def __init__(self, streams_btn_list: List[Any], view: Any) -> None: self.view = view @@ -783,9 +799,13 @@ def __init__(self, view: Any) -> None: self.controller = view.controller self.menu_v = self.menu_view() self.stream_v = self.streams_view() - + self.stream_panel = self.streams_panel(self.stream_v) self.is_in_topic_view = False - contents = [(4, self.menu_v), self.stream_v] + contents = [ + (4, self.menu_v), + ("pack", urwid.Divider(COLUMN_TITLE_BAR_LINE)), + self.stream_panel, + ] super().__init__(contents) def menu_view(self) -> Any: @@ -814,6 +834,10 @@ def menu_view(self) -> Any: w = urwid.ListBox(urwid.SimpleFocusListWalker(menu_btn_list)) return w + def streams_panel(self, submenu_view: Any) -> Any: + self.view.stream_p = StreamPanel(submenu_view, self.view) + return self.view.stream_p + def streams_view(self) -> Any: streams_btn_list = [ StreamButton( @@ -845,20 +869,7 @@ def streams_view(self) -> Any: } self.view.stream_w = StreamsView(streams_btn_list, self.view) - w = urwid.LineBox( - self.view.stream_w, - title="Streams", - title_attr="column_title", - tlcorner=COLUMN_TITLE_BAR_LINE, - tline=COLUMN_TITLE_BAR_LINE, - trcorner=COLUMN_TITLE_BAR_LINE, - blcorner="", - rline="", - lline="", - bline="", - brcorner="", - ) - return w + return self.view.stream_w def topics_view(self, stream_button: Any) -> Any: stream_id = stream_button.stream_id @@ -877,20 +888,7 @@ def topics_view(self, stream_button: Any) -> Any: ] self.view.topic_w = TopicsView(topics_btn_list, self.view, stream_button) - w = urwid.LineBox( - self.view.topic_w, - title="Topics", - title_attr="column_title", - tlcorner=COLUMN_TITLE_BAR_LINE, - tline=COLUMN_TITLE_BAR_LINE, - trcorner=COLUMN_TITLE_BAR_LINE, - blcorner="", - rline="", - lline="", - bline="", - brcorner="", - ) - return w + return self.view.topic_w def is_in_topic_view_with_stream_id(self, stream_id: int) -> bool: return ( @@ -905,12 +903,14 @@ def update_stream_view(self) -> None: def show_stream_view(self) -> None: self.is_in_topic_view = False - self.contents[1] = (self.stream_v, self.options(height_type="weight")) + self.stream_panel = self.streams_panel(self.stream_v) + self.contents[2] = (self.stream_panel, self.options(height_type="weight")) def show_topic_view(self, stream_button: Any) -> None: self.is_in_topic_view = True - self.contents[1] = ( - self.topics_view(stream_button), + self.stream_panel = self.streams_panel(self.topics_view(stream_button)) + self.contents[2] = ( + self.stream_panel, self.options(height_type="weight"), ) @@ -918,7 +918,8 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if is_command_key("SEARCH_STREAMS", key) or is_command_key( "SEARCH_TOPICS", key ): - self.focus_position = 1 + self.focus_position = 2 + self.view.stream_p.focus_position = 2 if self.is_in_topic_view: self.view.topic_w.keypress(size, key) else: From 2c846b6b9c936253d37b9f143410b9e1eef6614e Mon Sep 17 00:00:00 2001 From: vishwesh Date: Fri, 1 Sep 2023 20:06:42 -0700 Subject: [PATCH 02/12] boxes/views: Add context label parameter to PanelSearchBox. This commit adds a label parameter to the PanelSearchBox to provide context to the user which searching the streams/topics list. Tests updated. --- tests/ui/test_ui_tools.py | 4 ++-- zulipterminal/ui_tools/boxes.py | 19 +++++++++++++++---- zulipterminal/ui_tools/views.py | 10 ++++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py index d45ce19c29..2084a4c9c6 100644 --- a/tests/ui/test_ui_tools.py +++ b/tests/ui/test_ui_tools.py @@ -488,7 +488,7 @@ def test_init(self, mocker, stream_view): assert stream_view.streams_btn_list == self.streams_btn_list assert stream_view.stream_search_box self.stream_search_box.assert_called_once_with( - stream_view, "SEARCH_STREAMS", stream_view.update_streams + stream_view, "SEARCH_STREAMS", stream_view.update_streams, label="streams" ) @pytest.mark.parametrize( @@ -605,7 +605,7 @@ def test_init(self, mocker, topic_view): assert topic_view.view == self.view assert topic_view.topic_search_box self.topic_search_box.assert_called_once_with( - topic_view, "SEARCH_TOPICS", topic_view.update_topics + topic_view, "SEARCH_TOPICS", topic_view.update_topics, label="topics" ) self.header_list.assert_called_once_with( [ diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index fa3dc1ffd6..024b15653e 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -970,13 +970,24 @@ class PanelSearchBox(ReadlineEdit): """ def __init__( - self, panel_view: Any, search_command: str, update_function: Callable[..., None] + self, + panel_view: Any, + search_command: str, + update_function: Callable[..., None], + label: Optional[str] = None, ) -> None: self.panel_view = panel_view self.search_command = search_command - self.search_text = ( - f" Search [{', '.join(display_keys_for_command(search_command))}]: " - ) + if label: + self.search_text = ( + f" Search {label} " + f"[{', '.join(display_keys_for_command(search_command))}]: " + ) + else: + self.search_text = ( + f" Search " + f"[{', '.join(display_keys_for_command(search_command))}]: " + ) self.search_error = urwid.AttrMap( urwid.Text([" ", INVALID_MARKER, " No Results"]), "search_error" ) diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 3b1ec410c5..78518edc1e 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -342,7 +342,10 @@ def __init__(self, streams_btn_list: List[Any], view: Any) -> None: self.focus_index_before_search = 0 list_box = urwid.ListBox(self.log) self.stream_search_box = PanelSearchBox( - self, "SEARCH_STREAMS", self.update_streams + self, + "SEARCH_STREAMS", + self.update_streams, + label="streams", ) super().__init__( list_box, @@ -436,7 +439,10 @@ def __init__( self.focus_index_before_search = 0 self.list_box = urwid.ListBox(self.log) self.topic_search_box = PanelSearchBox( - self, "SEARCH_TOPICS", self.update_topics + self, + "SEARCH_TOPICS", + self.update_topics, + label="topics", ) self.header_list = urwid.Pile( [ From afd829c7d8a7aa755b32e4700989b182815400bf Mon Sep 17 00:00:00 2001 From: Vishwesh Pillai Date: Fri, 7 Jul 2023 18:16:54 +0530 Subject: [PATCH 03/12] helper/views: Add all_streams unread count to the UnreadCounts dict. This commit adds an all_streams key to the UnreadCounts dict to store the unread stream messages count. This is subsequently used by the StreamPanelButton in the stream panel. Test case added and tests updated. --- tests/conftest.py | 1 + tests/helper/test_helper.py | 14 ++++++++++++++ zulipterminal/helper.py | 7 +++++++ zulipterminal/ui_tools/views.py | 3 ++- 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index ad92c4cc71..e0f632acfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1469,6 +1469,7 @@ def classified_unread_counts() -> Dict[str, Any]: return { "all_msg": 12, "all_pms": 8, + "all_stream_msg": 4, "unread_topics": { (1000, "Some general unread topic"): 3, (99, "Some private unread topic"): 1, diff --git a/tests/helper/test_helper.py b/tests/helper/test_helper.py index 2e32e5c10f..eb1cd7952b 100644 --- a/tests/helper/test_helper.py +++ b/tests/helper/test_helper.py @@ -288,6 +288,7 @@ def test_sort_unread_topics( [["Some general stream", "Some general unread topic"]], { "all_msg": 8, + "all_stream_msg": 0, "streams": {99: 1}, "unread_topics": {(99, "Some private unread topic"): 1}, "all_mentions": 0, @@ -298,17 +299,30 @@ def test_sort_unread_topics( [["Secret stream", "Some private unread topic"]], { "all_msg": 8, + "all_stream_msg": 0, "streams": {1000: 3}, "unread_topics": {(1000, "Some general unread topic"): 3}, "all_mentions": 0, }, ), ({1}, [], {"all_mentions": 0}), + ( + {}, + [["Some general stream", "Some general unread topic"]], + { + "all_msg": 9, + "all_stream_msg": 1, + "streams": {99: 1}, + "unread_topics": {(99, "Some private unread topic"): 1}, + "all_mentions": 0, + }, + ), ], ids=[ "mute_private_stream_mute_general_stream_topic", "mute_general_stream_mute_private_stream_topic", "no_mute_some_other_stream_muted", + "mute_general_stream_topic", ], ) def test_classify_unread_counts( diff --git a/zulipterminal/helper.py b/zulipterminal/helper.py index a71d055b74..c5af90d104 100644 --- a/zulipterminal/helper.py +++ b/zulipterminal/helper.py @@ -136,6 +136,7 @@ class Index(TypedDict): class UnreadCounts(TypedDict): all_msg: int all_pms: int + all_stream_msg: int all_mentions: int unread_topics: Dict[Tuple[int, str], int] # stream_id, topic unread_pms: Dict[int, int] # sender_id @@ -239,6 +240,7 @@ def _set_count_in_view( user_buttons_list = controller.view.user_w.users_btn_list all_msg = controller.view.home_button all_pm = controller.view.pm_button + all_stream = controller.view.stream_button all_mentioned = controller.view.mentioned_button for message in changed_messages: user_id = message["sender_id"] @@ -272,6 +274,9 @@ def _set_count_in_view( for topic_button in topic_buttons_list: if topic_button.topic_name == msg_topic: topic_button.update_count(topic_button.count + new_count) + if add_to_counts: + unread_counts["all_stream_msg"] += new_count + all_stream.update_count(unread_counts["all_stream_msg"]) else: for user_button in user_buttons_list: if user_button.user_id == user_id: @@ -488,6 +493,7 @@ def classify_unread_counts(model: Any) -> UnreadCounts: unread_counts = UnreadCounts( all_msg=0, all_pms=0, + all_stream_msg=0, all_mentions=0, unread_topics=dict(), unread_pms=dict(), @@ -520,6 +526,7 @@ def classify_unread_counts(model: Any) -> UnreadCounts: unread_counts["streams"][stream_id] += count if stream_id not in model.muted_streams: unread_counts["all_msg"] += count + unread_counts["all_stream_msg"] += count # store unread count of group pms in `unread_huddles` for group_pm in unread_msg_counts["huddles"]: diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 78518edc1e..3c5aeb0e35 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -322,8 +322,9 @@ def __init__(self) -> None: class StreamPanel(urwid.Pile): def __init__(self, submenu_view: List[Any], view: Any) -> None: self.view = view + count = self.view.model.unread_counts.get("all_stream_msg", 0) self.view.stream_button = StreamPanelButton( - controller=self.view.controller, count=0 + controller=self.view.controller, count=count ) self._contents = [ ("pack", self.view.stream_button), From 781962f7623747a72ccc755a2c2719b1183a66c7 Mon Sep 17 00:00:00 2001 From: "neiljp (Neil Pilgrim)" Date: Thu, 11 Jul 2024 10:02:31 -0700 Subject: [PATCH 04/12] fixup: Make StreamPanelButton not selectable for now. --- zulipterminal/ui_tools/buttons.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 65a8a0bf0d..8239d80a4f 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -168,6 +168,9 @@ def __init__(self, *, controller: Any, count: int) -> None: count=count, ) + def selectable(self) -> bool: + return False + class MentionedButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: From a2451af9991a69a4bdbee1142a3fc73bcc785843 Mon Sep 17 00:00:00 2001 From: "neiljp (Neil Pilgrim)" Date: Thu, 11 Jul 2024 10:35:31 -0700 Subject: [PATCH 05/12] symbols/buttons: fixup: Add simple prefix for Stream messages. --- zulipterminal/config/symbols.py | 2 ++ zulipterminal/ui_tools/buttons.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/zulipterminal/config/symbols.py b/zulipterminal/config/symbols.py index f52dd85f7b..22da4d1f82 100644 --- a/zulipterminal/config/symbols.py +++ b/zulipterminal/config/symbols.py @@ -13,7 +13,9 @@ MENTIONED_MESSAGES_MARKER = "@" STARRED_MESSAGES_MARKER = "*" +# Used in View buttons, and short form of DMs (not for streams) DIRECT_MESSAGE_MARKER = "ยง" # SECTION SIGN, U+00A7 (Latin-1 supplement) +STREAM_MESSAGE_MARKER = ">" STREAM_MARKER_PRIVATE = "P" STREAM_MARKER_PUBLIC = "#" diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 8239d80a4f..e6f6ee306d 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -21,6 +21,7 @@ ALL_MESSAGES_MARKER, CHECK_MARK, DIRECT_MESSAGE_MARKER, + STREAM_MESSAGE_MARKER, MENTIONED_MESSAGES_MARKER, MUTE_MARKER, STARRED_MESSAGES_MARKER, @@ -163,6 +164,7 @@ def __init__(self, *, controller: Any, count: int) -> None: super().__init__( controller=controller, label_markup=(None, button_text), + prefix_markup=("title", STREAM_MESSAGE_MARKER), suffix_markup=("unread_count", ""), show_function=lambda: None, count=count, From 367b6a8caa0c6cc6ace22bf94eddf6f649da45e9 Mon Sep 17 00:00:00 2001 From: vishwesh Date: Mon, 4 Sep 2023 10:13:41 -0700 Subject: [PATCH 06/12] model: Fetch recent direct messages from the Zulip API. This commit adds a list of recent direct messages for the user to the ZT model (recent_dms). It also adds a _sort_recent_dms function to keep the list in sorted order according to the max_message_id of the dm. Fixture added and test updated. --- tests/conftest.py | 14 ++++++++++++++ tests/model/test_model.py | 3 +++ zulipterminal/model.py | 15 +++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index e0f632acfe..2075b0dda0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -868,6 +868,15 @@ def clean_custom_profile_data_fixture() -> List[CustomProfileData]: ] +@pytest.fixture +def sorted_recent_dms_fixture() -> List[Dict[str, Any]]: + return [ + {"max_message_id": 4, "user_ids": []}, + {"max_message_id": 3, "user_ids": [2]}, + {"max_message_id": 2, "user_ids": [1]}, + ] + + @pytest.fixture def initial_data( logged_on_user: Dict[str, Any], @@ -1050,6 +1059,11 @@ def initial_data( "zulip_feature_level": MINIMUM_SUPPORTED_SERVER_VERSION[1], "starred_messages": [1117554, 1117558, 1117574], "custom_profile_fields": custom_profile_fields_fixture, + "recent_private_conversations": [ + {"max_message_id": 4, "user_ids": []}, + {"max_message_id": 2, "user_ids": [1]}, + {"max_message_id": 3, "user_ids": [2]}, + ], } diff --git a/tests/model/test_model.py b/tests/model/test_model.py index 119b3aecab..ba20896d93 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -69,6 +69,7 @@ def test_init( realm_emojis_data, zulip_emoji, stream_dict, + sorted_recent_dms_fixture, ): assert hasattr(model, "controller") assert hasattr(model, "client") @@ -94,6 +95,7 @@ def test_init( assert model.users == [] self.classify_unread_counts.assert_called_once_with(model) assert model.unread_counts == [] + assert model.recent_dms == sorted_recent_dms_fixture assert model.active_emoji_data == OrderedDict( sorted( {**unicode_emojis, **realm_emojis_data, **zulip_emoji}.items(), @@ -261,6 +263,7 @@ def test_register_initial_desired_events(self, mocker, initial_data): "user_settings", "realm_emoji", "custom_profile_fields", + "recent_private_conversations", "zulip_version", ] model.client.register.assert_called_once_with( diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 1d7688cdeb..8a6ed9dc9d 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -142,6 +142,7 @@ def __init__(self, controller: Any) -> None: "user_settings", "realm_emoji", "custom_profile_fields", + "recent_private_conversations", # zulip_version and zulip_feature_level are always returned in # POST /register from Feature level 3. "zulip_version", @@ -180,6 +181,11 @@ def __init__(self, controller: Any) -> None: self.users: List[MinimalUserData] = [] self._update_users_data_from_initial_data() + self.recent_dms: List[Dict[str, Any]] = self.initial_data[ + "recent_private_conversations" + ] + self._sort_recent_dms() + self.stream_dict: Dict[int, Any] = {} self.muted_streams: Set[int] = set() self.pinned_streams: List[StreamData] = [] @@ -1335,6 +1341,15 @@ def user_name_from_id(self, user_id: int) -> str: return self.user_dict[user_email]["full_name"] + def _sort_recent_dms(self) -> None: + """ + Sorts the list of recent direct message conversations. + """ + self.recent_dms.sort( + key=lambda conversation: conversation["max_message_id"], + reverse=True, + ) + def _subscribe_to_streams(self, subscriptions: List[Subscription]) -> None: def make_reduced_stream_data(stream: Subscription) -> StreamData: # stream_id has been changed to id. From 92705371030ffeef2cfada33ca87efce61627f84 Mon Sep 17 00:00:00 2001 From: vishwesh Date: Mon, 4 Sep 2023 13:56:31 -0700 Subject: [PATCH 07/12] model: Update recent_dms while handling a message_event. This commit adds an _update_recent_dms method which updates the recent_dms list when a message event is received. It keeps the list in sorted order as well. Tests modified. --- tests/model/test_model.py | 4 ++++ zulipterminal/model.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/tests/model/test_model.py b/tests/model/test_model.py index ba20896d93..9a6ca40f92 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -1921,6 +1921,7 @@ def test__handle_message_event_with_Falsey_log( ) model.notify_user = mocker.Mock() event = {"type": "message", "message": message_fixture} + model.user_id = 5140 model._handle_message_event(event) @@ -1940,6 +1941,7 @@ def test__handle_message_event_with_valid_log(self, mocker, model, message_fixtu ) model.notify_user = mocker.Mock() event = {"type": "message", "message": message_fixture} + model.user_id = 5140 model._handle_message_event(event) @@ -1967,6 +1969,7 @@ def test__handle_message_event_with_flags(self, mocker, model, message_fixture): "message": message_fixture, "flags": ["read", "mentioned"], } + model.user_id = 5140 model._handle_message_event(event) @@ -2096,6 +2099,7 @@ def test__handle_message_event( mocker.patch(MODULE + ".index_messages", return_value={}) mocker.patch(MODULE + ".create_msg_box_list", return_value=["msg_w"]) set_count = mocker.patch(MODULE + ".set_count") + mocker.patch(MODEL + "._update_recent_dms") self.controller.view.message_view = mocker.Mock(log=[]) ( self.controller.view.left_panel.is_in_topic_view_with_stream_id.return_value diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 8a6ed9dc9d..7b2c724a62 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -1350,6 +1350,28 @@ def _sort_recent_dms(self) -> None: reverse=True, ) + def _update_recent_dms(self, message: Message) -> None: + """ + Updates the list of recent direct message conversations. + """ + msg_id = message["id"] + user_ids = [recipient["id"] for recipient in message["display_recipient"]] + user_ids.remove(self.user_id) + replaced = False + for dm in self.recent_dms: + if set(dm["user_ids"]) == set(user_ids) and msg_id > dm["max_message_id"]: + dm["max_message_id"] = msg_id + replaced = True + break + if not replaced: + self.recent_dms.append( + { + "user_ids": user_ids, + "max_message_id": msg_id, + } + ) + self._sort_recent_dms() + def _subscribe_to_streams(self, subscriptions: List[Subscription]) -> None: def make_reduced_stream_data(stream: Subscription) -> StreamData: # stream_id has been changed to id. @@ -1710,6 +1732,8 @@ def _handle_message_event(self, event: Event) -> None: message["stream_id"], message["subject"], message["sender_id"] ) self.controller.update_screen() + elif message["type"] == "private": + self._update_recent_dms(message) # We can notify user regardless of whether UI is rendered or not, # but depend upon the UI to indicate failures. From 23f28ad7ecac9dc2502184f5e1d5ca57aa83d76d Mon Sep 17 00:00:00 2001 From: vishwesh Date: Thu, 7 Sep 2023 13:32:37 -0700 Subject: [PATCH 08/12] buttons/views:Add DMButton, DMPanel and DMView classes. --- zulipterminal/ui_tools/buttons.py | 38 ++++++++++++++++++++++++++++++- zulipterminal/ui_tools/views.py | 25 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index e6f6ee306d..b6c25ca205 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -26,7 +26,11 @@ MUTE_MARKER, STARRED_MESSAGES_MARKER, ) -from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS, STREAM_ACCESS_TYPE +from zulipterminal.config.ui_mappings import ( + EDIT_MODE_CAPTIONS, + STATE_ICON, + STREAM_ACCESS_TYPE, +) from zulipterminal.helper import StreamData, hash_util_decode, process_media from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size @@ -285,6 +289,38 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: return super().keypress(size, key) +class DMButton(TopButton): + def __init__( + self, + *, + dm_data: Dict[str, Any], + controller: Any, + view: Any, + state_marker: str, + color: Optional[str] = None, + count: int, + ) -> None: + self.model = controller.model + self.count = count + self.view = view + self.users: str = dm_data["users"] + self.user_emails: List[str] = dm_data["emails"] + self.dm_type: str = dm_data["type"] + + narrow_function = partial( + controller.narrow_to_user, + recipient_emails=self.user_emails, + ) + super().__init__( + controller=controller, + prefix_markup=(color, state_marker), + label_markup=(None, self.users), + suffix_markup=("unread_count", count), + show_function=narrow_function, + count=count, + ) + + class UserButton(TopButton): def __init__( self, diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 3c5aeb0e35..7f506e1d79 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -306,6 +306,31 @@ def read_message(self, index: int = -1) -> None: self.model.mark_message_ids_as_read(read_msg_ids) +class DMPanel(urwid.Pile): + def __init__(self, submenu_view: List[Any], view: Any) -> None: + self.view = view + count = self.view.model.unread_counts.get("all_pms", 0) + self.view.pm_button = PMButton(controller=self.view.controller, count=count) + + self._contents = [ + ("pack", self.view.pm_button), + ("pack", urwid.Divider(div_char=SECTION_DIVIDER_LINE)), + submenu_view, + ] + + super().__init__(self.contents) + + +class DMView(urwid.Frame): + def __init__(self, dm_btn_list: List[Any], view: Any) -> None: + self.view = view + self.log = urwid.SimpleFocusListWalker(dm_btn_list) + self.dm_btn_list = dm_btn_list + self.focus_index_before_search = 0 + list_box = urwid.ListBox(self.log) + super().__init__(list_box) + + class StreamsViewDivider(urwid.Divider): """ A custom urwid.Divider to visually separate pinned and unpinned streams. From 88ff3770fd1217356b84f8844f12a9570c3a4af2 Mon Sep 17 00:00:00 2001 From: vishwesh Date: Thu, 7 Sep 2023 13:37:08 -0700 Subject: [PATCH 09/12] views: Rework direct messages UI. This commit reworks the direct messages button by moving it to a different section between the menu and the streams panel. It also adds a direct messages panel which shows all DM recipients. --- zulipterminal/ui_tools/views.py | 81 +++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 7f506e1d79..42ca638725 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -46,6 +46,7 @@ from zulipterminal.server_url import near_message_url from zulipterminal.ui_tools.boxes import PanelSearchBox from zulipterminal.ui_tools.buttons import ( + DMButton, EmojiButton, HomeButton, MentionedButton, @@ -830,11 +831,15 @@ def __init__(self, view: Any) -> None: self.view = view self.controller = view.controller self.menu_v = self.menu_view() + self.dm_v = self.dms_view() + self.dm_panel = self.dms_panel(self.dm_v) self.stream_v = self.streams_view() self.stream_panel = self.streams_panel(self.stream_v) self.is_in_topic_view = False contents = [ - (4, self.menu_v), + (3, self.menu_v), + ("pack", urwid.Divider(COLUMN_TITLE_BAR_LINE)), + self.dm_panel, ("pack", urwid.Divider(COLUMN_TITLE_BAR_LINE)), self.stream_panel, ] @@ -844,9 +849,6 @@ def menu_view(self) -> Any: count = self.model.unread_counts.get("all_msg", 0) self.view.home_button = HomeButton(controller=self.controller, count=count) - count = self.model.unread_counts.get("all_pms", 0) - self.view.pm_button = PMButton(controller=self.controller, count=count) - self.view.mentioned_button = MentionedButton( controller=self.controller, count=self.model.unread_counts["all_mentions"], @@ -859,7 +861,6 @@ def menu_view(self) -> Any: ) menu_btn_list = [ self.view.home_button, - self.view.pm_button, self.view.mentioned_button, self.view.starred_button, ] @@ -870,6 +871,70 @@ def streams_panel(self, submenu_view: Any) -> Any: self.view.stream_p = StreamPanel(submenu_view, self.view) return self.view.stream_p + def dms_panel(self, submenu_view: Any) -> Any: + self.view.dm_p = DMPanel(submenu_view, self.view) + return self.view.dm_p + + def dms_view(self) -> Any: + + def get_dm_unread_count(user_ids): + if len(user_ids) == 1: + count = self.model.unread_counts["unread_pms"].get( + user_ids[0], 0 + ) + else: + user_ids.append(self.model.user_id) + count = self.model.unread_counts["unread_huddles"].get( + frozenset(user_ids), 0 + ) + return count + + def get_dm_state_marker_and_color(user_emails): + if len(user_emails) == 1: + user = user_emails[0] + user_dict = self.model.user_dict + status = user_dict[user]["status"] + else: + status = "offline" + + state_marker = STATE_ICON[status] + color = f"user_{status}" + return state_marker, color + + dm_btn_list = [] + dm_list = self.model.recent_dms + for dm in dm_list: + user_emails = [] + user_names = [] + non_existing_user = False + for user_id in dm["user_ids"]: + try: + user_names.append(str(self.model.user_name_from_id(user_id))) + user_emails.append(self.model.user_id_email_dict[user_id]) + except RuntimeError: + non_existing_user = True + if non_existing_user is False: + users = ", ".join(user_names) + dm_data = { + "users": users, + "emails": user_emails, + "type": "dm" if len(dm["user_ids"]) == 1 else "group_dm", + } + count = get_dm_unread_count(dm["user_ids"]) + state_marker, color = get_dm_state_marker_and_color(user_emails) + dm_btn_list.append( + DMButton( + dm_data=dm_data, + controller=self.controller, + view=self.view, + state_marker=state_marker, + color=color, + count=count, + ) + ) + self.view.dm_w = DMView(dm_btn_list, self.view) + return self.view.dm_w + def streams_view(self) -> Any: streams_btn_list = [ StreamButton( @@ -936,12 +1001,12 @@ def update_stream_view(self) -> None: def show_stream_view(self) -> None: self.is_in_topic_view = False self.stream_panel = self.streams_panel(self.stream_v) - self.contents[2] = (self.stream_panel, self.options(height_type="weight")) + self.contents[4] = (self.stream_panel, self.options(height_type="weight")) def show_topic_view(self, stream_button: Any) -> None: self.is_in_topic_view = True self.stream_panel = self.streams_panel(self.topics_view(stream_button)) - self.contents[2] = ( + self.contents[4] = ( self.stream_panel, self.options(height_type="weight"), ) @@ -950,7 +1015,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if is_command_key("SEARCH_STREAMS", key) or is_command_key( "SEARCH_TOPICS", key ): - self.focus_position = 2 + self.focus_position = 4 self.view.stream_p.focus_position = 2 if self.is_in_topic_view: self.view.topic_w.keypress(size, key) From 3298cb65f47f5e82b5459e1eecddeea56a2426ef Mon Sep 17 00:00:00 2001 From: vishwesh Date: Thu, 7 Sep 2023 13:39:21 -0700 Subject: [PATCH 10/12] buttons/views: Rename PMButton to DMPanelButton. --- zulipterminal/ui_tools/buttons.py | 2 +- zulipterminal/ui_tools/views.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index b6c25ca205..7c82759961 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -148,7 +148,7 @@ def __init__(self, *, controller: Any, count: int) -> None: ) -class PMButton(TopButton): +class DMPanelButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: button_text = f"Direct messages [{primary_display_key_for_command('ALL_PM')}]" diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 42ca638725..d5a168c6e4 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -47,11 +47,11 @@ from zulipterminal.ui_tools.boxes import PanelSearchBox from zulipterminal.ui_tools.buttons import ( DMButton, + DMPanelButton, EmojiButton, HomeButton, MentionedButton, MessageLinkButton, - PMButton, StarredButton, StreamButton, StreamPanelButton, @@ -311,7 +311,9 @@ class DMPanel(urwid.Pile): def __init__(self, submenu_view: List[Any], view: Any) -> None: self.view = view count = self.view.model.unread_counts.get("all_pms", 0) - self.view.pm_button = PMButton(controller=self.view.controller, count=count) + self.view.pm_button = DMPanelButton( + controller=self.view.controller, count=count + ) self._contents = [ ("pack", self.view.pm_button), From 518e8d287c776573060a1535ade62471c2398ddf Mon Sep 17 00:00:00 2001 From: vishwesh Date: Tue, 5 Sep 2023 18:51:54 -0700 Subject: [PATCH 11/12] buttons/views: Add panel folding for DM and stream panels. --- zulipterminal/ui_tools/buttons.py | 20 +++++-- zulipterminal/ui_tools/views.py | 97 ++++++++++++++++++++++++------- 2 files changed, 90 insertions(+), 27 deletions(-) diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 7c82759961..c6324af27e 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -149,7 +149,9 @@ def __init__(self, *, controller: Any, count: int) -> None: class DMPanelButton(TopButton): - def __init__(self, *, controller: Any, count: int) -> None: + def __init__( + self, *, controller: Any, count: int, show_function: Callable[[], Any] + ) -> None: button_text = f"Direct messages [{primary_display_key_for_command('ALL_PM')}]" super().__init__( @@ -157,26 +159,34 @@ def __init__(self, *, controller: Any, count: int) -> None: label_markup=(None, button_text), prefix_markup=("title", DIRECT_MESSAGE_MARKER), suffix_markup=("unread_count", ""), - show_function=controller.narrow_to_all_pm, + show_function=show_function, count=count, ) + def activate(self, key: Any) -> None: + self.show_function() + class StreamPanelButton(TopButton): - def __init__(self, *, controller: Any, count: int) -> None: - button_text = "Stream messages " + def __init__( + self, *, controller: Any, count: int, show_function: Callable[[], Any] + ) -> None: + button_text = "Stream messages [S]" super().__init__( controller=controller, label_markup=(None, button_text), prefix_markup=("title", STREAM_MESSAGE_MARKER), suffix_markup=("unread_count", ""), - show_function=lambda: None, + show_function=show_function, count=count, ) def selectable(self) -> bool: return False + def activate(self, key: Any) -> None: + self.show_function() + class MentionedButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index d5a168c6e4..ab56958479 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -308,20 +308,32 @@ def read_message(self, index: int = -1) -> None: class DMPanel(urwid.Pile): - def __init__(self, submenu_view: List[Any], view: Any) -> None: + def __init__( + self, + submenu_view: Optional[List[Any]], + view: Any, + show_function: Callable[[], Any], + ) -> None: self.view = view count = self.view.model.unread_counts.get("all_pms", 0) self.view.pm_button = DMPanelButton( - controller=self.view.controller, count=count + controller=self.view.controller, count=count, show_function=show_function ) - self._contents = [ - ("pack", self.view.pm_button), - ("pack", urwid.Divider(div_char=SECTION_DIVIDER_LINE)), - submenu_view, - ] + if submenu_view: + self._contents = [ + ("pack", self.view.pm_button), + ("pack", urwid.Divider(div_char=SECTION_DIVIDER_LINE)), + submenu_view, + ] + focus_item = 2 + else: + self._contents = [ + ("pack", self.view.pm_button), + ] + focus_item = 0 - super().__init__(self.contents) + super().__init__(self.contents, focus_item=focus_item) class DMView(urwid.Frame): @@ -348,19 +360,35 @@ def __init__(self) -> None: class StreamPanel(urwid.Pile): - def __init__(self, submenu_view: List[Any], view: Any) -> None: + def __init__( + self, + submenu_view: Optional[List[Any]], + view: Any, + show_function: Callable[[], Any], + ) -> None: self.view = view count = self.view.model.unread_counts.get("all_stream_msg", 0) self.view.stream_button = StreamPanelButton( - controller=self.view.controller, count=count + controller=self.view.controller, + count=count, + show_function=show_function, ) - self._contents = [ - ("pack", self.view.stream_button), - ("pack", urwid.Divider(div_char=SECTION_DIVIDER_LINE)), - submenu_view, - ] - super().__init__(self.contents, focus_item=2) + if submenu_view: + self._contents = [ + ("pack", self.view.stream_button), + ("pack", urwid.Divider(div_char=SECTION_DIVIDER_LINE)), + submenu_view, + ] + focus_item = 2 + else: + self._contents = [ + ("pack", self.view.stream_button), + ("pack", urwid.Divider(div_char=SECTION_DIVIDER_LINE)), + ] + focus_item = 0 + + super().__init__(self.contents, focus_item=focus_item) class StreamsView(urwid.Frame): @@ -834,14 +862,15 @@ def __init__(self, view: Any) -> None: self.controller = view.controller self.menu_v = self.menu_view() self.dm_v = self.dms_view() - self.dm_panel = self.dms_panel(self.dm_v) + self.dm_panel = self.dms_panel(None) self.stream_v = self.streams_view() self.stream_panel = self.streams_panel(self.stream_v) self.is_in_topic_view = False + self.is_in_dm_panel_view = False contents = [ (3, self.menu_v), ("pack", urwid.Divider(COLUMN_TITLE_BAR_LINE)), - self.dm_panel, + ("pack", self.dm_panel), ("pack", urwid.Divider(COLUMN_TITLE_BAR_LINE)), self.stream_panel, ] @@ -870,11 +899,15 @@ def menu_view(self) -> Any: return w def streams_panel(self, submenu_view: Any) -> Any: - self.view.stream_p = StreamPanel(submenu_view, self.view) + self.view.stream_p = StreamPanel( + submenu_view, self.view, show_function=self.show_stream_panel + ) return self.view.stream_p def dms_panel(self, submenu_view: Any) -> Any: - self.view.dm_p = DMPanel(submenu_view, self.view) + self.view.dm_p = DMPanel( + submenu_view, self.view, show_function=self.show_dm_panel + ) return self.view.dm_p def dms_view(self) -> Any: @@ -1013,10 +1046,26 @@ def show_topic_view(self, stream_button: Any) -> None: self.options(height_type="weight"), ) + def show_dm_panel(self) -> None: + self.dm_panel = self.dms_panel(self.dm_v) + self.contents[2] = (self.dm_panel, self.options(height_type="weight")) + self.stream_panel = self.streams_panel(None) + self.contents[4] = (self.stream_panel, self.options(height_type="pack")) + self.focus_position = 2 + self.is_in_dm_panel_view = True + + def show_stream_panel(self) -> None: + self.stream_panel = self.streams_panel(self.stream_v) + self.contents[4] = (self.stream_panel, self.options(height_type="weight")) + self.dm_panel = self.dms_panel(None) + self.contents[2] = (self.dm_panel, self.options(height_type="pack")) + self.focus_position = 4 + self.is_in_dm_panel_view = False + def keypress(self, size: urwid_Size, key: str) -> Optional[str]: - if is_command_key("SEARCH_STREAMS", key) or is_command_key( + if (is_command_key("SEARCH_STREAMS", key) or is_command_key( "SEARCH_TOPICS", key - ): + )) and not self.is_in_dm_panel_view: self.focus_position = 4 self.view.stream_p.focus_position = 2 if self.is_in_topic_view: @@ -1026,6 +1075,10 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: return key elif is_command_key("GO_RIGHT", key): self.view.show_left_panel(visible=False) + elif is_command_key("ALL_PM", key): + self.show_dm_panel() + elif key == "S": + self.show_stream_panel() return super().keypress(size, key) From 9167fb3c30257db7b10d9e920a1eb3f722045f58 Mon Sep 17 00:00:00 2001 From: "neiljp (Neil Pilgrim)" Date: Thu, 11 Jul 2024 10:39:37 -0700 Subject: [PATCH 12/12] fixup: Switch DM panel button to not be selectable. --- zulipterminal/ui_tools/buttons.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index c6324af27e..25fe241b03 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -163,6 +163,9 @@ def __init__( count=count, ) + def selectable(self) -> bool: + return False + def activate(self, key: Any) -> None: self.show_function()