Repository: zulip/zulip

Issue:
Resolve topic should mark topic as unread only for participants
At present, resolving a topic marks it as unread. This can be annoying to users, as it unnecessarily bumps the topic, when in fact the intent is to clearly mark it as being "done". However, participants in a topic may want to be alerted when a topic is resolved, e.g. to make sure that they agree with the decision to resolve it.

To balance between these needs, **resolving** or **unresolving** a topic should create new unread messages only for users who have **participated in the topic**. For all others, the resolve/unresolve messages should automatically be marked as read.

When **Follow topic** is implemented in #6027, this behavior should be altered so that resolving/unresolving topics creates unreads for anyone following the topic.

This change is important for resolve topic workflows, so we should consider it a release goal.

[CZO discussion thread](https://chat.zulip.org/#narrow/stream/137-feedback/topic/Topic.20resolution.20-.3E.20bump.3F)


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 -->

@zulipbot claim

Hello @mitanshu001, it looks like you've currently claimed 1 issue in this repository. We encourage new contributors to focus their efforts on at most 1 issue at a time, so please complete your work on your other claimed issues before trying to claim this issue again.

We look forward to your valuable contributions!

**ERROR:** You have not claimed this issue to work on yet.
@zulipbot claim.
@alya are the required changes are of Python(backend)? I am new to the community. This will be my 2nd issue. Please let me know if this is not a good issue for beginners
@timabbott will have more insight into whether this is a good beginner issue.
This is probably accessible to someone good at using `git grep` and similar to trace call flows in a Python codebase.  Structurally, you want to pass the `mark_as_read` parameter through to to `do_send_messages` from the `maybe_send_resolve_topic_notifications` call to `internal_send_stream_message`.

Except that's probably awkward, since the participants are rare (So in a 10K user stream, you'll be passing 10K-7 users around).  So probably we want a new `unread_for_participants_only` parameter that touches that same code.

I expect it won't merge quickly, as we'll need some iteration (e.g. we may refactor it 2-3 times after getting something basic working that has tests), but it's an OK thing to work on for someone experienced in development in general but not the Zulip codebase specifically.
Thanks, Tim for the explanation
Unassignning myself as I am not getting much time currently, so other people can pick it up. In case no one starts working on it in 4-5  days will pick it up.
@zulipbot abandon
@zulipbot claim
Welcome to Zulip, @collinwhitlow! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

**ERROR:** You have not claimed this issue to work on yet.
@zulipbot claim
Hello @collinwhitlow, it looks like we've already sent you a collaboration invite at https://github.com/zulip/zulip/invitations, but you haven't accepted it yet!

Please accept the invite and try to claim this issue again afterwards. We look forward to your contributions!

@alya my invitation expired before I had a chance to get started on it. Do you have any idea how I can go about getting a new one so I can successfully claim this issue?
We may need to manually cancel and resend it? I'm not sure. CC @timabbott 
Sent a new one.
Thank you, @timabbott 
@zulipbot claim
Hello @collinwhitlow, it looks like you've currently claimed 1 issue in this repository. We encourage new contributors to focus their efforts on at most 1 issue at a time, so please complete your work on your other claimed issues before trying to claim this issue again.

We look forward to your valuable contributions!

