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