Repository: zulip/zulip

Issue:
Resolving topics that are longer than 59 characters doesn't generate expected notices
I'm not sure how best to fix this, but if you try to mark as resolved a 59-60 character name topic, then we end up truncating the topic name (because there's not space to add the 2 extra characters that are `RESOLVED_TOPIC_PREFIX`); this in turn makes logic like the following unable to correctly determine whether the topic was truncated:

```
        new_thread_notification_string = None
        if send_notification_to_new_thread and (
            new_stream is not None
            or not sent_resolve_topic_notification
            or (
                topic_name is not None
                and orig_topic_name.lstrip(RESOLVED_TOPIC_PREFIX)
                != topic_name.lstrip(RESOLVED_TOPIC_PREFIX)
            )
        ):
```

I'm not sure how best to fix this. I think it's possible we want to (1) change the topic truncation logic to use a single character `…` symbol rather than `...`, which might help some, and then do some sort of special logic that saves the raw topic name passed into the `message_edit` code path so that we can check whether it's a topic resolution pre-truncation. 

```
    if topic_name is not None:                                                                                             
        topic_name = truncate_topic(topic_name)                                                                            
```



Discussion:
Hello @zulip/server-message-view members, this issue was labeled with the "area: message-editing" label, so you may want to check it out!

<!-- areaLabelAddition -->


Resolving patch:
diff --git a/zerver/actions/message_edit.py b/zerver/actions/message_edit.py
--- a/zerver/actions/message_edit.py
+++ b/zerver/actions/message_edit.py
@@ -134,14 +134,10 @@ def maybe_send_resolve_topic_notifications(
     changed_messages: List[Message],
 ) -> bool:
     """Returns True if resolve topic notifications were in fact sent."""
-
     # Note that topics will have already been stripped in check_update_message.
     #
     # This logic is designed to treat removing a weird "✔ ✔✔ "
     # prefix as unresolving the topic.
-    if old_topic.lstrip(RESOLVED_TOPIC_PREFIX) != new_topic.lstrip(RESOLVED_TOPIC_PREFIX):
-        return False
-
     topic_resolved: bool = new_topic.startswith(RESOLVED_TOPIC_PREFIX) and not old_topic.startswith(
         RESOLVED_TOPIC_PREFIX
     )
@@ -542,6 +538,10 @@ def do_update_message(
                 user_id for user_id in new_stream_sub_ids if user_id not in old_stream_sub_ids
             ]
 
+    # We save the full topic name so that checks that require comparison
+    # between the original topic and the topic name passed into this function
+    # will not be affected by the potential truncation of topic_name below.
+    pre_truncation_topic_name = topic_name
     if topic_name is not None:
         topic_name = truncate_topic(topic_name)
         target_message.set_topic_name(topic_name)
@@ -869,9 +869,9 @@ def user_info(um: UserMessage) -> Dict[str, Any]:
             new_stream is not None
             or not sent_resolve_topic_notification
             or (
-                topic_name is not None
+                pre_truncation_topic_name is not None
                 and orig_topic_name.lstrip(RESOLVED_TOPIC_PREFIX)
-                != topic_name.lstrip(RESOLVED_TOPIC_PREFIX)
+                != pre_truncation_topic_name.lstrip(RESOLVED_TOPIC_PREFIX)
             )
         ):
             if moved_all_visible_messages:


Test patch:
diff --git a/zerver/tests/test_message_edit.py b/zerver/tests/test_message_edit.py
--- a/zerver/tests/test_message_edit.py
+++ b/zerver/tests/test_message_edit.py
@@ -17,7 +17,7 @@
 from zerver.actions.realm_settings import do_change_realm_plan_type, do_set_realm_property
 from zerver.actions.streams import do_change_stream_post_policy, do_deactivate_stream
 from zerver.actions.users import do_change_user_role
-from zerver.lib.message import MessageDict, has_message_access, messages_for_ids
+from zerver.lib.message import MessageDict, has_message_access, messages_for_ids, truncate_topic
 from zerver.lib.test_classes import ZulipTestCase, get_topic_messages
 from zerver.lib.test_helpers import cache_tries_captured, queries_captured
 from zerver.lib.topic import RESOLVED_TOPIC_PREFIX, TOPIC_NAME
@@ -28,7 +28,16 @@
     topic_is_muted,
 )
 from zerver.lib.utils import assert_is_not_none
-from zerver.models import Message, Realm, Stream, UserMessage, UserProfile, get_realm, get_stream
+from zerver.models import (
+    MAX_TOPIC_NAME_LENGTH,
+    Message,
+    Realm,
+    Stream,
+    UserMessage,
+    UserProfile,
+    get_realm,
+    get_stream,
+)
 
 if TYPE_CHECKING:
     from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