@timabbott Since I am new to contributing to Zulip, do you happen to know which files would be the most beneficial to start with for this issue?? Thank you!
@collinwhitlow please take a look at the (recently updated) [Zulip contributor guide](https://zulip.readthedocs.io/en/latest/overview/contributing.html) to learn more about how to get help. Also, keep in mind the following guideline:

> Before you claim an issue, you should be confident that you will be able to tackle it effectively.

I will go ahead and unassign this issue, and you should feel free to re-claim it once you have figured out how to approach it (or pick a different one if you prefer).

That's understandable. I spent some time reviewing that link, and I am now more confident I am in a position to succeed. I'll give this another shot. 
@zulipbot claim
Cool, let me know when you have a draft PR ready for review; this is one of the issues I'm tracking as important to include in the upcoming 5.0 release (final likely in January, but we want to merge as many things like this as we can before then).
Hello @collinwhitlow, you have been unassigned from this issue because you have not updated this issue or any referenced pull requests for over 14 days.

You can reclaim this issue or claim any other issue by commenting `@zulipbot claim` on that issue.

Thanks for your contributions, and hope to see you again soon!


Resolving patch:
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -1910,6 +1910,7 @@ def build_message_send_dict(
     widget_content_dict: Optional[Dict[str, Any]] = None,
     email_gateway: bool = False,
     mention_backend: Optional[MentionBackend] = None,
+    limit_unread_user_ids: Optional[Set[int]] = None,
 ) -> SendMessageRequest:
     """Returns a dictionary that can be passed into do_send_messages.  In
     production, this is always called by check_message, but some
@@ -2015,6 +2016,7 @@ def build_message_send_dict(
         wildcard_mention_user_ids=wildcard_mention_user_ids,
         links_for_embed=links_for_embed,
         widget_content=widget_content_dict,
+        limit_unread_user_ids=limit_unread_user_ids,
     )
 
     return message_send_dict
@@ -2069,6 +2071,7 @@ def do_send_messages(
                 stream_email_user_ids=send_request.stream_email_user_ids,
                 mentioned_user_ids=mentioned_user_ids,
                 mark_as_read_for_users=mark_as_read_for_users,
+                limit_unread_user_ids=send_request.limit_unread_user_ids,
             )
 
             for um in user_messages:
@@ -2280,6 +2283,7 @@ def create_user_messages(
     stream_email_user_ids: AbstractSet[int],
     mentioned_user_ids: AbstractSet[int],
     mark_as_read_for_users: Set[int],
+    limit_unread_user_ids: Optional[Set[int]],
 ) -> List[UserMessageLite]:
     # These properties on the Message are set via
     # render_markdown by code in the Markdown inline patterns
@@ -2316,8 +2320,10 @@ def create_user_messages(
     for user_profile_id in um_eligible_user_ids:
         flags = base_flags
         if (
-            user_profile_id == sender_id and message.sent_by_human()
-        ) or user_profile_id in mark_as_read_for_users:
+            (user_profile_id == sender_id and message.sent_by_human())
+            or user_profile_id in mark_as_read_for_users
+            or (limit_unread_user_ids is not None and user_profile_id not in limit_unread_user_ids)
+        ):
             flags |= UserMessage.flags.read
         if user_profile_id in mentioned_user_ids:
             flags |= UserMessage.flags.mentioned
@@ -3364,6 +3370,7 @@ def check_message(
     *,
     skip_stream_access_check: bool = False,
     mention_backend: Optional[MentionBackend] = None,
+    limit_unread_user_ids: Optional[Set[int]] = None,
 ) -> SendMessageRequest:
     """See
     https://zulip.readthedocs.io/en/latest/subsystems/sending-messages.html
@@ -3490,6 +3497,7 @@ def check_message(
         widget_content_dict=widget_content_dict,
         email_gateway=email_gateway,
         mention_backend=mention_backend,
+        limit_unread_user_ids=limit_unread_user_ids,
     )
 
     if stream is not None and message_send_dict.rendering_result.mentions_wildcard:
@@ -3507,6 +3515,7 @@ def _internal_prep_message(
     content: str,
     email_gateway: bool = False,
     mention_backend: Optional[MentionBackend] = None,
+    limit_unread_user_ids: Optional[Set[int]] = None,
 ) -> Optional[SendMessageRequest]:
     """
     Create a message object and checks it, but doesn't send it or save it to the database.
@@ -3537,6 +3546,7 @@ def _internal_prep_message(
             realm=realm,
             email_gateway=email_gateway,
             mention_backend=mention_backend,
+            limit_unread_user_ids=limit_unread_user_ids,
         )
     except JsonableError as e:
         logging.exception(
@@ -3555,6 +3565,7 @@ def internal_prep_stream_message(
     topic: str,
     content: str,
     email_gateway: bool = False,
+    limit_unread_user_ids: Optional[Set[int]] = None,
 ) -> Optional[SendMessageRequest]:
     """
     See _internal_prep_message for details of how this works.
@@ -3568,6 +3579,7 @@ def internal_prep_stream_message(
         addressee=addressee,
         content=content,
         email_gateway=email_gateway,
+        limit_unread_user_ids=limit_unread_user_ids,
     )
 
 
@@ -3629,9 +3641,12 @@ def internal_send_stream_message(
     topic: str,
     content: str,
     email_gateway: bool = False,
+    limit_unread_user_ids: Optional[Set[int]] = None,
 ) -> Optional[int]:
 
-    message = internal_prep_stream_message(sender, stream, topic, content, email_gateway)
+    message = internal_prep_stream_message(
+        sender, stream, topic, content, email_gateway, limit_unread_user_ids=limit_unread_user_ids
+    )
 
     if message is None:
         return None
@@ -6403,6 +6418,7 @@ def maybe_send_resolve_topic_notifications(
     stream: Stream,
     old_topic: str,
     new_topic: str,
+    changed_messages: List[Message],
 ) -> None:
     # Note that topics will have already been stripped in check_update_message.
     #
@@ -6434,6 +6450,14 @@ def maybe_send_resolve_topic_notifications(
         # not a bug with the "resolve topics" feature.
         return
 
+    # Compute the users who either sent or reacted to messages that
+    # were moved via the "resolve topic' action. Only those users
+    # should be eligible for this message being managed as unread.
+    affected_participant_ids = (set(message.sender_id for message in changed_messages)) | set(
+        Reaction.objects.filter(message__in=changed_messages).values_list(
+            "user_profile_id", flat=True
+        )
+    )
     sender = get_system_bot(settings.NOTIFICATION_BOT, user_profile.realm_id)
     user_mention = silent_mention_syntax_for_user(user_profile)
     with override_language(stream.realm.default_language):
@@ -6449,6 +6473,7 @@ def maybe_send_resolve_topic_notifications(
             notification_string.format(
                 user=user_mention,
             ),
+            limit_unread_user_ids=affected_participant_ids,
         )
 
 
@@ -7023,6 +7048,7 @@ def user_info(um: UserMessage) -> Dict[str, Any]:
             stream=stream_being_edited,
             old_topic=orig_topic_name,
             new_topic=topic_name,
+            changed_messages=changed_messages,
         )
 
     return len(changed_messages)
diff --git a/zerver/lib/message.py b/zerver/lib/message.py
--- a/zerver/lib/message.py
+++ b/zerver/lib/message.py
@@ -126,6 +126,7 @@ class SendMessageRequest:
     submessages: List[Dict[str, Any]] = field(default_factory=list)
     deliver_at: Optional[datetime.datetime] = None
     delivery_type: Optional[str] = None
+    limit_unread_user_ids: Optional[Set[int]] = None
 
 
 # We won't try to fetch more unread message IDs from the database than


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 (
+    do_add_reaction,
     do_change_realm_plan_type,
     do_change_stream_post_policy,
     do_change_user_role,
@@ -2165,6 +2166,8 @@ def test_mark_topic_as_resolved(self) -> None:
         self.login("iago")
         admin_user = self.example_user("iago")
         hamlet = self.example_user("hamlet")
+        cordelia = self.example_user("cordelia")
+        aaron = self.example_user("aaron")
 
         # Set the user's translation language to German to test that
         # it is overridden by the realm's default language.
@@ -2173,11 +2176,16 @@ def test_mark_topic_as_resolved(self) -> None:
         stream = self.make_stream("new")
         self.subscribe(admin_user, stream.name)
         self.subscribe(hamlet, stream.name)
+        self.subscribe(cordelia, stream.name)
+        self.subscribe(aaron, stream.name)
 
         original_topic = "topic 1"
         id1 = self.send_stream_message(hamlet, "new", topic_name=original_topic)
         id2 = self.send_stream_message(admin_user, "new", topic_name=original_topic)
 
+        msg1 = Message.objects.get(id=id1)
+        do_add_reaction(aaron, msg1, "tada", "1f389", "unicode_emoji")
+
         # Check that we don't incorrectly send "unresolve topic"
         # notifications when asking the preserve the current topic.
         result = self.client_patch(
@@ -2216,6 +2224,23 @@ def test_mark_topic_as_resolved(self) -> None:
             f"@_**Iago|{admin_user.id}** has marked this topic as resolved.",
         )
 
+        # Check topic resolved notification message is only unread for participants.
+        assert (
+            UserMessage.objects.filter(
+                user_profile__in=[admin_user, hamlet, aaron], message__id=messages[2].id
+            )
+            .extra(where=[UserMessage.where_unread()])
+            .count()
+            == 3
+        )
+
+        assert (
+            UserMessage.objects.filter(user_profile=cordelia, message__id=messages[2].id)
+            .extra(where=[UserMessage.where_unread()])
+            .count()
+            == 0
+        )
+
         # Now move to a weird state and confirm no new messages
         weird_topic = "✔ ✔✔" + original_topic
         result = self.client_patch(
@@ -2267,6 +2292,23 @@ def test_mark_topic_as_resolved(self) -> None:
             f"@_**Iago|{admin_user.id}** has marked this topic as unresolved.",
         )
 
+        # Check topic unresolved notification message is only unread for participants.
+        assert (
+            UserMessage.objects.filter(
+                user_profile__in=[admin_user, hamlet, aaron], message__id=messages[3].id
+            )
+            .extra(where=[UserMessage.where_unread()])
+            .count()
+            == 3
+        )
+
+        assert (
+            UserMessage.objects.filter(user_profile=cordelia, message__id=messages[3].id)
+            .extra(where=[UserMessage.where_unread()])
+            .count()
+            == 0
+        )
+
 
 class DeleteMessageTest(ZulipTestCase):
     def test_delete_message_invalid_request_format(self) -> None: