diff --git a/docs/hotkeys.md b/docs/hotkeys.md index e0ecf04303..33edf95b91 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -75,6 +75,11 @@ |Show/hide user information|i| |Narrow to direct messages with user|Enter| +## Topic list actions +|Command|Key Combination| +| :--- | :---: | +|Show/hide topic information & (un)resolve topic|i| + ## Begin composing a message |Command|Key Combination| | :--- | :---: | diff --git a/tests/model/test_model.py b/tests/model/test_model.py index 119b3aecab..76c0efbbe2 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -9,6 +9,7 @@ from pytest_mock import MockerFixture from zulip import Client, ZulipError +from zulipterminal.api_types import RESOLVED_TOPIC_PREFIX from zulipterminal.config.symbols import STREAM_TOPIC_SEPARATOR from zulipterminal.helper import initial_index, powerset from zulipterminal.model import ( @@ -1360,6 +1361,200 @@ def test_can_user_edit_topic( else: report_error.assert_called_once_with(expected_response[user_type][0]) + @pytest.mark.parametrize( + "topic_name, latest_message_timestamp, server_feature_level," + " topic_editing_limit_seconds, move_messages_within_stream_limit_seconds," + " expected_new_topic_name", + [ + case( + "hi!", + 11662271397, + 0, + None, + None, + RESOLVED_TOPIC_PREFIX + "hi!", + id="topic_resolved:Zulip2.1+:ZFL0", + ), + case( + RESOLVED_TOPIC_PREFIX + "hi!", + 11662271397, + 0, + None, + None, + "hi!", + id="topic_unresolved:Zulip2.1+:ZFL0", + ), + case( + RESOLVED_TOPIC_PREFIX + "hi!", + 11662271397, + 10, + 86400, + None, + "hi!", + id="topic_unresolved:Zulip2.1+:ZFL10", + ), + case( + "hi!", + 11662271397, + 12, + 259200, + None, + RESOLVED_TOPIC_PREFIX + "hi!", + id="topic_resolved:Zulip2.1+:ZFL12", + ), + case( + "hi!", + 11662271397, + 162, + 86400, + 86400, + RESOLVED_TOPIC_PREFIX + "hi!", + id="topic_resolved:Zulip7.0+:ZFL162", + ), + case( + RESOLVED_TOPIC_PREFIX + "hi!", + 11662271397, + 162, + 259200, + 259200, + "hi!", + id="topic_unresolved:Zulip7.0+:ZFL162", + ), + case( + "hi!", + 11662271397, + 184, + 259200, + 259200, + RESOLVED_TOPIC_PREFIX + "hi!", + id="topic_unresolved:Zulip7.0+:ZFL184", + ), + case( + RESOLVED_TOPIC_PREFIX + "hi!", + 11662271397, + 184, + 259200, + 259200, + "hi!", + id="topic_unresolved:Zulip7.0+:ZFL184", + ), + ], + ) + def test_toggle_topic_resolve_status_no_footer_error( + self, + mocker, + model, + initial_data, + topic_name, + latest_message_timestamp, + server_feature_level, + topic_editing_limit_seconds, + move_messages_within_stream_limit_seconds, + expected_new_topic_name, + stream_id=1, + message_id=1, + ): + model.initial_data = initial_data + model.server_feature_level = server_feature_level + initial_data[ + "realm_community_topic_editing_limit_seconds" + ] = topic_editing_limit_seconds + initial_data[ + "realm_move_messages_within_stream_limit_seconds" + ] = move_messages_within_stream_limit_seconds + # If user can't edit topic, topic (un)resolve is disabled. Therefore, + # default return_value=True + model.can_user_edit_topic = mocker.Mock(return_value=True) + model.get_latest_message_in_topic = mocker.Mock( + return_value={ + "subject": topic_name, + "timestamp": latest_message_timestamp, + "id": message_id, + } + ) + model.update_stream_message = mocker.Mock(return_value={"result": "success"}) + + model.toggle_topic_resolve_status(stream_id, topic_name) + + model.update_stream_message.assert_called_once_with( + message_id=message_id, + topic=expected_new_topic_name, + propagate_mode="change_all", + ) + + @pytest.mark.parametrize( + "topic_name, latest_message_timestamp, server_feature_level," + " topic_editing_limit_seconds, move_messages_within_stream_limit_seconds," + " expected_footer_error,", + [ + case( + "hi!", + 0, + 12, + 86400, + None, + " Time limit for editing topic has been exceeded.", + id="time_limit_exceeded:Zulip2.1+:ZFL12", + ), + case( + "hi!", + 0, + 162, + 259200, + 259200, + " Time limit for editing topic has been exceeded.", + id="time_limit_exceeded:Zulip7.0+:ZFL162", + ), + case( + "hi!", + 0, + 184, + None, + 86400, + " Time limit for editing topic has been exceeded.", + id="topic_resolved:Zulip2.1+:ZFL184", + ), + ], + ) + def test_toggle_topic_resolve_status_footer_error( + self, + mocker, + model, + initial_data, + topic_name, + latest_message_timestamp, + server_feature_level, + topic_editing_limit_seconds, + move_messages_within_stream_limit_seconds, + expected_footer_error, + stream_id=1, + message_id=1, + ): + model.initial_data = initial_data + model.server_feature_level = server_feature_level + initial_data[ + "realm_community_topic_editing_limit_seconds" + ] = topic_editing_limit_seconds + initial_data[ + "realm_move_messages_within_stream_limit_seconds" + ] = move_messages_within_stream_limit_seconds + # If user can't edit topic, topic (un)resolve is disabled. Therefore, + # default return_value=True + model.can_user_edit_topic = mocker.Mock(return_value=True) + model.get_latest_message_in_topic = mocker.Mock( + return_value={ + "subject": topic_name, + "timestamp": latest_message_timestamp, + "id": message_id, + } + ) + model.update_stream_message = mocker.Mock(return_value={"result": "success"}) + report_error = model.controller.report_error + + model.toggle_topic_resolve_status(stream_id, topic_name) + + report_error.assert_called_once_with(expected_footer_error) + # NOTE: This tests only getting next-unread, not a fixed anchor def test_success_get_messages( self, diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index 878be178c4..8e4d14f69c 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -6,7 +6,7 @@ from pytest_mock import MockerFixture from urwid import Columns, Pile, Text, Widget -from zulipterminal.api_types import Message +from zulipterminal.api_types import RESOLVED_TOPIC_PREFIX, Message from zulipterminal.config.keys import is_command_key, keys_for_command from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS from zulipterminal.helper import CustomProfileData, TidiedUserInfo @@ -26,6 +26,7 @@ PopUpView, StreamInfoView, StreamMembersView, + TopicInfoView, UserInfoView, ) from zulipterminal.urwid_types import urwid_Size @@ -1604,6 +1605,52 @@ def test_checkbox_toggle_visual_notification( toggle_visual_notify_status.assert_called_once_with(stream_id) +class TestTopicInfoView: + @pytest.fixture(autouse=True) + def mock_external_classes( + self, mocker: MockerFixture, general_stream: Dict[str, Any], topics: List[str] + ) -> None: + self.controller = mocker.Mock() + + mocker.patch.object( + self.controller, "maximum_popup_dimensions", return_value=(64, 64) + ) + mocker.patch(LISTWALKER, return_value=[]) + self.stream_id = general_stream["stream_id"] + self.topic = topics[0] + self.controller.model.stream_dict = {self.stream_id: general_stream} + + self.topic_info_view = TopicInfoView( + self.controller, self.stream_id, self.topic + ) + + @pytest.mark.parametrize( + "topic_name, expected_topic_button_label", + [ + ("hi!", "Resolve Topic"), + (f"{RESOLVED_TOPIC_PREFIX}" + "hi!", "Unresolve Topic"), + ], + ) + def test_topic_button_label( + self, topic_name: str, expected_topic_button_label: str + ) -> None: + topic_info_view = TopicInfoView(self.controller, self.stream_id, topic_name) + assert ( + topic_info_view.resolve_topic_setting_button_label + == expected_topic_button_label + ) + + def test_toggle_resolve_status(self) -> None: + resolve_button = self.topic_info_view.widgets[-1] + resolve_button._emit("click") + + self.controller.model.toggle_topic_resolve_status.assert_called_once_with( + stream_id=self.stream_id, topic_name=self.topic + ) + + self.controller.exit_popup.assert_called_once() + + class TestStreamMembersView: @pytest.fixture(autouse=True) def mock_external_classes(self, mocker: MockerFixture) -> None: diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index 6b7bb31acd..76c8672d8d 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -312,6 +312,11 @@ class KeyBinding(TypedDict): 'help_text': 'Show/hide stream information & modify settings', 'key_category': 'stream_list', }, + 'TOPIC_INFO': { + 'keys': ['i'], + 'help_text': 'Show/hide topic information & (un)resolve topic', + 'key_category': 'topic_list', + }, 'STREAM_MEMBERS': { 'keys': ['m'], 'help_text': 'Show/hide stream members', @@ -467,6 +472,7 @@ class KeyBinding(TypedDict): "msg_actions": "Message actions", "stream_list": "Stream list actions", "user_list": "User list actions", + "topic_list": "Topic list actions", "open_compose": "Begin composing a message", "compose_box": "Writing a message", "editor_navigation": "Editor: Navigation", diff --git a/zulipterminal/config/themes.py b/zulipterminal/config/themes.py index d6fe406c2f..c5415f4b8d 100644 --- a/zulipterminal/config/themes.py +++ b/zulipterminal/config/themes.py @@ -71,6 +71,7 @@ 'area:help' : 'standout', 'area:msg' : 'standout', 'area:stream' : 'standout', + 'area:topic' : 'standout', 'area:error' : 'standout', 'area:user' : 'standout', 'search_error' : 'standout', diff --git a/zulipterminal/core.py b/zulipterminal/core.py index a23b1596f1..cf52bcb3b2 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -44,6 +44,7 @@ PopUpConfirmationView, StreamInfoView, StreamMembersView, + TopicInfoView, UserInfoView, ) from zulipterminal.version import ZT_VERSION @@ -293,6 +294,10 @@ def show_stream_info(self, stream_id: int) -> None: show_stream_view = StreamInfoView(self, stream_id) self.show_pop_up(show_stream_view, "area:stream") + def show_topic_info(self, stream_id: int, topic_name: str) -> None: + show_topic_view = TopicInfoView(self, stream_id, topic_name) + self.show_pop_up(show_topic_view, "area:topic") + def show_stream_members(self, stream_id: int) -> None: stream_members_view = StreamMembersView(self, stream_id) self.show_pop_up(stream_members_view, "area:stream") diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 1d7688cdeb..6e4242ad44 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -36,6 +36,7 @@ MAX_TOPIC_NAME_LENGTH, PRESENCE_OFFLINE_THRESHOLD_SECS, PRESENCE_PING_INTERVAL_SECS, + RESOLVED_TOPIC_PREFIX, TYPING_STARTED_EXPIRY_PERIOD, TYPING_STARTED_WAIT_PERIOD, TYPING_STOPPED_WAIT_PERIOD, @@ -709,6 +710,45 @@ def can_user_edit_topic(self) -> bool: self.controller.report_error("User not found") return False + def toggle_topic_resolve_status(self, stream_id: int, topic_name: str) -> None: + latest_msg = self.get_latest_message_in_topic(stream_id, topic_name) + if not self.can_user_edit_topic() or not latest_msg: + return + + time_since_msg_sent = time.time() - latest_msg["timestamp"] + + # ZFL >= 162, realm_move_messages_within_stream_limit_seconds was + # introduced in place of realm_community_topic_editing_limit_seconds + if self.server_feature_level >= 162: + edit_time_limit = self.initial_data.get( + "realm_move_messages_within_stream_limit_seconds", None + ) + elif 11 <= self.server_feature_level < 162: + edit_time_limit = self.initial_data.get( + "realm_community_topic_editing_limit_seconds", None + ) + # ZFL < 11, community_topic_editing_limit_seconds + # was hardcoded as int value in secs eg. 86400s (1 day) or None + else: + edit_time_limit = 86400 + + # Don't allow editing topic if time-limit exceeded. + if edit_time_limit is not None and time_since_msg_sent >= edit_time_limit: + self.controller.report_error( + " Time limit for editing topic has been exceeded." + ) + return + + if topic_name.startswith(RESOLVED_TOPIC_PREFIX): + topic_name = topic_name[2:] + else: + topic_name = RESOLVED_TOPIC_PREFIX + topic_name + self.update_stream_message( + message_id=latest_msg["id"], + topic=topic_name, + propagate_mode="change_all", + ) + def generate_all_emoji_data( self, custom_emoji: Dict[str, RealmEmojiData] ) -> Tuple[NamedEmojiData, List[str]]: diff --git a/zulipterminal/themes/gruvbox_dark.py b/zulipterminal/themes/gruvbox_dark.py index 4492b75510..3aaf0546b5 100644 --- a/zulipterminal/themes/gruvbox_dark.py +++ b/zulipterminal/themes/gruvbox_dark.py @@ -68,6 +68,7 @@ 'area:help' : (Color.DARK0_HARD, Color.BRIGHT_GREEN), 'area:msg' : (Color.DARK0_HARD, Color.NEUTRAL_PURPLE), 'area:stream' : (Color.DARK0_HARD, Color.BRIGHT_BLUE), + 'area:topic' : (Color.DARK0_HARD, Color.BRIGHT_BLUE), 'area:error' : (Color.DARK0_HARD, Color.BRIGHT_RED), 'area:user' : (Color.DARK0_HARD, Color.BRIGHT_YELLOW), 'search_error' : (Color.BRIGHT_RED, Background.COLOR), diff --git a/zulipterminal/themes/gruvbox_light.py b/zulipterminal/themes/gruvbox_light.py index e2c09ae0c8..81fabfd7cf 100644 --- a/zulipterminal/themes/gruvbox_light.py +++ b/zulipterminal/themes/gruvbox_light.py @@ -67,6 +67,7 @@ 'area:help' : (Color.LIGHT0_HARD, Color.FADED_GREEN), 'area:msg' : (Color.LIGHT0_HARD, Color.NEUTRAL_PURPLE), 'area:stream' : (Color.LIGHT0_HARD, Color.FADED_BLUE), + 'area:topic' : (Color.LIGHT0_HARD, Color.FADED_BLUE), 'area:error' : (Color.LIGHT0_HARD, Color.FADED_RED), 'area:user' : (Color.LIGHT0_HARD, Color.FADED_YELLOW), 'search_error' : (Color.FADED_RED, Background.COLOR), diff --git a/zulipterminal/themes/zt_blue.py b/zulipterminal/themes/zt_blue.py index 0a43fe903c..99e6363919 100644 --- a/zulipterminal/themes/zt_blue.py +++ b/zulipterminal/themes/zt_blue.py @@ -61,6 +61,7 @@ 'widget_disabled' : (Color.DARK_GRAY, Background.COLOR), 'area:help' : (Color.WHITE, Color.DARK_GREEN), 'area:stream' : (Color.WHITE, Color.DARK_CYAN), + 'area:topic' : (Color.WHITE, Color.DARK_CYAN), 'area:msg' : (Color.WHITE, Color.BROWN), 'area:error' : (Color.WHITE, Color.DARK_RED), 'area:user' : (Color.WHITE, Color.DARK_BLUE), diff --git a/zulipterminal/themes/zt_dark.py b/zulipterminal/themes/zt_dark.py index 447abd7438..fcd4429bc3 100644 --- a/zulipterminal/themes/zt_dark.py +++ b/zulipterminal/themes/zt_dark.py @@ -62,6 +62,7 @@ 'area:help' : (Color.WHITE, Color.DARK_GREEN), 'area:msg' : (Color.WHITE, Color.BROWN), 'area:stream' : (Color.WHITE, Color.DARK_CYAN), + 'area:topic' : (Color.WHITE, Color.DARK_CYAN), 'area:error' : (Color.WHITE, Color.DARK_RED), 'area:user' : (Color.WHITE, Color.DARK_BLUE), 'search_error' : (Color.LIGHT_RED, Background.COLOR), diff --git a/zulipterminal/themes/zt_light.py b/zulipterminal/themes/zt_light.py index 388a9c6770..887c4924fd 100644 --- a/zulipterminal/themes/zt_light.py +++ b/zulipterminal/themes/zt_light.py @@ -61,6 +61,7 @@ 'widget_disabled' : (Color.LIGHT_GRAY, Background.COLOR), 'area:help' : (Color.BLACK, Color.LIGHT_GREEN), 'area:stream' : (Color.BLACK, Color.LIGHT_BLUE), + 'area:topic' : (Color.BLACK, Color.LIGHT_BLUE), 'area:msg' : (Color.BLACK, Color.YELLOW), 'area:error' : (Color.BLACK, Color.LIGHT_RED), 'area:user' : (Color.WHITE, Color.DARK_BLUE), diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 8e67d79adf..a0064888d1 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -381,6 +381,8 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: # Exit topic view self.view.associate_stream_with_topic(self.stream_id, self.topic_name) self.view.left_panel.show_stream_view() + elif is_command_key("TOPIC_INFO", key): + self.model.controller.show_topic_info(self.stream_id, self.topic_name) return super().keypress(size, key) diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 6d01a82566..2aa6b9d3d4 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -10,7 +10,7 @@ import urwid from typing_extensions import Literal -from zulipterminal.api_types import EditPropagateMode, Message +from zulipterminal.api_types import RESOLVED_TOPIC_PREFIX, EditPropagateMode, Message from zulipterminal.config.keys import ( HELP_CATEGORIES, KEY_BINDINGS, @@ -25,6 +25,7 @@ COLUMN_TITLE_BAR_LINE, PINNED_STREAMS_DIVIDER, SECTION_DIVIDER_LINE, + STREAM_TOPIC_SEPARATOR, ) from zulipterminal.config.ui_mappings import ( BOT_TYPE_BY_ID, @@ -1569,6 +1570,65 @@ def keypress(self, size: urwid_Size, key: str) -> str: return super().keypress(size, key) +class TopicInfoView(PopUpView): + def __init__(self, controller: Any, stream_id: int, topic: str) -> None: + self.stream_id = stream_id + self.controller = controller + stream = controller.model.stream_dict[stream_id] + self.topic_name = topic + stream_name = stream["name"] + + title = f"{stream_name} {STREAM_TOPIC_SEPARATOR} {self.topic_name}" + + topic_info_content: PopUpViewTableContent = [] + + popup_width, column_widths = self.calculate_table_widths( + topic_info_content, len(title) + ) + + if self.topic_name.startswith(RESOLVED_TOPIC_PREFIX): + self.resolve_topic_setting_button_label = "Unresolve Topic" + else: + self.resolve_topic_setting_button_label = "Resolve Topic" + resolve_topic_setting = urwid.Button( + self.resolve_topic_setting_button_label, + self.toggle_resolve_status, + ) + + curs_pos = len(self.resolve_topic_setting_button_label) + 1 + # This shifts the cursor present over the first character of + # resolve_topic_button_setting label to last character + 1 so that it isn't + # visible + + resolve_topic_setting._w = urwid.AttrMap( + urwid.SelectableIcon( + self.resolve_topic_setting_button_label, cursor_position=curs_pos + ), + None, + "selected", + ) + + # Manual because calculate_table_widths does not support buttons. + # Add 4 to button label to accommodate the buttons itself. + popup_width = max( + popup_width, + len(resolve_topic_setting.label) + 4, + ) + + self.widgets = self.make_table_with_categories( + topic_info_content, column_widths + ) + + self.widgets.append(resolve_topic_setting) + super().__init__(controller, self.widgets, "TOPIC_INFO", popup_width, title) + + def toggle_resolve_status(self, args: Any) -> None: + self.controller.model.toggle_topic_resolve_status( + stream_id=self.stream_id, topic_name=self.topic_name + ) + self.controller.exit_popup() + + class MsgInfoView(PopUpView): def __init__( self,