diff --git a/docs/hotkeys.md b/docs/hotkeys.md index db4972036f..8f045d5a25 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -13,6 +13,7 @@ |Redraw screen|ctrl + l| |Quit|ctrl + c| |View user information (From Users list)|i| +|Show/hide topic information & modify settings|i| ## Navigation |Command|Key Combination| diff --git a/tests/model/test_model.py b/tests/model/test_model.py index 04a553afcd..5146adce2c 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -7,6 +7,7 @@ from pytest import param as case 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 ( @@ -1097,11 +1098,11 @@ def test_get_latest_message_in_topic( }, { "_": ["User not found", False], - "_owner": ["Editing messages is disabled", False], - "_admin": ["Editing messages is disabled", False], - "_moderator": ["Editing messages is disabled", False], - "_member": ["Editing messages is disabled", False], - "_guest": ["Editing messages is disabled", False], + "_owner": [" Editing messages is disabled", False], + "_admin": [" Editing messages is disabled", False], + "_moderator": [" Editing messages is disabled", False], + "_member": [" Editing messages is disabled", False], + "_guest": [" Editing messages is disabled", False], }, id="editing_msg_disabled:PreZulip4.0", ), @@ -1114,9 +1115,9 @@ def test_get_latest_message_in_topic( "_": ["User not found", False], "_owner": ["", True], "_admin": ["", True], - "_moderator": ["Editing topic is disabled", False], - "_member": ["Editing topic is disabled", False], - "_guest": ["Editing topic is disabled", False], + "_moderator": [" Editing topic is disabled", False], + "_member": [" Editing topic is disabled", False], + "_guest": [" Editing topic is disabled", False], }, id="editing_topic_disabled:PreZulip4.0", ), @@ -1135,18 +1136,6 @@ def test_get_latest_message_in_topic( }, id="editing_topic_and_msg_enabled:PreZulip4.0", ), - case( - {"allow_message_editing": False, "edit_topic_policy": 1}, - { - "_": ["User not found", False], - "_owner": ["Editing messages is disabled", False], - "_admin": ["Editing messages is disabled", False], - "_moderator": ["Editing messages is disabled", False], - "_member": ["Editing messages is disabled", False], - "_guest": ["Editing messages is disabled", False], - }, - id="all_but_no_editing:Zulip4.0+:ZFL75", - ), case( {"allow_message_editing": True, "edit_topic_policy": 1}, { @@ -1156,8 +1145,8 @@ def test_get_latest_message_in_topic( "_moderator": ["", True], "_member": ["", True], "_guest": [ - "Only organization administrators, moderators, full members and" - " members can edit topic", + "Only organization administrators, moderators, full members" + " and members can edit topic", False, ], }, @@ -1208,13 +1197,13 @@ def test_get_latest_message_in_topic( "_admin": ["", True], "_moderator": ["", True], "_member": [ - "Only organization administrators and moderators" - " can edit topic", + "Only organization administrators and moderators can edit" + " topic", False, ], "_guest": [ - "Only organization administrators and moderators" - " can edit topic", + "Only organization administrators and moderators can edit" + " topic", False, ], }, @@ -1246,9 +1235,9 @@ def test_can_user_edit_topic( ): model.get_user_info = mocker.Mock(return_value=user_role) model.initial_data = initial_data - initial_data["realm_allow_message_editing"] = realm_editing_settings[ + initial_data["realm_allow_message_editing"] = realm_editing_settings.get( "allow_message_editing" - ] + ) allow_community_topic_editing = realm_editing_settings.get( "allow_community_topic_editing", None ) @@ -1271,6 +1260,87 @@ def test_can_user_edit_topic( else: report_error.assert_called_once_with(expected_response[user_type][0]) + @pytest.mark.parametrize( + "topic_name, msg_response, server_feature_level, topic_editing_limit_seconds," + " expected_new_topic_name, expected_footer_error", + [ + case( + "hi!", + { + "subject": "hi!", + "timestamp": 11662271397, + "id": 1, + }, + 12, + 259200, + RESOLVED_TOPIC_PREFIX + "hi!", + None, + id="topic_resolved:Zulip2.1+:ZFL12", + ), + case( + "hi!", + { + "subject": "hi!", + "timestamp": 0, + "id": 1, + }, + None, + None, + RESOLVED_TOPIC_PREFIX + "hi!", + None, + id="no_time_limit:Zulip2.1+:None", + ), + case( + RESOLVED_TOPIC_PREFIX + "hi!", + { + "subject": RESOLVED_TOPIC_PREFIX + "hi!", + "timestamp": 11662271397, + "id": 1, + }, + 10, + 86400, + "hi!", + None, + id="topic_unresolved:Zulip2.1+:ZFL10", + ), + ], + ) + def test_toggle_topic_resolve_status( + self, + mocker, + model, + initial_data, + topic_name, + msg_response, + server_feature_level, + topic_editing_limit_seconds, + expected_new_topic_name, + expected_footer_error, + stream_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 + # 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=msg_response) + 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) + + if not expected_footer_error: + model.update_stream_message.assert_called_once_with( + message_id=msg_response["id"], + topic=expected_new_topic_name, + propagate_mode="change_all", + ) + else: + 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 3205a13d52..a82b5ab6be 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -26,6 +26,7 @@ PopUpView, StreamInfoView, StreamMembersView, + TopicInfoView, UserInfoView, ) from zulipterminal.urwid_types import urwid_Size @@ -1154,6 +1155,24 @@ def test_create_link_buttons( assert link_width == expected_link_width +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.topic_info_view = TopicInfoView( + self.controller, self.stream_id, self.topic + ) + + class TestStreamInfoView: @pytest.fixture(autouse=True) def mock_external_classes( diff --git a/tools/lint-hotkeys b/tools/lint-hotkeys index 68e9f8cdc2..958fe1d7e5 100755 --- a/tools/lint-hotkeys +++ b/tools/lint-hotkeys @@ -19,7 +19,7 @@ SCRIPT_NAME = PurePath(__file__).name HELP_TEXT_STYLE = re.compile(r"^[a-zA-Z /()',&@#:_-]*$") # Exclude keys from duplicate keys checking -KEYS_TO_EXCLUDE = ["q", "e", "m", "r"] +KEYS_TO_EXCLUDE = ["q", "e", "m", "r", "i"] def main(fix: bool) -> None: diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index 453b319053..ce1bd86e14 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -310,6 +310,11 @@ class KeyBinding(TypedDict): 'help_text': 'View user information (From Users list)', 'key_category': 'general', }, + 'TOPIC_INFO': { + 'keys': ['i'], + 'help_text': 'Show/hide topic information & modify settings', + 'key_category': 'general', + }, 'BEGINNING_OF_LINE': { 'keys': ['ctrl a'], 'help_text': 'Jump to the beginning of line', diff --git a/zulipterminal/config/themes.py b/zulipterminal/config/themes.py index 5872572adb..3ced3cedab 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 8ee293d8ee..678b82a207 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -43,6 +43,7 @@ PopUpConfirmationView, StreamInfoView, StreamMembersView, + TopicInfoView, UserInfoView, ) from zulipterminal.version import ZT_VERSION @@ -305,6 +306,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 f82556380f..26ecd52d88 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -30,6 +30,7 @@ from zulipterminal import unicode_emojis from zulipterminal.api_types import ( + RESOLVED_TOPIC_PREFIX, Composition, EditPropagateMode, Event, @@ -648,7 +649,7 @@ def can_user_edit_topic(self) -> bool: user_info = self.get_user_info(self.user_id) if user_info is not None: if not self.initial_data.get("realm_allow_message_editing"): - self.controller.report_error("Editing messages is disabled") + self.controller.report_error(" Editing messages is disabled") return False role = user_info["role"] if role <= 200: @@ -661,7 +662,7 @@ def can_user_edit_topic(self) -> bool: if allow_community_topic_editing is True: return True elif allow_community_topic_editing is False: - self.controller.report_error("Editing topic is disabled") + self.controller.report_error(" Editing topic is disabled") return False else: edit_topic_policy = self.initial_data.get("realm_edit_topic_policy") @@ -693,12 +694,44 @@ def can_user_edit_topic(self) -> bool: else: self.controller.report_error(EDIT_TOPIC_POLICY[1]) return False - else: # edit_topic_policy == 5 (or None) + else: # All users including guests return True self.controller.report_error("User not found") return False + def toggle_topic_resolve_status(self, stream_id: int, topic_name: str) -> None: + if self.can_user_edit_topic(): + latest_msg = self.get_latest_message_in_topic(stream_id, topic_name) + if latest_msg: + time_since_msg_sent = time.time() - latest_msg["timestamp"] + # ZFL < 11, community_topic_editing_limit_seconds + # was hardcoded as int value in secs eg. 86400s (1 day) or None + if self.server_feature_level is None or self.server_feature_level >= 11: + edit_time_limit = self.initial_data.get( + "realm_community_topic_editing_limit_seconds", 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." + ) + else: + 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 5e0c232521..93d45d6caa 100644 --- a/zulipterminal/themes/gruvbox_dark.py +++ b/zulipterminal/themes/gruvbox_dark.py @@ -66,6 +66,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, Color.DARK0_HARD), diff --git a/zulipterminal/themes/gruvbox_light.py b/zulipterminal/themes/gruvbox_light.py index 7c536de97a..9daf3bf585 100644 --- a/zulipterminal/themes/gruvbox_light.py +++ b/zulipterminal/themes/gruvbox_light.py @@ -65,6 +65,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, Color.LIGHT0_HARD), diff --git a/zulipterminal/themes/zt_blue.py b/zulipterminal/themes/zt_blue.py index d6fed8b0cc..bc813f12a9 100644 --- a/zulipterminal/themes/zt_blue.py +++ b/zulipterminal/themes/zt_blue.py @@ -59,6 +59,7 @@ 'widget_disabled' : (Color.DARK_GRAY, Color.LIGHT_BLUE), '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 36791644ee..d1b0e4ba3f 100644 --- a/zulipterminal/themes/zt_dark.py +++ b/zulipterminal/themes/zt_dark.py @@ -60,6 +60,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, Color.BLACK), diff --git a/zulipterminal/themes/zt_light.py b/zulipterminal/themes/zt_light.py index 1ca6b94547..5104fe5122 100644 --- a/zulipterminal/themes/zt_light.py +++ b/zulipterminal/themes/zt_light.py @@ -59,6 +59,7 @@ 'widget_disabled' : (Color.LIGHT_GRAY, Color.WHITE), '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 5b1584da77..3634df437c 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -349,6 +349,8 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if is_command_key("TOGGLE_TOPIC", key): # Exit topic view 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 a4b673d6b9..805ac3d828 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 +from zulipterminal.api_types import RESOLVED_TOPIC_PREFIX, EditPropagateMode from zulipterminal.config.keys import ( HELP_CATEGORIES, KEY_BINDINGS, @@ -23,6 +23,7 @@ CHECK_MARK, COLUMN_TITLE_BAR_LINE, PINNED_STREAMS_DIVIDER, + STREAM_TOPIC_SEPARATOR, ) from zulipterminal.config.ui_mappings import ( BOT_TYPE_BY_ID, @@ -1474,6 +1475,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_btn_lbl = "Unresolve Topic" + else: + self.resolve_topic_setting_btn_lbl = "Resolve Topic" + resolve_topic_setting = urwid.Button( + self.resolve_topic_setting_btn_lbl, + self.toggle_resolve_status, + ) + + curs_pos = len(self.resolve_topic_setting_btn_lbl) + 1 + # This shifts the ugly 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_btn_lbl, 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 StreamMembersView(PopUpView): def __init__(self, controller: Any, stream_id: int) -> None: self.stream_id = stream_id