Repository: zulip/zulip-terminal

Issue:
Improve algorithm for 'next unread topic' (n) to use current message state
This is intended as a first step to resolving #1332. Please focus on this element of that issue first, and if things go well, then extending the algorithm further to resolve the remaining elements would be great as followup PR(s).

The current message is part of the UI, so we likely want to try passing this information as an additional parameter to the `get_next_unread_topic` method. This seems likely to replace the persistent topic state stored in the model right now.

There are existing tests for this method, which will need to be adjusted to account for the change in method signature (new parameter) and possible change in behavior. Depending on the cases covered by the tests, this may also warrant additional cases to demonstrate the code is working well.

Comments:

Patch:
diff --git a/zulipterminal/model.py b/zulipterminal/model.py
index 82d809a..7073e4f 100644
--- a/zulipterminal/model.py
+++ b/zulipterminal/model.py
@@ -2,6 +2,7 @@
 Defines the `Model`, fetching and storing data retrieved from the Zulip server
 """
 
+import bisect
 import itertools
 import json
 import time
@@ -111,7 +112,6 @@ class Model:
         self.stream_id: Optional[int] = None
         self.recipients: FrozenSet[Any] = frozenset()
         self.index = initial_index
-        self._last_unread_topic = None
         self.last_unread_pm = None
 
         self.user_id = -1
@@ -886,23 +886,67 @@ class Model:
         topic_to_search = (stream_name, topic)
         return topic_to_search in self._muted_topics
 
-    def get_next_unread_topic(self) -> Optional[Tuple[int, str]]:
+    def stream_topic_from_message_id(
+        self, message_id: int
+    ) -> Optional[Tuple[int, str]]:
+        """
+        Returns the stream and topic of a message of a given message id.
+        If the message is not a stream message or if it is not present in the index,
+        None is returned.
+        """
+        message = self.index["messages"].get(message_id, None)
+        if message is not None and message["type"] == "stream":
+            stream_id = message["stream_id"]
+            topic = message["subject"]
+            return (stream_id, topic)
+        return None
+
+    def next_unread_topic_from_message_id(
+        self, current_message: Optional[int]
+    ) -> Optional[Tuple[int, str]]:
+        if current_message:
+            current_topic = self.stream_topic_from_message_id(current_message)
+        else:
+            current_topic = (
+                self.stream_id_from_name(self.narrow[0][1]),
+                self.narrow[1][1],
+            )
         unread_topics = sorted(self.unread_counts["unread_topics"].keys())
         next_topic = False
-        if self._last_unread_topic not in unread_topics:
+        stream_start: Optional[Tuple[int, str]] = None
+        if current_topic is None:
             next_topic = True
+        elif current_topic not in unread_topics:
+            # insert current_topic in list of unread_topics for the case where
+            # current_topic is not in unread_topics, and the next unmuted topic
+            # is to be returned. This does not modify the original unread topics
+            # data, and is just used to compute the next unmuted topic to be returned.
+            bisect.insort(unread_topics, current_topic)
         # loop over unread_topics list twice for the case that last_unread_topic was
         # the last valid unread_topic in unread_topics list.
         for unread_topic in unread_topics * 2:
             stream_id, topic_name = unread_topic
-            if (
-                not self.is_muted_topic(stream_id, topic_name)
-                and not self.is_muted_stream(stream_id)
-                and next_topic
-            ):
-                self._last_unread_topic = unread_topic
-                return unread_topic
-            if unread_topic == self._last_unread_topic:
+            if not self.is_muted_topic(
+                stream_id, topic_name
+            ) and not self.is_muted_stream(stream_id):
+                if next_topic:
+                    if unread_topic == current_topic:
+                        return None
+                    if (
+                        current_topic is not None
+                        and unread_topic[0] != current_topic[0]
+                        and stream_start != current_topic
+                    ):
+                        return stream_start
+                    return unread_topic
+
+                if (
+                    stream_start is None
+                    and current_topic is not None
+                    and unread_topic[0] == current_topic[0]
+                ):
+                    stream_start = unread_topic
+            if unread_topic == current_topic:
                 next_topic = True
         return None
 
diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py
index 64eebeb..6a1e4e6 100644
--- a/zulipterminal/ui_tools/views.py
+++ b/zulipterminal/ui_tools/views.py
@@ -584,9 +584,20 @@ class MiddleColumnView(urwid.Frame):
 
         elif is_command_key("NEXT_UNREAD_TOPIC", key):
             # narrow to next unread topic
-            stream_topic = self.model.get_next_unread_topic()
-            if stream_topic is None:
+            focus = self.view.message_view.focus
+            narrow = self.model.narrow
+            if focus:
+                current_msg_id = focus.original_widget.message["id"]
+                stream_topic = self.model.next_unread_topic_from_message_id(
+                    current_msg_id
+                )
+                if stream_topic is None:
+                    return key
+            elif narrow[0][0] == "stream" and narrow[1][0] == "topic":
+                stream_topic = self.model.next_unread_topic_from_message_id(None)
+            else:
                 return key
+
             stream_id, topic = stream_topic
             self.controller.narrow_to_topic(
                 stream_name=self.model.stream_dict[stream_id]["name"],


Test patch:
diff --git a/tests/model/test_model.py b/tests/model/test_model.py
index 2bf150a..fe2ba55 100644
--- a/tests/model/test_model.py
+++ b/tests/model/test_model.py
@@ -72,7 +72,6 @@ class TestModel:
         assert model.stream_dict == stream_dict
         assert model.recipients == frozenset()
         assert model.index == initial_index
-        assert model._last_unread_topic is None
         assert model.last_unread_pm is None
         model.get_messages.assert_called_once_with(
             num_before=30, num_after=10, anchor=None
@@ -3900,7 +3899,7 @@ class TestModel:
         assert return_value == is_muted
 
     @pytest.mark.parametrize(
-        "unread_topics, last_unread_topic, next_unread_topic",
+        "unread_topics, current_topic, next_unread_topic",
         [
             case(
                 {(1, "topic"), (2, "topic2")},
@@ -3920,10 +3919,10 @@ class TestModel:
                 (1, "topic"),
                 id="unread_present_before_previous_topic",
             ),
-            case(  # TODO Should be None? (2 other cases)
+            case(
                 {(1, "topic")},
                 (1, "topic"),
-                (1, "topic"),
+                None,
                 id="unread_still_present_in_topic",
             ),
             case(
@@ -3993,16 +3992,42 @@ class TestModel:
                 (2, "topic2"),
                 id="unread_present_after_previous_topic_muted",
             ),
+            case(
+                {(1, "topic1"), (2, "topic2"), (2, "topic2 muted")},
+                (2, "topic1"),
+                (2, "topic2"),
+                id="unmuted_unread_present_in_same_stream_as_current_topic_not_in_unread_list",
+            ),
+            case(
+                {(1, "topic1"), (2, "topic2 muted"), (4, "topic4")},
+                (2, "topic1"),
+                (4, "topic4"),
+                id="unmuted_unread_present_in_next_stream_as_current_topic_not_in_unread_list",
+            ),
+            case(
+                {(1, "topic1"), (2, "topic2 muted"), (3, "topic3")},
+                (2, "topic1"),
+                (1, "topic1"),
+                id="unmuted_unread_not_present_in_next_stream_as_current_topic_not_in_unread_list",
+            ),
+            case(
+                {(1, "topic1"), (1, "topic11"), (2, "topic2")},
+                (1, "topic11"),
+                (1, "topic1"),
+                id="unread_present_in_same_stream_wrap_around",
+            ),
         ],
     )
-    def test_get_next_unread_topic(
-        self, model, unread_topics, last_unread_topic, next_unread_topic
+    def test_next_unread_topic_from_message(
+        self, mocker, model, unread_topics, current_topic, next_unread_topic
     ):
         # NOTE Not important how many unreads per topic, so just use '1'
         model.unread_counts = {
             "unread_topics": {stream_topic: 1 for stream_topic in unread_topics}
         }
-        model._last_unread_topic = last_unread_topic
+
+        current_message_id = 10  # Arbitrary value due to mock below
+        model.stream_topic_from_message_id = mocker.Mock(return_value=current_topic)
 
         # Minimal extra streams for muted stream testing (should not exist otherwise)
         assert {3, 4} & set(model.stream_dict) == set()
@@ -4020,7 +4045,38 @@ class TestModel:
             ]
         }
 
-        unread_topic = model.get_next_unread_topic()
+        unread_topic = model.next_unread_topic_from_message_id(current_message_id)
+
+        assert unread_topic == next_unread_topic
+
+    @pytest.mark.parametrize(
+        "unread_topics, empty_narrow, narrow_stream_id, next_unread_topic",
+        [
+            case(
+                {(1, "topic1"), (1, "topic2"), (2, "topic3")},
+                [["stream", "Stream 1"], ["topic", "topic1.5"]],
+                1,
+                (1, "topic2"),
+            ),
+        ],
+    )
+    def test_next_unread_topic_from_message__empty_narrow(
+        self,
+        mocker,
+        model,
+        unread_topics,
+        empty_narrow,
+        narrow_stream_id,
+        next_unread_topic,
+    ):
+        # NOTE Not important how many unreads per topic, so just use '1'
+        model.unread_counts = {
+            "unread_topics": {stream_topic: 1 for stream_topic in unread_topics}
+        }
+        model.stream_id_from_name = mocker.Mock(return_value=narrow_stream_id)
+        model.narrow = empty_narrow
+
+        unread_topic = model.next_unread_topic_from_message_id(None)
 
         assert unread_topic == next_unread_topic
 
@@ -4043,6 +4099,21 @@ class TestModel:
         assert return_value is None
         assert model.last_unread_pm is None
 
+    @pytest.mark.parametrize(
+        "message_id, expected_value",
+        [
+            case(537286, (205, "Test"), id="stream_message"),
+            case(537287, None, id="direct_message"),
+            case(537289, None, id="non-existent message"),
+        ],
+    )
+    def test_stream_topic_from_message_id(
+        self, mocker, model, message_id, expected_value, empty_index
+    ):
+        model.index = empty_index
+        current_topic = model.stream_topic_from_message_id(message_id)
+        assert current_topic == expected_value
+
     @pytest.mark.parametrize(
         "stream_id, expected_response",
         [
diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py
index c04c9c3..324ee04 100644
--- a/tests/ui/test_ui_tools.py
+++ b/tests/ui/test_ui_tools.py
@@ -887,9 +887,10 @@ class TestMiddleColumnView:
     ):
         size = widget_size(mid_col_view)
         mocker.patch(MIDCOLVIEW + ".focus_position")
+        mocker.patch.object(self.view, "message_view")
 
         mid_col_view.model.stream_dict = {1: {"name": "stream"}}
-        mid_col_view.model.get_next_unread_topic.return_value = (1, "topic")
+        mid_col_view.model.next_unread_topic_from_message_id.return_value = (1, "topic")
 
         return_value = mid_col_view.keypress(size, key)
 
@@ -904,7 +905,8 @@ class TestMiddleColumnView:
     ):
         size = widget_size(mid_col_view)
         mocker.patch(MIDCOLVIEW + ".focus_position")
-        mid_col_view.model.get_next_unread_topic.return_value = None
+        mocker.patch.object(self.view, "message_view")
+        mid_col_view.model.next_unread_topic_from_message_id.return_value = None
 
         return_value = mid_col_view.keypress(size, key)