Repository: zulip/zulip

Issue:
Renaming whole topic causes unmute
If you mute a topic and someone takes a message out of it by renaming its topic, the extracted messages are "unmuted". Unless I am mistaken, this is a feature that lets you know these messages aren't part of the topic you muted.
However, when the whole topic is renamed *a priori* you still do not want to see the contents and thus the topic should stay muted.



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 -->
Yeah, I think I agree that in the case that it's a full-topic change, we should update any MutedTopic entries to point to the new topic.  This might be a little complicated to do, since we'll need to notify the frontend in addition to updating the backend data model.

Should this still happen when the stream changed as well?  Probably not.  

@aman566 FYI; this is probably lower priority than the other message editing issues on your plate.

Resolving patch:
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -204,7 +204,12 @@
 )
 from zerver.lib.user_mutes import add_user_mute, get_muting_users, get_user_mutes
 from zerver.lib.user_status import update_user_status
-from zerver.lib.user_topics import add_topic_mute, get_topic_mutes, remove_topic_mute
+from zerver.lib.user_topics import (
+    add_topic_mute,
+    get_topic_mutes,
+    get_users_muting_topic,
+    remove_topic_mute,
+)
 from zerver.lib.users import (
     check_bot_name_available,
     check_full_name,
@@ -7086,6 +7091,47 @@ def user_info(um: UserMessage) -> Dict[str, Any]:
 
             users_to_be_notified += list(map(subscriber_info, sorted(list(subscriber_ids))))
 
+    # Migrate muted topic configuration in the following circumstances:
+    #
+    # * If propagate_mode is change_all, do so unconditionally.
+    #
+    # * If propagate_mode is change_later, it's likely that we want to
+    #   move these only when it appears that the intent is to move
+    #   most of the topic, not just the last 1-2 messages which may
+    #   have been "off topic". At present we do so unconditionally.
+    #
+    # * Never move muted topic configuration with change_one.
+    #
+    # We may want more complex behavior in cases where one appears to
+    # be merging topics (E.g. there are existing messages in the
+    # target topic).
+    #
+    # Moving a topic to another stream is complicated in that we want
+    # to avoid creating a UserTopic row for the user in a stream that
+    # they don't have access to; doing so could leak information about
+    # the existence of a private stream to some users. See the
+    # moved_all_visible_messages below for related details.
+    #
+    # So for now, we require new_stream=None for this feature.
+    if topic_name is not None and propagate_mode != "change_one" and new_stream is None:
+        assert stream_being_edited is not None
+        for muting_user in get_users_muting_topic(stream_being_edited.id, orig_topic_name):
+            # TODO: Ideally, this would be a bulk update operation,
+            # because we are doing database operations in a loop here.
+            #
+            # This loop is only acceptable in production because it is
+            # rare for more than a few users to have muted an
+            # individual topic that is being moved; as of this
+            # writing, no individual topic in Zulip Cloud had been
+            # muted by more than 100 users.
+
+            # We call remove_topic_mute rather than do_unmute_topic to
+            # avoid sending two events with new muted topics in
+            # immediate succession; this is correct only because
+            # muted_topics events always send the full set of topics.
+            remove_topic_mute(muting_user, stream_being_edited.id, orig_topic_name)
+            do_mute_topic(muting_user, stream_being_edited, topic_name)
+
     send_event(user_profile.realm, event, users_to_be_notified)
 
     if len(changed_messages) > 0 and new_stream is not None and stream_being_edited is not None:
@@ -7906,6 +7952,9 @@ def do_mute_topic(
 
 
 def do_unmute_topic(user_profile: UserProfile, stream: Stream, topic: str) -> None:
+    # Note: If you add any new code to this function, the
+    # remove_topic_mute call in do_update_message will need to be
+    # updated for correctness.
     try:
         remove_topic_mute(user_profile, stream.id, topic)
     except UserTopic.DoesNotExist:
diff --git a/zerver/lib/user_topics.py b/zerver/lib/user_topics.py
--- a/zerver/lib/user_topics.py
+++ b/zerver/lib/user_topics.py
@@ -1,6 +1,7 @@
 import datetime
 from typing import Any, Callable, Dict, List, Optional, Tuple
 
+from django.db.models.query import QuerySet
 from django.utils.timezone import now as timezone_now
 from sqlalchemy.sql import ClauseElement, and_, column, not_, or_
 from sqlalchemy.types import Integer
@@ -152,3 +153,13 @@ def is_muted(recipient_id: int, topic: str) -> bool:
         return (recipient_id, topic.lower()) in tups
 
     return is_muted
+
+
+def get_users_muting_topic(stream_id: int, topic_name: str) -> QuerySet[UserProfile]:
+    return UserProfile.objects.select_related("realm").filter(
+        id__in=UserTopic.objects.filter(
+            stream_id=stream_id,
+            visibility_policy=UserTopic.MUTED,
+            topic_name__iexact=topic_name,
+        ).values("user_profile_id")
+    )


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
@@ -9,6 +9,7 @@
 from django.utils.timezone import now as timezone_now
 
 from zerver.lib.actions import (
+    check_update_message,
     do_add_reaction,
     do_change_realm_plan_type,
     do_change_stream_post_policy,
@@ -24,6 +25,12 @@
 from zerver.lib.test_classes import ZulipTestCase
 from zerver.lib.test_helpers import cache_tries_captured, queries_captured
 from zerver.lib.topic import RESOLVED_TOPIC_PREFIX, TOPIC_NAME
+from zerver.lib.user_topics import (
+    get_topic_mutes,
+    get_users_muting_topic,
+    set_topic_mutes,
+    topic_is_muted,
+)
 from zerver.models import Message, Realm, Stream, UserMessage, UserProfile, get_realm, get_stream
 
 
@@ -1236,6 +1243,108 @@ def notify(user_id: int) -> Dict[str, Any]:
         users_to_be_notified = list(map(notify, [hamlet.id]))
         do_update_message_topic_success(hamlet, message, "Change again", users_to_be_notified)
 
+    @mock.patch("zerver.lib.actions.send_event")
+    def test_edit_muted_topic(self, mock_send_event: mock.MagicMock) -> None:
+        stream_name = "Stream 123"
+        stream = self.make_stream(stream_name)
+        hamlet = self.example_user("hamlet")
+        cordelia = self.example_user("cordelia")
+        aaron = self.example_user("aaron")
+        self.subscribe(hamlet, stream_name)
+        self.login_user(hamlet)
+        message_id = self.send_stream_message(
+            hamlet, stream_name, topic_name="Topic1", content="Hello World"
+        )
+
+        self.subscribe(cordelia, stream_name)
+        self.login_user(cordelia)
+        self.subscribe(aaron, stream_name)
+        self.login_user(aaron)
+
+        muted_topics = [
+            [stream_name, "Topic1"],
+            [stream_name, "Topic2"],
+        ]
+        set_topic_mutes(hamlet, muted_topics)
+        set_topic_mutes(cordelia, muted_topics)
+
+        # Returns the users that need to be notified when a message topic is changed
+        def notify(user_id: int) -> Dict[str, Any]:
+            um = UserMessage.objects.get(message=message_id)
+            if um.user_profile_id == user_id:
+                return {
+                    "id": user_id,
+                    "flags": um.flags_list(),
+                }
+
+            else:
+                return {
+                    "id": user_id,
+                    "flags": ["read"],
+                }
+
+        users_to_be_notified = list(map(notify, [hamlet.id, cordelia.id, aaron.id]))
+        change_all_topic_name = "Topic 1 edited"
+
+        with queries_captured() as queries:
+            check_update_message(
+                user_profile=hamlet,
+                message_id=message_id,
+                stream_id=None,
+                topic_name=change_all_topic_name,
+                propagate_mode="change_all",
+                send_notification_to_old_thread=False,
+                send_notification_to_new_thread=False,
+                content=None,
+            )
+            # This code path adds 9 (1 + 4/user with muted topics) to
+            # the number of database queries for moving a topic.
+            self.assert_length(queries, 18)
+
+        for muting_user in get_users_muting_topic(stream.id, change_all_topic_name):
+            for user in users_to_be_notified:
+                if muting_user.id == user["id"]:
+                    user["muted_topics"] = get_topic_mutes(muting_user)
+                    break
+
+        self.assertFalse(topic_is_muted(hamlet, stream.id, "Topic1"))
+        self.assertFalse(topic_is_muted(cordelia, stream.id, "Topic1"))
+        self.assertFalse(topic_is_muted(aaron, stream.id, "Topic1"))
+        self.assertTrue(topic_is_muted(hamlet, stream.id, "Topic2"))
+        self.assertTrue(topic_is_muted(cordelia, stream.id, "Topic2"))
+        self.assertFalse(topic_is_muted(aaron, stream.id, "Topic2"))
+        self.assertTrue(topic_is_muted(hamlet, stream.id, change_all_topic_name))
+        self.assertTrue(topic_is_muted(cordelia, stream.id, change_all_topic_name))
+        self.assertFalse(topic_is_muted(aaron, stream.id, change_all_topic_name))
+
+        change_later_topic_name = "Topic 1 edited again"
+        check_update_message(
+            user_profile=hamlet,
+            message_id=message_id,
+            stream_id=None,
+            topic_name=change_later_topic_name,
+            propagate_mode="change_later",
+            send_notification_to_old_thread=False,
+            send_notification_to_new_thread=False,
+            content=None,
+        )
+        self.assertFalse(topic_is_muted(hamlet, stream.id, change_all_topic_name))
+        self.assertTrue(topic_is_muted(hamlet, stream.id, change_later_topic_name))
+
+        change_one_topic_name = "Topic 1 edited change_one"
+        check_update_message(
+            user_profile=hamlet,
+            message_id=message_id,
+            stream_id=None,
+            topic_name=change_one_topic_name,
+            propagate_mode="change_one",
+            send_notification_to_old_thread=False,
+            send_notification_to_new_thread=False,
+            content=None,
+        )
+        self.assertFalse(topic_is_muted(hamlet, stream.id, change_one_topic_name))
+        self.assertTrue(topic_is_muted(hamlet, stream.id, change_later_topic_name))
+
     @mock.patch("zerver.lib.actions.send_event")
     def test_wildcard_mention(self, mock_send_event: mock.MagicMock) -> None:
         stream_name = "Macbeth"