mirror of https://github.com/zulip/zulip.git
resolve topic: Limit unread flag on automated notifications.
Previously, users found it annoying that the automated "Resolve topic" notifications triggered an unread for everyone in the stream; this discouraged some users from using the feature on older threads for fear of being annoying. We change this to a better default, of only users who participated in the topic (via either messages or reactions) being eligible for the new message being unread. We will likely want to create global and stream-level notifications settings to control this behavior as a follow-up -- some users, like me, might prefer the simpler "Always unread" behavior in some streams. Note that the automated notifications that a topic was resolved will still result in the topic being moved to the top of the left sidebar. This would be somewhat difficult to change, since the left sidebar algorithm just looks at the highest message ID in the topic. Fixes #19709. Tests added by Aman Agrawal (amanagr@zulip.com).
This commit is contained in:
parent
38003408cb
commit
1a46df40c3
|
@ -10,7 +10,8 @@ investigation, or notification. Marking a topic as resolved:
|
||||||
* Puts a ✔ at the beginning of the topic name, e.g. `example topic`
|
* Puts a ✔ at the beginning of the topic name, e.g. `example topic`
|
||||||
becomes `✔ example topic`.
|
becomes `✔ example topic`.
|
||||||
* Triggers an automated message from Notification Bot indicating that
|
* Triggers an automated message from Notification Bot indicating that
|
||||||
you resolved the topic.
|
you resolved the topic. This message will be marked as unread
|
||||||
|
only for users who had participated in the topic.
|
||||||
* Changes whether the topic appears when using the `is:resolved` and
|
* Changes whether the topic appears when using the `is:resolved` and
|
||||||
`-is:resolved` [search operators](/help/search-for-messages).
|
`-is:resolved` [search operators](/help/search-for-messages).
|
||||||
|
|
||||||
|
|
|
@ -1910,6 +1910,7 @@ def build_message_send_dict(
|
||||||
widget_content_dict: Optional[Dict[str, Any]] = None,
|
widget_content_dict: Optional[Dict[str, Any]] = None,
|
||||||
email_gateway: bool = False,
|
email_gateway: bool = False,
|
||||||
mention_backend: Optional[MentionBackend] = None,
|
mention_backend: Optional[MentionBackend] = None,
|
||||||
|
limit_unread_user_ids: Optional[Set[int]] = None,
|
||||||
) -> SendMessageRequest:
|
) -> SendMessageRequest:
|
||||||
"""Returns a dictionary that can be passed into do_send_messages. In
|
"""Returns a dictionary that can be passed into do_send_messages. In
|
||||||
production, this is always called by check_message, but some
|
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,
|
wildcard_mention_user_ids=wildcard_mention_user_ids,
|
||||||
links_for_embed=links_for_embed,
|
links_for_embed=links_for_embed,
|
||||||
widget_content=widget_content_dict,
|
widget_content=widget_content_dict,
|
||||||
|
limit_unread_user_ids=limit_unread_user_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_send_dict
|
return message_send_dict
|
||||||
|
@ -2069,6 +2071,7 @@ def do_send_messages(
|
||||||
stream_email_user_ids=send_request.stream_email_user_ids,
|
stream_email_user_ids=send_request.stream_email_user_ids,
|
||||||
mentioned_user_ids=mentioned_user_ids,
|
mentioned_user_ids=mentioned_user_ids,
|
||||||
mark_as_read_for_users=mark_as_read_for_users,
|
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:
|
for um in user_messages:
|
||||||
|
@ -2280,6 +2283,7 @@ def create_user_messages(
|
||||||
stream_email_user_ids: AbstractSet[int],
|
stream_email_user_ids: AbstractSet[int],
|
||||||
mentioned_user_ids: AbstractSet[int],
|
mentioned_user_ids: AbstractSet[int],
|
||||||
mark_as_read_for_users: Set[int],
|
mark_as_read_for_users: Set[int],
|
||||||
|
limit_unread_user_ids: Optional[Set[int]],
|
||||||
) -> List[UserMessageLite]:
|
) -> List[UserMessageLite]:
|
||||||
# These properties on the Message are set via
|
# These properties on the Message are set via
|
||||||
# render_markdown by code in the Markdown inline patterns
|
# 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:
|
for user_profile_id in um_eligible_user_ids:
|
||||||
flags = base_flags
|
flags = base_flags
|
||||||
if (
|
if (
|
||||||
user_profile_id == sender_id and message.sent_by_human()
|
(user_profile_id == sender_id and message.sent_by_human())
|
||||||
) or user_profile_id in mark_as_read_for_users:
|
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
|
flags |= UserMessage.flags.read
|
||||||
if user_profile_id in mentioned_user_ids:
|
if user_profile_id in mentioned_user_ids:
|
||||||
flags |= UserMessage.flags.mentioned
|
flags |= UserMessage.flags.mentioned
|
||||||
|
@ -3364,6 +3370,7 @@ def check_message(
|
||||||
*,
|
*,
|
||||||
skip_stream_access_check: bool = False,
|
skip_stream_access_check: bool = False,
|
||||||
mention_backend: Optional[MentionBackend] = None,
|
mention_backend: Optional[MentionBackend] = None,
|
||||||
|
limit_unread_user_ids: Optional[Set[int]] = None,
|
||||||
) -> SendMessageRequest:
|
) -> SendMessageRequest:
|
||||||
"""See
|
"""See
|
||||||
https://zulip.readthedocs.io/en/latest/subsystems/sending-messages.html
|
https://zulip.readthedocs.io/en/latest/subsystems/sending-messages.html
|
||||||
|
@ -3490,6 +3497,7 @@ def check_message(
|
||||||
widget_content_dict=widget_content_dict,
|
widget_content_dict=widget_content_dict,
|
||||||
email_gateway=email_gateway,
|
email_gateway=email_gateway,
|
||||||
mention_backend=mention_backend,
|
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:
|
if stream is not None and message_send_dict.rendering_result.mentions_wildcard:
|
||||||
|
@ -3507,6 +3515,7 @@ def _internal_prep_message(
|
||||||
content: str,
|
content: str,
|
||||||
email_gateway: bool = False,
|
email_gateway: bool = False,
|
||||||
mention_backend: Optional[MentionBackend] = None,
|
mention_backend: Optional[MentionBackend] = None,
|
||||||
|
limit_unread_user_ids: Optional[Set[int]] = None,
|
||||||
) -> Optional[SendMessageRequest]:
|
) -> Optional[SendMessageRequest]:
|
||||||
"""
|
"""
|
||||||
Create a message object and checks it, but doesn't send it or save it to the database.
|
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,
|
realm=realm,
|
||||||
email_gateway=email_gateway,
|
email_gateway=email_gateway,
|
||||||
mention_backend=mention_backend,
|
mention_backend=mention_backend,
|
||||||
|
limit_unread_user_ids=limit_unread_user_ids,
|
||||||
)
|
)
|
||||||
except JsonableError as e:
|
except JsonableError as e:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
|
@ -3555,6 +3565,7 @@ def internal_prep_stream_message(
|
||||||
topic: str,
|
topic: str,
|
||||||
content: str,
|
content: str,
|
||||||
email_gateway: bool = False,
|
email_gateway: bool = False,
|
||||||
|
limit_unread_user_ids: Optional[Set[int]] = None,
|
||||||
) -> Optional[SendMessageRequest]:
|
) -> Optional[SendMessageRequest]:
|
||||||
"""
|
"""
|
||||||
See _internal_prep_message for details of how this works.
|
See _internal_prep_message for details of how this works.
|
||||||
|
@ -3568,6 +3579,7 @@ def internal_prep_stream_message(
|
||||||
addressee=addressee,
|
addressee=addressee,
|
||||||
content=content,
|
content=content,
|
||||||
email_gateway=email_gateway,
|
email_gateway=email_gateway,
|
||||||
|
limit_unread_user_ids=limit_unread_user_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -3629,9 +3641,12 @@ def internal_send_stream_message(
|
||||||
topic: str,
|
topic: str,
|
||||||
content: str,
|
content: str,
|
||||||
email_gateway: bool = False,
|
email_gateway: bool = False,
|
||||||
|
limit_unread_user_ids: Optional[Set[int]] = None,
|
||||||
) -> Optional[int]:
|
) -> 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:
|
if message is None:
|
||||||
return None
|
return None
|
||||||
|
@ -6403,6 +6418,7 @@ def maybe_send_resolve_topic_notifications(
|
||||||
stream: Stream,
|
stream: Stream,
|
||||||
old_topic: str,
|
old_topic: str,
|
||||||
new_topic: str,
|
new_topic: str,
|
||||||
|
changed_messages: List[Message],
|
||||||
) -> None:
|
) -> None:
|
||||||
# Note that topics will have already been stripped in check_update_message.
|
# 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.
|
# not a bug with the "resolve topics" feature.
|
||||||
return
|
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)
|
sender = get_system_bot(settings.NOTIFICATION_BOT, user_profile.realm_id)
|
||||||
user_mention = silent_mention_syntax_for_user(user_profile)
|
user_mention = silent_mention_syntax_for_user(user_profile)
|
||||||
with override_language(stream.realm.default_language):
|
with override_language(stream.realm.default_language):
|
||||||
|
@ -6449,6 +6473,7 @@ def maybe_send_resolve_topic_notifications(
|
||||||
notification_string.format(
|
notification_string.format(
|
||||||
user=user_mention,
|
user=user_mention,
|
||||||
),
|
),
|
||||||
|
limit_unread_user_ids=affected_participant_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -7023,6 +7048,7 @@ def do_update_message(
|
||||||
stream=stream_being_edited,
|
stream=stream_being_edited,
|
||||||
old_topic=orig_topic_name,
|
old_topic=orig_topic_name,
|
||||||
new_topic=topic_name,
|
new_topic=topic_name,
|
||||||
|
changed_messages=changed_messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
return len(changed_messages)
|
return len(changed_messages)
|
||||||
|
|
|
@ -126,6 +126,7 @@ class SendMessageRequest:
|
||||||
submessages: List[Dict[str, Any]] = field(default_factory=list)
|
submessages: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
deliver_at: Optional[datetime.datetime] = None
|
deliver_at: Optional[datetime.datetime] = None
|
||||||
delivery_type: Optional[str] = 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
|
# We won't try to fetch more unread message IDs from the database than
|
||||||
|
|
|
@ -9,6 +9,7 @@ from django.http import HttpResponse
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
|
|
||||||
from zerver.lib.actions import (
|
from zerver.lib.actions import (
|
||||||
|
do_add_reaction,
|
||||||
do_change_realm_plan_type,
|
do_change_realm_plan_type,
|
||||||
do_change_stream_post_policy,
|
do_change_stream_post_policy,
|
||||||
do_change_user_role,
|
do_change_user_role,
|
||||||
|
@ -2165,6 +2166,8 @@ class EditMessageTest(EditMessageTestCase):
|
||||||
self.login("iago")
|
self.login("iago")
|
||||||
admin_user = self.example_user("iago")
|
admin_user = self.example_user("iago")
|
||||||
hamlet = self.example_user("hamlet")
|
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
|
# Set the user's translation language to German to test that
|
||||||
# it is overridden by the realm's default language.
|
# it is overridden by the realm's default language.
|
||||||
|
@ -2173,11 +2176,16 @@ class EditMessageTest(EditMessageTestCase):
|
||||||
stream = self.make_stream("new")
|
stream = self.make_stream("new")
|
||||||
self.subscribe(admin_user, stream.name)
|
self.subscribe(admin_user, stream.name)
|
||||||
self.subscribe(hamlet, stream.name)
|
self.subscribe(hamlet, stream.name)
|
||||||
|
self.subscribe(cordelia, stream.name)
|
||||||
|
self.subscribe(aaron, stream.name)
|
||||||
|
|
||||||
original_topic = "topic 1"
|
original_topic = "topic 1"
|
||||||
id1 = self.send_stream_message(hamlet, "new", topic_name=original_topic)
|
id1 = self.send_stream_message(hamlet, "new", topic_name=original_topic)
|
||||||
id2 = self.send_stream_message(admin_user, "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"
|
# Check that we don't incorrectly send "unresolve topic"
|
||||||
# notifications when asking the preserve the current topic.
|
# notifications when asking the preserve the current topic.
|
||||||
result = self.client_patch(
|
result = self.client_patch(
|
||||||
|
@ -2216,6 +2224,23 @@ class EditMessageTest(EditMessageTestCase):
|
||||||
f"@_**Iago|{admin_user.id}** has marked this topic as resolved.",
|
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
|
# Now move to a weird state and confirm no new messages
|
||||||
weird_topic = "✔ ✔✔" + original_topic
|
weird_topic = "✔ ✔✔" + original_topic
|
||||||
result = self.client_patch(
|
result = self.client_patch(
|
||||||
|
@ -2267,6 +2292,23 @@ class EditMessageTest(EditMessageTestCase):
|
||||||
f"@_**Iago|{admin_user.id}** has marked this topic as unresolved.",
|
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):
|
class DeleteMessageTest(ZulipTestCase):
|
||||||
def test_delete_message_invalid_request_format(self) -> None:
|
def test_delete_message_invalid_request_format(self) -> None:
|
||||||
|
|
Loading…
Reference in New Issue