@@ -2722,6 +2731,125 @@ def test_notify_no_topic_after_message_move(self) -> None:
         self.assert_length(messages, 1)
         self.assertEqual(messages[0].content, "First")
 
+    def test_notify_resolve_topic_long_name(self) -> None:
+        user_profile = self.example_user("hamlet")
+        self.login("hamlet")
+        stream = self.make_stream("public stream")
+        self.subscribe(user_profile, stream.name)
+        # Marking topics with a long name as resolved causes the new topic name to be truncated.
+        # We want to avoid having code paths believing that the topic is "moved" instead of
+        # "resolved" in this edge case.
+        topic_name = "a" * MAX_TOPIC_NAME_LENGTH
+        msg_id = self.send_stream_message(
+            user_profile, stream.name, topic_name=topic_name, content="First"
+        )
+
+        resolved_topic = RESOLVED_TOPIC_PREFIX + topic_name
+        result = self.client_patch(
+            "/json/messages/" + str(msg_id),
+            {
+                "topic": resolved_topic,
+                "propagate_mode": "change_all",
+            },
+        )
+        self.assert_json_success(result)
+
+        new_topic_name = truncate_topic(resolved_topic)
+        messages = get_topic_messages(user_profile, stream, new_topic_name)
+        self.assert_length(messages, 2)
+        self.assertEqual(messages[0].content, "First")
+        self.assertEqual(
+            messages[1].content,
+            f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as resolved.",
+        )
+
+        # Note that we are removing the prefix from the already truncated topic,
+        # so unresolved_topic_name will not be the same as the original topic_name
+        unresolved_topic_name = new_topic_name.replace(RESOLVED_TOPIC_PREFIX, "")
+        result = self.client_patch(
+            "/json/messages/" + str(msg_id),
+            {
+                "topic": unresolved_topic_name,
+                "propagate_mode": "change_all",
+            },
+        )
+        self.assert_json_success(result)
+
+        messages = get_topic_messages(user_profile, stream, unresolved_topic_name)
+        self.assert_length(messages, 3)
+        self.assertEqual(
+            messages[2].content,
+            f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as unresolved.",
+        )
+
+    def test_notify_resolve_and_move_topic(self) -> None:
+        user_profile = self.example_user("hamlet")
+        self.login("hamlet")
+        stream = self.make_stream("public stream")
+        topic = "test"
+        self.subscribe(user_profile, stream.name)
+
+        # Resolve a topic normally first
+        msg_id = self.send_stream_message(user_profile, stream.name, "foo", topic_name=topic)
+        resolved_topic = RESOLVED_TOPIC_PREFIX + topic
+        result = self.client_patch(
+            "/json/messages/" + str(msg_id),
+            {
+                "topic": resolved_topic,
+                "propagate_mode": "change_all",
+            },
+        )
+        self.assert_json_success(result)
+
+        messages = get_topic_messages(user_profile, stream, resolved_topic)
+        self.assert_length(messages, 2)
+        self.assertEqual(
+            messages[1].content,
+            f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as resolved.",
+        )
+
+        # Test unresolving a topic while moving it (✔ test -> bar)
+        new_topic = "bar"
+        result = self.client_patch(
+            "/json/messages/" + str(msg_id),
+            {
+                "topic": new_topic,
+                "propagate_mode": "change_all",
+            },
+        )
+        self.assert_json_success(result)
+        messages = get_topic_messages(user_profile, stream, new_topic)
+        self.assert_length(messages, 4)
+        self.assertEqual(
+            messages[2].content,
+            f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as unresolved.",
+        )
+        self.assertEqual(
+            messages[3].content,
+            f"This topic was moved here from #**public stream>✔ test** by @_**{user_profile.full_name}|{user_profile.id}**.",
+        )
+
+        # Now test moving the topic while also resolving it (bar -> ✔ baz)
+        new_resolved_topic = RESOLVED_TOPIC_PREFIX + "baz"
+        result = self.client_patch(
+            "/json/messages/" + str(msg_id),
+            {
+                "topic": new_resolved_topic,
+                "propagate_mode": "change_all",
+            },
+        )
+        self.assert_json_success(result)
+        messages = get_topic_messages(user_profile, stream, new_resolved_topic)
+        self.assert_length(messages, 6)
+        self.assertEqual(
+            messages[4].content,
+            f"@_**{user_profile.full_name}|{user_profile.id}** has marked this topic as resolved.",
+        )
+        self.assertEqual(
+            messages[5].content,
+            f"This topic was moved here from #**public stream>{new_topic}** by @_**{user_profile.full_name}|{user_profile.id}**.",
+        )
+
     def parameterized_test_move_message_involving_private_stream(
         self,
         from_invite_only: bool,