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,