diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 1edfeed023..501f836936 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,15 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 8.0 +**Feature level 214** + +* [`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults), + [`POST /register`](/api/register-queue), [`PATCH /settings`](/api/update-settings): + Added two new user settings, `automatically_follow_topics_policy` and + `automatically_unmute_topics_in_muted_streams_policy`. The settings control the + user's preference on which topics the user will automatically 'follow' and + 'unmute in muted streams' respectively. + **Feature level 213** * [`POST /register`](/api/register-queue): Fixed incorrect handling of diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py index c0d5e5e9d0..466ae52592 100644 --- a/tools/linter_lib/custom_check.py +++ b/tools/linter_lib/custom_check.py @@ -230,6 +230,9 @@ python_rules = RuleList( "good_lines": ["topic_name"], "bad_lines": ['subject="foo"', " MAX_SUBJECT_LEN"], "exclude": FILES_WITH_LEGACY_SUBJECT, + "exclude_line": { + ("zerver/lib/message.py", "message__subject__iexact=message.topic_name(),"), + }, "include_only": { "zerver/data_import/", "zerver/lib/", diff --git a/version.py b/version.py index 5bfed5f098..94ed6284ed 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # Changes should be accompanied by documentation explaining what the # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 213 +API_FEATURE_LEVEL = 214 # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/zerver/actions/message_send.py b/zerver/actions/message_send.py index 8ce1bb058c..d6f5663729 100644 --- a/zerver/actions/message_send.py +++ b/zerver/actions/message_send.py @@ -30,6 +30,7 @@ from django.utils.translation import override as override_language from django_stubs_ext import ValuesQuerySet from zerver.actions.uploads import do_claim_attachments +from zerver.actions.user_topics import do_set_user_topic_visibility_policy from zerver.lib.addressee import Addressee from zerver.lib.alert_words import get_alert_word_automaton from zerver.lib.cache import cache_with_key, user_profile_delivery_email_cache_key @@ -50,7 +51,9 @@ from zerver.lib.message import ( check_user_group_mention_allowed, normalize_body, render_markdown, + set_visibility_policy_possible, truncate_topic, + visibility_policy_for_send_message, wildcard_mention_allowed, ) from zerver.lib.muted_users import get_muting_users @@ -180,6 +183,7 @@ class RecipientInfoResult: service_bot_tuples: List[Tuple[int, int]] all_bot_user_ids: Set[int] topic_participant_user_ids: Set[int] + sender_muted_stream: Optional[bool] class ActiveUserDict(TypedDict): @@ -212,6 +216,7 @@ def get_recipient_info( stream_wildcard_mention_in_followed_topic_user_ids: Set[int] = set() muted_sender_user_ids: Set[int] = get_muting_users(sender_id) topic_participant_user_ids: Set[int] = set() + sender_muted_stream: Optional[bool] = None if recipient.type == Recipient.PERSONAL: # The sender and recipient may be the same id, so @@ -275,7 +280,14 @@ def get_recipient_info( .order_by("user_profile_id") ) - message_to_user_ids = [row["user_profile_id"] for row in subscription_rows] + message_to_user_ids = list() + for row in subscription_rows: + message_to_user_ids.append(row["user_profile_id"]) + # We store the 'sender_muted_stream' information here to avoid db query at + # a later stage when we perform automatically unmute topic in muted stream operation. + if row["user_profile_id"] == sender_id: + sender_muted_stream = row["is_muted"] + user_id_to_visibility_policy = stream_topic.user_id_to_visibility_policy_dict() def notification_recipients(setting: str) -> Set[int]: @@ -466,6 +478,7 @@ def get_recipient_info( service_bot_tuples=service_bot_tuples, all_bot_user_ids=all_bot_user_ids, topic_participant_user_ids=topic_participant_user_ids, + sender_muted_stream=sender_muted_stream, ) @@ -642,6 +655,7 @@ def build_message_send_dict( message_send_dict = SendMessageRequest( stream=stream, + sender_muted_stream=info.sender_muted_stream, local_id=local_id, sender_queue_id=sender_queue_id, realm=realm, @@ -896,6 +910,8 @@ def do_send_messages( # This next loop is responsible for notifying other parts of the # Zulip system about the messages we just committed to the database: + # * Sender automatically follows or unmutes the topic depending on 'automatically_follow_topics_policy' + # and 'automatically_unmute_topics_in_muted_streams_policy' user settings. # * Notifying clients via send_event # * Triggering outgoing webhooks via the service event queue. # * Updating the `first_message_id` field for streams without any message history. @@ -910,6 +926,39 @@ def do_send_messages( # assert needed because stubs for django are missing assert send_request.stream is not None realm_id = send_request.stream.realm_id + sender = send_request.message.sender + + # Determine and set the visibility_policy depending on 'automatically_follow_topics_policy' + # and 'automatically_unmute_topics_in_muted_streams_policy'. + if set_visibility_policy_possible(sender, send_request.message) and not ( + sender.automatically_follow_topics_policy + == sender.automatically_unmute_topics_in_muted_streams_policy + == UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER + ): + try: + user_topic = UserTopic.objects.get( + user_profile=sender, + stream_id=send_request.stream.id, + topic_name__iexact=send_request.message.topic_name(), + ) + visibility_policy = user_topic.visibility_policy + except UserTopic.DoesNotExist: + visibility_policy = UserTopic.VisibilityPolicy.INHERIT + + new_visibility_policy = visibility_policy_for_send_message( + sender, + send_request.message, + send_request.stream, + send_request.sender_muted_stream, + visibility_policy, + ) + if new_visibility_policy: + do_set_user_topic_visibility_policy( + user_profile=sender, + stream=send_request.stream, + topic=send_request.message.topic_name(), + visibility_policy=new_visibility_policy, + ) # Deliver events to the real-time push system, as well as # enqueuing any additional processing triggered by the message. diff --git a/zerver/actions/reactions.py b/zerver/actions/reactions.py index 99a71da081..6d5331c945 100644 --- a/zerver/actions/reactions.py +++ b/zerver/actions/reactions.py @@ -1,10 +1,18 @@ from typing import Any, Dict, Optional from zerver.actions.create_user import create_historical_user_messages +from zerver.actions.user_topics import do_set_user_topic_visibility_policy from zerver.lib.emoji import check_emoji_request, get_emoji_data from zerver.lib.exceptions import ReactionExistsError -from zerver.lib.message import access_message, update_to_dict_cache +from zerver.lib.message import ( + access_message, + set_visibility_policy_possible, + should_change_visibility_policy, + update_to_dict_cache, + visibility_policy_for_participation, +) from zerver.lib.stream_subscription import subscriber_ids_with_stream_history_access +from zerver.lib.streams import access_stream_by_id from zerver.models import Message, Reaction, Recipient, Stream, UserMessage, UserProfile from zerver.tornado.django_api import send_event_on_commit @@ -82,6 +90,32 @@ def do_add_reaction( reaction.save() + # Determine and set the visibility_policy depending on 'automatically_follow_topics_policy' + # and 'automatically_unmute_topics_in_muted_streams_policy'. + if set_visibility_policy_possible( + user_profile, message + ) and UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION in [ + user_profile.automatically_follow_topics_policy, + user_profile.automatically_unmute_topics_in_muted_streams_policy, + ]: + stream_id = message.recipient.type_id + (stream, sub) = access_stream_by_id(user_profile, stream_id) + assert stream is not None + if sub: + new_visibility_policy = visibility_policy_for_participation(user_profile, sub.is_muted) + if new_visibility_policy and should_change_visibility_policy( + new_visibility_policy, + user_profile, + stream_id, + topic_name=message.topic_name(), + ): + do_set_user_topic_visibility_policy( + user_profile=user_profile, + stream=stream, + topic=message.topic_name(), + visibility_policy=new_visibility_policy, + ) + notify_reaction_update(user_profile, message, reaction, "add") diff --git a/zerver/actions/submessage.py b/zerver/actions/submessage.py index 2c03b587b9..3de331c936 100644 --- a/zerver/actions/submessage.py +++ b/zerver/actions/submessage.py @@ -1,7 +1,14 @@ from django.utils.translation import gettext as _ +from zerver.actions.user_topics import do_set_user_topic_visibility_policy from zerver.lib.exceptions import JsonableError -from zerver.models import Realm, SubMessage, UserMessage +from zerver.lib.message import ( + set_visibility_policy_possible, + should_change_visibility_policy, + visibility_policy_for_participation, +) +from zerver.lib.streams import access_stream_by_id +from zerver.models import Realm, SubMessage, UserMessage, UserProfile from zerver.tornado.django_api import send_event_on_commit @@ -48,6 +55,33 @@ def do_add_submessage( ) submessage.save() + # Determine and set the visibility_policy depending on 'automatically_follow_topics_policy' + # and 'automatically_unmute_topics_policy'. + sender = submessage.sender + if set_visibility_policy_possible( + sender, submessage.message + ) and UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION in [ + sender.automatically_follow_topics_policy, + sender.automatically_unmute_topics_in_muted_streams_policy, + ]: + stream_id = submessage.message.recipient.type_id + (stream, sub) = access_stream_by_id(sender, stream_id) + assert stream is not None + if sub: + new_visibility_policy = visibility_policy_for_participation(sender, sub.is_muted) + if new_visibility_policy and should_change_visibility_policy( + new_visibility_policy, + sender, + stream_id, + topic_name=submessage.message.topic_name(), + ): + do_set_user_topic_visibility_policy( + user_profile=sender, + stream=stream, + topic=submessage.message.topic_name(), + visibility_policy=new_visibility_policy, + ) + event = dict( type="submessage", msg_type=msg_type, diff --git a/zerver/lib/message.py b/zerver/lib/message.py index eff4a69027..db89fb5356 100644 --- a/zerver/lib/message.py +++ b/zerver/lib/message.py @@ -20,7 +20,7 @@ import ahocorasick import orjson from django.conf import settings from django.db import connection -from django.db.models import Max, Sum +from django.db.models import Max, QuerySet, Sum from django.utils.timezone import now as timezone_now from django.utils.translation import gettext as _ from django_stubs_ext import ValuesQuerySet @@ -47,9 +47,15 @@ from zerver.lib.stream_subscription import ( get_subscribed_stream_recipient_ids_for_user, num_subscribers_for_stream_id, ) -from zerver.lib.streams import get_web_public_streams_queryset +from zerver.lib.streams import can_access_stream_history, get_web_public_streams_queryset from zerver.lib.timestamp import datetime_to_timestamp -from zerver.lib.topic import DB_TOPIC_NAME, MESSAGE__TOPIC, TOPIC_LINKS, TOPIC_NAME +from zerver.lib.topic import ( + DB_TOPIC_NAME, + MESSAGE__TOPIC, + TOPIC_LINKS, + TOPIC_NAME, + messages_for_topic, +) from zerver.lib.types import DisplayRecipientT, EditHistoryEvent, UserDisplayRecipient from zerver.lib.url_preview.types import UrlEmbedData from zerver.lib.user_groups import is_user_in_group @@ -147,6 +153,7 @@ class SendMessageRequest: message: Message rendering_result: MessageRenderingResult stream: Optional[Stream] + sender_muted_stream: Optional[bool] local_id: Optional[str] sender_queue_id: Optional[str] realm: Realm @@ -1713,3 +1720,180 @@ def update_to_dict_cache( cache_set_many(items_for_remote_cache) return message_ids + + +def visibility_policy_for_participation( + sender: UserProfile, + is_stream_muted: Optional[bool], +) -> Optional[int]: + """ + This function determines the visibility policy to set when a user + participates in a topic, depending on the 'automatically_follow_topics_policy' + and 'automatically_unmute_topics_in_muted_streams_policy' settings. + """ + if ( + sender.automatically_follow_topics_policy + == UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION + ): + return UserTopic.VisibilityPolicy.FOLLOWED + + if ( + is_stream_muted + and sender.automatically_unmute_topics_in_muted_streams_policy + == UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION + ): + return UserTopic.VisibilityPolicy.UNMUTED + + return None + + +def visibility_policy_for_send( + sender: UserProfile, + is_stream_muted: Optional[bool], +) -> Optional[int]: + if ( + sender.automatically_follow_topics_policy + == UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND + ): + return UserTopic.VisibilityPolicy.FOLLOWED + + if ( + is_stream_muted + and sender.automatically_unmute_topics_in_muted_streams_policy + == UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND + ): + return UserTopic.VisibilityPolicy.UNMUTED + + return None + + +def visibility_policy_for_send_message( + sender: UserProfile, + message: Message, + stream: Stream, + is_stream_muted: Optional[bool], + current_visibility_policy: int, +) -> Optional[int]: + """ + This function determines the visibility policy to set when a message + is sent to a topic, depending on the 'automatically_follow_topics_policy' + and 'automatically_unmute_topics_in_muted_streams_policy' settings. + + It returns None when the policies can't make it more visible than the + current visibility policy. + """ + # We prioritize 'FOLLOW' over 'UNMUTE' in muted streams. + # We need to carefully handle the following two cases: + # + # 1. When an action qualifies for multiple values. Example: + # - starting a topic is INITIATION, PARTICIPATION as well as SEND + # - sending a non-first message is PARTICIPATION as well as SEND + # action | 'automatically_follow_topics_policy' | 'automatically_unmute_topics_in_muted_streams_policy' | visibility_policy + # start | ON_PARTICIPATION / ON_SEND | ON_INITIATION | FOLLOWED + # send | ON_SEND / ON_PARTICIPATION | ON_PARTICIPATION / ON_SEND | FOLLOWED + # + # 2. When both the policies have the same values. + # action | 'automatically_follow_topics_policy' | 'automatically_unmute_topics_in_muted_streams_policy' | visibility_policy + # start | ON_INITIATION | ON_INITIATION | FOLLOWED + # partc | ON_PARTICIPATION | ON_PARTICIPATION | FOLLOWED + # send | ON_SEND | ON_SEND | FOLLOWED + visibility_policy = None + + if current_visibility_policy == UserTopic.VisibilityPolicy.FOLLOWED: + return visibility_policy + + visibility_policy_participation = visibility_policy_for_participation(sender, is_stream_muted) + visibility_policy_send = visibility_policy_for_send(sender, is_stream_muted) + + if UserTopic.VisibilityPolicy.FOLLOWED in ( + visibility_policy_participation, + visibility_policy_send, + ): + return UserTopic.VisibilityPolicy.FOLLOWED + + if UserTopic.VisibilityPolicy.UNMUTED in ( + visibility_policy_participation, + visibility_policy_send, + ): + visibility_policy = UserTopic.VisibilityPolicy.UNMUTED + + # If a topic has a visibility policy set, it can't be the case + # of initiation. We return early, thus saving a DB query. + if current_visibility_policy != UserTopic.VisibilityPolicy.INHERIT: + if visibility_policy and current_visibility_policy == visibility_policy: + return None + return visibility_policy + + # Now we need to check if the user initiated the topic. + old_accessible_messages_in_topic: Union[QuerySet[Message], QuerySet[UserMessage]] + if can_access_stream_history(sender, stream): + old_accessible_messages_in_topic = messages_for_topic( + realm_id=sender.realm_id, + stream_recipient_id=message.recipient_id, + topic_name=message.topic_name(), + ).exclude(id=message.id) + else: + # We use the user's own message access to avoid leaking information in + # private streams with protected history. + old_accessible_messages_in_topic = UserMessage.objects.filter( + user_profile=sender, + message__recipient_id=message.recipient_id, + message__subject__iexact=message.topic_name(), + ).exclude(message_id=message.id) + + if ( + sender.automatically_follow_topics_policy + == UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION + and not old_accessible_messages_in_topic.exists() + ): + return UserTopic.VisibilityPolicy.FOLLOWED + + if ( + is_stream_muted + and sender.automatically_unmute_topics_in_muted_streams_policy + == UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION + and not old_accessible_messages_in_topic.exists() + ): + visibility_policy = UserTopic.VisibilityPolicy.UNMUTED + + return visibility_policy + + +def should_change_visibility_policy( + new_visibility_policy: int, + sender: UserProfile, + stream_id: int, + topic_name: str, +) -> bool: + try: + user_topic = UserTopic.objects.get( + user_profile=sender, stream_id=stream_id, topic_name__iexact=topic_name + ) + except UserTopic.DoesNotExist: + return True + current_visibility_policy = user_topic.visibility_policy + + if new_visibility_policy == current_visibility_policy: + return False + + # The intent of these "automatically follow or unmute" policies is that they + # can only increase the user's visibility policy for the topic. If a topic is + # already FOLLOWED, we don't change the state to UNMUTED due to these policies. + if current_visibility_policy == UserTopic.VisibilityPolicy.FOLLOWED: + return False + + return True + + +def set_visibility_policy_possible(user_profile: UserProfile, message: Message) -> bool: + """If the user can set a visibility policy.""" + if not message.is_stream_message(): + return False + + if user_profile.is_bot: + return False + + if user_profile.realm != message.get_realm(): + return False + + return True diff --git a/zerver/migrations/0476_realmuserdefault_automatically_follow_topics_policy_and_more.py b/zerver/migrations/0476_realmuserdefault_automatically_follow_topics_policy_and_more.py new file mode 100644 index 0000000000..7dc6406bc5 --- /dev/null +++ b/zerver/migrations/0476_realmuserdefault_automatically_follow_topics_policy_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.5 on 2023-09-19 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0475_realm_jitsi_server_url"), + ] + + operations = [ + migrations.AddField( + model_name="realmuserdefault", + name="automatically_follow_topics_policy", + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.AddField( + model_name="realmuserdefault", + name="automatically_unmute_topics_in_muted_streams_policy", + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.AddField( + model_name="userprofile", + name="automatically_follow_topics_policy", + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.AddField( + model_name="userprofile", + name="automatically_unmute_topics_in_muted_streams_policy", + field=models.PositiveSmallIntegerField(default=3), + ), + ] diff --git a/zerver/migrations/0477_alter_realmuserdefault_automatically_follow_topics_policy_and_more.py b/zerver/migrations/0477_alter_realmuserdefault_automatically_follow_topics_policy_and_more.py new file mode 100644 index 0000000000..da37d33174 --- /dev/null +++ b/zerver/migrations/0477_alter_realmuserdefault_automatically_follow_topics_policy_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.5 on 2023-10-02 05:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0476_realmuserdefault_automatically_follow_topics_policy_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="realmuserdefault", + name="automatically_follow_topics_policy", + field=models.PositiveSmallIntegerField(default=4), + ), + migrations.AlterField( + model_name="realmuserdefault", + name="automatically_unmute_topics_in_muted_streams_policy", + field=models.PositiveSmallIntegerField(default=4), + ), + migrations.AlterField( + model_name="userprofile", + name="automatically_follow_topics_policy", + field=models.PositiveSmallIntegerField(default=4), + ), + migrations.AlterField( + model_name="userprofile", + name="automatically_unmute_topics_in_muted_streams_policy", + field=models.PositiveSmallIntegerField(default=4), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index d4aa25ac1e..10129cb14d 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -1642,6 +1642,30 @@ class UserBaseSettings(models.Model): default=REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_AUTOMATIC ) + # The following two settings control which topics to automatically + # 'follow' or 'unmute in a muted stream', respectively. + # Follow or unmute a topic automatically on: + # - PARTICIPATION: Send a message, React to a message, Participate in a poll or Edit a TO-DO list. + # - SEND: Send a message. + # - INITIATION: Send the first message in the topic. + # - NEVER: Never automatically follow or unmute a topic. + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION = 1 + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND = 2 + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION = 3 + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER = 4 + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES = [ + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND, + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + ] + automatically_follow_topics_policy = models.PositiveSmallIntegerField( + default=AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER + ) + automatically_unmute_topics_in_muted_streams_policy = models.PositiveSmallIntegerField( + default=AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER + ) + # Whether or not the user wants to sync their drafts. enable_drafts_synchronization = models.BooleanField(default=True) @@ -1737,6 +1761,8 @@ class UserBaseSettings(models.Model): enable_followed_topic_push_notifications=bool, enable_followed_topic_audible_notifications=bool, enable_followed_topic_wildcard_mentions_notify=bool, + automatically_follow_topics_policy=int, + automatically_unmute_topics_in_muted_streams_policy=int, ) notification_setting_types = { diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 313629ef34..954f5081a4 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -10206,6 +10206,44 @@ paths: - 2 - 3 example: 1 + - name: automatically_follow_topics_policy + in: query + description: | + Which [topics to follow automatically](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). + schema: + type: integer + enum: + - 1 + - 2 + - 3 + - 4 + example: 1 + - name: automatically_unmute_topics_in_muted_streams_policy + in: query + description: | + Which [topics to unmute automatically in muted streams](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). + schema: + type: integer + enum: + - 1 + - 2 + - 3 + - 4 + example: 1 - name: presence_enabled in: query description: | @@ -12422,6 +12460,28 @@ paths: **Changes**: New in Zulip 7.0 (feature level 168), replacing the previous `realm_name_in_notifications` boolean; `true` corresponded to `Always`, and `false` to `Never`. + automatically_follow_topics_policy: + type: integer + description: | + Which [topics to follow automatically](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). + automatically_unmute_topics_in_muted_streams_policy: + type: integer + description: | + Which [topics to unmute automatically in muted streams](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). presence_enabled: type: boolean description: | @@ -14561,6 +14621,28 @@ paths: **Changes**: New in Zulip 7.0 (feature level 168), replacing the previous `realm_name_in_notifications` boolean; `true` corresponded to `Always`, and `false` to `Never`. + automatically_follow_topics_policy: + type: integer + description: | + Which [topics to follow automatically](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). + automatically_unmute_topics_in_muted_streams_policy: + type: integer + description: | + Which [topics to unmute automatically in muted streams](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). presence_enabled: type: boolean description: | @@ -15808,6 +15890,44 @@ paths: - 2 - 3 example: 1 + - name: automatically_follow_topics_policy + in: query + description: | + Which [topics to follow automatically](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). + schema: + type: integer + enum: + - 1 + - 2 + - 3 + - 4 + example: 1 + - name: automatically_unmute_topics_in_muted_streams_policy + in: query + description: | + Which [topics to unmute automatically in muted streams](/help/mute-a-topic). + + - 1 - Topics the user participates in + - 2 - Topics the user sends a message to + - 3 - Topics the user starts + - 4 - Never + + **Changes**: New in Zulip 8.0 (feature level 214). + schema: + type: integer + enum: + - 1 + - 2 + - 3 + - 4 + example: 1 - name: presence_enabled in: query description: | diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 6ba310c25a..3f76c20016 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -535,11 +535,195 @@ class NormalActionsTest(BaseAction): ) def test_stream_send_message_events(self) -> None: - user_profile = self.example_user("hamlet") - events = self.verify_action( - lambda: self.send_stream_message(user_profile, "Verona", "hello"), - client_gravatar=False, + hamlet = self.example_user("hamlet") + for stream_name in ["Verona", "Denmark", "core team"]: + stream = get_stream(stream_name, hamlet.realm) + sub = get_subscription(stream.name, hamlet) + do_change_subscription_property(hamlet, sub, stream, "is_muted", True, acting_user=None) + + def verify_events_generated_and_reset_visibility_policy( + events: List[Dict[str, Any]], stream_name: str, topic: str + ) -> None: + # event-type: muted_topics + check_muted_topics("events[0]", events[0]) + # event-type: user_topic + check_user_topic("events[1]", events[1]) + + if events[2]["type"] == "message": + check_message("events[2]", events[2]) + else: + # event-type: reaction + check_reaction_add("events[2]", events[2]) + + # Reset visibility policy + do_set_user_topic_visibility_policy( + hamlet, + get_stream(stream_name, hamlet.realm), + topic, + visibility_policy=UserTopic.VisibilityPolicy.INHERIT, + ) + + # Events generated during send message action depends on the 'automatically_follow_topics_policy' + # and 'automatically_unmute_topics_in_muted_streams_policy' settings. Here we test all the + # possible combinations. + + # action: participation + # 'automatically_follow_topics_policy' | 'automatically_unmute_topics_in_muted_streams_policy' | visibility_policy + # ON_PARTICIPATION | ON_INITIATION | FOLLOWED + # ON_PARTICIPATION | ON_PARTICIPATION | FOLLOWED + # ON_PARTICIPATION | ON_SEND | FOLLOWED + # ON_PARTICIPATION | NEVER | FOLLOWED + message_id = self.send_stream_message(hamlet, "Verona", "hello", "topic") + message = Message.objects.get(id=message_id) + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_follow_topics_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, ) + for setting_value in UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES: + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_unmute_topics_in_muted_streams_policy", + setting_value=setting_value, + acting_user=None, + ) + # Three events are generated: + # 2 for following the topic and 1 for adding reaction. + events = self.verify_action( + lambda: do_add_reaction(hamlet, message, "tada", "1f389", "unicode_emoji"), + client_gravatar=False, + num_events=3, + ) + verify_events_generated_and_reset_visibility_policy(events, "Verona", "topic") + do_remove_reaction(hamlet, message, "1f389", "unicode_emoji") + + # action: send + # 'automatically_follow_topics_policy' | 'automatically_unmute_topics_in_muted_streams_policy' | visibility_policy + # ON_SEND | ON_INITIATION | FOLLOWED + # ON_SEND | ON_PARTICIPATION | FOLLOWED + # ON_SEND | ON_SEND | FOLLOWED + # ON_SEND | NEVER | FOLLOWED + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_follow_topics_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND, + acting_user=None, + ) + for setting_value in UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES: + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_unmute_topics_in_muted_streams_policy", + setting_value=setting_value, + acting_user=None, + ) + # Three events are generated: + # 2 for following the topic and 1 for the message sent. + events = self.verify_action( + lambda: self.send_stream_message(hamlet, "Verona", "hello", "topic"), + client_gravatar=False, + num_events=3, + ) + verify_events_generated_and_reset_visibility_policy(events, "Verona", "topic") + + # action: initiation + # 'automatically_follow_topics_policy' | 'automatically_unmute_topics_in_muted_streams_policy' | visibility_policy + # ON_INITIATION | ON_INITIATION | FOLLOWED + # ON_INITIATION | ON_PARTICIPATION | FOLLOWED + # ON_INITIATION | ON_SEND | FOLLOWED + # ON_INITIATION | NEVER | FOLLOWED + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_follow_topics_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + for index, setting_value in enumerate( + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES + ): + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_unmute_topics_in_muted_streams_policy", + setting_value=setting_value, + acting_user=None, + ) + # Three events are generated: + # 2 for following the topic and 1 for the message sent. + send_message = lambda index=index: self.send_stream_message( + hamlet, "Denmark", "hello", f"new topic {index}" + ) + events = self.verify_action( + send_message, + client_gravatar=False, + num_events=3, + ) + verify_events_generated_and_reset_visibility_policy( + events, "Denmark", f"new topic {index}" + ) + + # 'automatically_follow_topics_policy' | 'automatically_unmute_topics_in_muted_streams_policy' | visibility_policy + # NEVER | ON_INITIATION | UNMUTED + # NEVER | ON_PARTICIPATION | UNMUTED + # NEVER | ON_SEND | UNMUTED + # NEVER | NEVER | NA + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_follow_topics_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) + for setting_value in [ + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND, + ]: + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_unmute_topics_in_muted_streams_policy", + setting_value=setting_value, + acting_user=None, + ) + # Three events are generated: + # 2 for unmuting the topic and 1 for the message sent. + events = self.verify_action( + lambda: self.send_stream_message(hamlet, "core team", "hello", "topic"), + client_gravatar=False, + num_events=3, + ) + verify_events_generated_and_reset_visibility_policy(events, "core team", "topic") + + # If current_visibility_policy is already set to the value the policies would set. + do_set_user_topic_visibility_policy( + hamlet, + get_stream("core team", hamlet.realm), + "new Topic", + visibility_policy=UserTopic.VisibilityPolicy.UNMUTED, + ) + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_unmute_topics_in_muted_streams_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # 1 event for the message sent + events = self.verify_action( + lambda: self.send_stream_message(hamlet, "core team", "hello", "new Topic"), + client_gravatar=False, + num_events=1, + ) + + do_change_user_setting( + user_profile=hamlet, + setting_name="automatically_unmute_topics_in_muted_streams_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) + # Only one message event is generated + events = self.verify_action( + lambda: self.send_stream_message(hamlet, "core team", "hello"), + client_gravatar=True, + ) + # event-type: message check_message("events[0]", events[0]) assert isinstance(events[0]["message"]["avatar_url"], str) @@ -551,7 +735,7 @@ class NormalActionsTest(BaseAction): ) events = self.verify_action( - lambda: self.send_stream_message(user_profile, "Verona", "hello"), + lambda: self.send_stream_message(hamlet, "core team", "hello"), client_gravatar=True, ) check_message("events[0]", events[0]) @@ -560,13 +744,9 @@ class NormalActionsTest(BaseAction): # Here we add coverage for the case where 'apply_unread_message_event' # should be called and unread messages in unmuted or followed topic in # muted stream is treated as unmuted stream message, thus added to 'unmuted_stream_msgs'. - stream = get_stream("Verona", user_profile.realm) - sub = get_subscription(stream.name, user_profile) - do_change_subscription_property( - user_profile, sub, stream, "is_muted", True, acting_user=None - ) + stream = get_stream("Verona", hamlet.realm) do_set_user_topic_visibility_policy( - user_profile, + hamlet, stream, "test", visibility_policy=UserTopic.VisibilityPolicy.UNMUTED, @@ -2063,6 +2243,8 @@ class NormalActionsTest(BaseAction): "desktop_icon_count_display", "presence_enabled", "realm_name_in_email_notifications_policy", + "automatically_follow_topics_policy", + "automatically_unmute_topics_in_muted_streams_policy", ]: # These settings are tested in their own tests. continue @@ -2201,6 +2383,38 @@ class NormalActionsTest(BaseAction): check_user_settings_update("events[0]", events[0]) check_update_global_notifications("events[1]", events[1], 2) + def test_change_automatically_follow_topics_policy(self) -> None: + notification_setting = "automatically_follow_topics_policy" + + for setting_value in UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES: + events = self.verify_action( + partial( + do_change_user_setting, + self.user_profile, + notification_setting, + setting_value, + acting_user=self.user_profile, + ), + num_events=1, + ) + check_user_settings_update("events[0]", events[0]) + + def test_change_automatically_unmute_topics_in_muted_streams_policy(self) -> None: + notification_setting = "automatically_unmute_topics_in_muted_streams_policy" + + for setting_value in UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES: + events = self.verify_action( + partial( + do_change_user_setting, + self.user_profile, + notification_setting, + setting_value, + acting_user=self.user_profile, + ), + num_events=1, + ) + check_user_settings_update("events[0]", events[0]) + def test_realm_update_org_type(self) -> None: realm = self.user_profile.realm @@ -3129,6 +3343,8 @@ class RealmPropertyActionTest(BaseAction): email_notifications_batching_period_seconds=[120, 300], email_address_visibility=UserProfile.EMAIL_ADDRESS_VISIBILITY_TYPES, realm_name_in_email_notifications_policy=UserProfile.REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_CHOICES, + automatically_follow_topics_policy=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES, + automatically_unmute_topics_in_muted_streams_policy=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES, ) vals = test_values.get(name) diff --git a/zerver/tests/test_message_send.py b/zerver/tests/test_message_send.py index 32d315e5df..e046d9d108 100644 --- a/zerver/tests/test_message_send.py +++ b/zerver/tests/test_message_send.py @@ -1454,12 +1454,24 @@ class StreamMessagesTest(ZulipTestCase): topic_name = "foo" content = "whatever" + # Note: We don't need to assert the db query count for each possible + # combination of 'automatically_follow_topics_policy' and 'automatically_unmute_topics_in_muted_streams_policy', + # as the query count depends only on the actions, i.e., 'ON_INITIATION', + # 'ON_PARTICIPATION', and 'NEVER', and is independent of the final visibility_policy set. + # Asserting query count using one of the above-mentioned settings fulfils our purpose. + # To get accurate count of the queries, we should make sure that # caches don't come into play. If we count queries while caches are # filled, we will get a lower count. Caches are not supposed to be # persistent, so our test can also fail if cache is invalidated # during the course of the unit test. flush_per_request_caches() + do_change_user_setting( + user_profile=sender, + setting_name="automatically_follow_topics_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) with self.assert_database_query_count(13): check_send_stream_message( sender=sender, @@ -1469,6 +1481,57 @@ class StreamMessagesTest(ZulipTestCase): body=content, ) + do_change_user_setting( + user_profile=sender, + setting_name="automatically_follow_topics_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + # There will be an increase in the query count of 5 while sending + # the first message to a topic. + # 5 queries: 1 to check if it is the first message in the topic + + # 1 to check if the topic is already followed + 3 to follow the topic. + flush_per_request_caches() + with self.assert_database_query_count(18): + check_send_stream_message( + sender=sender, + client=sending_client, + stream_name=stream_name, + topic="new topic", + body=content, + ) + + do_change_user_setting( + user_profile=sender, + setting_name="automatically_follow_topics_policy", + setting_value=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + self.send_stream_message(self.example_user("iago"), stream_name, "Hello", "topic 2") + # There will be an increase in the query count of 4 while sending + # a message to a topic with visibility policy other than FOLLOWED. + # 1 to check if the topic is already followed + 3 queries to follow the topic. + flush_per_request_caches() + with self.assert_database_query_count(17): + check_send_stream_message( + sender=sender, + client=sending_client, + stream_name=stream_name, + topic="topic 2", + body=content, + ) + # If the topic is already FOLLOWED, there will be an increase in the query + # count of 1 to check if the topic is already followed. + flush_per_request_caches() + with self.assert_database_query_count(14): + check_send_stream_message( + sender=sender, + client=sending_client, + stream_name=stream_name, + topic="topic 2", + body=content, + ) + def test_stream_message_dict(self) -> None: user_profile = self.example_user("iago") self.subscribe(user_profile, "Denmark") diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 417c8fdb0f..3bcb811baf 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -1345,6 +1345,8 @@ class RealmAPITest(ZulipTestCase): email_notifications_batching_period_seconds=[120, 300], email_address_visibility=UserProfile.EMAIL_ADDRESS_VISIBILITY_TYPES, realm_name_in_email_notifications_policy=UserProfile.REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_CHOICES, + automatically_follow_topics_policy=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES, + automatically_unmute_topics_in_muted_streams_policy=UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES, ) vals = test_values.get(name) diff --git a/zerver/tests/test_settings.py b/zerver/tests/test_settings.py index 007b28e1fd..bbe1f41641 100644 --- a/zerver/tests/test_settings.py +++ b/zerver/tests/test_settings.py @@ -362,6 +362,8 @@ class ChangeSettingsTest(ZulipTestCase): desktop_icon_count_display=2, email_address_visibility=3, realm_name_in_email_notifications_policy=2, + automatically_follow_topics_policy=1, + automatically_unmute_topics_in_muted_streams_policy=1, ) self.login("hamlet") diff --git a/zerver/tests/test_user_topics.py b/zerver/tests/test_user_topics.py index b98aeb9b42..f0f3a09760 100644 --- a/zerver/tests/test_user_topics.py +++ b/zerver/tests/test_user_topics.py @@ -1,12 +1,16 @@ from datetime import datetime, timezone from typing import Any, Dict, List +import orjson import time_machine from django.utils.timezone import now as timezone_now +from zerver.actions.reactions import check_add_reaction +from zerver.actions.user_settings import do_change_user_setting from zerver.actions.user_topics import do_set_user_topic_visibility_policy from zerver.lib.stream_topic import StreamTopicTarget from zerver.lib.test_classes import ZulipTestCase +from zerver.lib.test_helpers import get_subscription from zerver.lib.user_topics import get_topic_mutes, topic_has_visibility_policy from zerver.models import UserProfile, UserTopic, get_stream @@ -638,3 +642,895 @@ class UnmutedTopicsTests(ZulipTestCase): result = self.api_post(user, url, data) self.assert_json_error(result, "Invalid stream ID") + + +class AutomaticallyFollowTopicsTests(ZulipTestCase): + def test_automatically_follow_topic_on_initiation(self) -> None: + hamlet = self.example_user("hamlet") + cordelia = self.example_user("cordelia") + iago = self.example_user("iago") + stream = get_stream("Verona", hamlet.realm) + topic_name = "teST topic" + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, set()) + + # For hamlet & cordelia, + # 'automatically_follow_topics_policy' set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION'. + for user in [hamlet, cordelia]: + do_change_user_setting( + user, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + # Hamlet starts a topic. DO automatically follow the topic. + self.send_stream_message(hamlet, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {hamlet.id}) + + # Cordelia sends a message to the topic which hamlet started. DON'T automatically follow the topic. + self.send_stream_message(cordelia, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {hamlet.id}) + + # Iago has 'automatically_follow_topics_policy' set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER'. + # DON'T automatically follow the topic, even if he starts the topic. + do_change_user_setting( + iago, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) + self.send_stream_message(iago, stream_name=stream.name, topic_name="New Topic") + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name="New Topic", + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, set()) + + # When a user sends the first message to a topic with protected history, + # the user starts that topic from their perspective. So, the user + # should follow the topic if 'automatically_follow_topics_policy' is set + # to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION', even if the message + # is not the first message in the topic. + private_stream = self.make_stream(stream_name="private stream", invite_only=True) + self.subscribe(iago, private_stream.name) + self.send_stream_message(iago, private_stream.name) + + # Hamlet should automatically follow the topic, even if it already has messages. + self.subscribe(hamlet, private_stream.name) + self.send_stream_message(hamlet, private_stream.name) + stream_topic_target = StreamTopicTarget( + stream_id=private_stream.id, + topic_name="test", + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {hamlet.id}) + + def test_automatically_follow_topic_on_send(self) -> None: + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", hamlet.realm) + topic_name = "teST topic" + self.send_stream_message(aaron, stream.name, "hello", topic_name) + + # For hamlet, 'automatically_follow_topics_policy' set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND'. + do_change_user_setting( + hamlet, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND, + acting_user=None, + ) + # For aaron, 'automatically_follow_topics_policy' NOT set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND'. + do_change_user_setting( + aaron, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, set()) + + # Hamlet sends a message. DO automatically follow the topic. + # Aaron sends a message. DON'T automatically follow the topic. + self.send_stream_message(hamlet, stream_name=stream.name, topic_name=topic_name) + self.send_stream_message(aaron, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {hamlet.id}) + + def test_automatically_follow_topic_on_participation_send_message(self) -> None: + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", hamlet.realm) + topic_name = "teST topic" + + # For hamlet, 'automatically_follow_topics_policy' set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + hamlet, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_follow_topics_policy' NOT set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, set()) + + # Hamlet sends a message. DO automatically follow the topic. + # Aaron sends a message. DON'T automatically follow the topic. + self.send_stream_message(hamlet, stream_name=stream.name, topic_name=topic_name) + self.send_stream_message(aaron, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {hamlet.id}) + + def test_automatically_follow_topic_on_participation_add_reaction(self) -> None: + cordelia = self.example_user("cordelia") + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + + # For cordelia, 'automatically_follow_topics_policy' set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + cordelia, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_follow_topics_policy' NOT set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, set()) + + message_id = self.send_stream_message( + hamlet, stream_name=stream.name, topic_name=topic_name + ) + # Cordelia reacts to a message. DO automatically follow the topic. + # Aaron reacts to a message. DON'T automatically follow the topic. + check_add_reaction( + user_profile=cordelia, + message_id=message_id, + emoji_name="smile", + emoji_code=None, + reaction_type=None, + ) + check_add_reaction( + user_profile=aaron, + message_id=message_id, + emoji_name="smile", + emoji_code=None, + reaction_type=None, + ) + + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {cordelia.id}) + + # We don't decrease visibility policy + sub = get_subscription(stream.name, cordelia) + sub.is_muted = True + sub.save() + do_change_user_setting( + cordelia, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) + do_change_user_setting( + cordelia, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + check_add_reaction( + user_profile=cordelia, + message_id=message_id, + emoji_name="plus", + emoji_code=None, + reaction_type=None, + ) + + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {cordelia.id}) + + # increase visibility policy + do_set_user_topic_visibility_policy( + cordelia, + stream, + topic_name, + visibility_policy=UserTopic.VisibilityPolicy.MUTED, + ) + check_add_reaction( + user_profile=cordelia, + message_id=message_id, + emoji_name="heart", + emoji_code=None, + reaction_type=None, + ) + + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {cordelia.id}) + + # Add test coverage for 'should_change_visibility_policy' when + # new_visibility_policy == current_visibility_policy + check_add_reaction( + user_profile=cordelia, + message_id=message_id, + emoji_name="tada", + emoji_code=None, + reaction_type=None, + ) + + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {cordelia.id}) + + def test_automatically_follow_topic_on_participation_participate_in_poll(self) -> None: + iago = self.example_user("iago") + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + + # For iago, 'automatically_follow_topics_policy' set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + iago, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_follow_topics_policy' NOT set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, set()) + + # Hamlet creates a poll. + payload = dict( + type="stream", + to=orjson.dumps(stream.name).decode(), + topic=topic_name, + content="/poll Preference?\n\nyes\nno", + ) + result = self.api_post(hamlet, "/api/v1/messages", payload) + self.assert_json_success(result) + + # Iago participates in the poll. DO automatically follow the topic. + # Aaron participates in the poll. DON'T automatically follow the topic. + message = self.get_last_message() + + def participate_in_poll(user: UserProfile, data: Dict[str, object]) -> None: + content = orjson.dumps(data).decode() + payload = dict( + message_id=message.id, + msg_type="widget", + content=content, + ) + result = self.api_post(user, "/api/v1/submessage", payload) + self.assert_json_success(result) + + participate_in_poll(iago, dict(type="vote", key="1,1", vote=1)) + participate_in_poll(aaron, dict(type="new_option", idx=7, option="maybe")) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {iago.id}) + + def test_automatically_follow_topic_on_participation_edit_todo_list(self) -> None: + othello = self.example_user("othello") + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + + # For othello, 'automatically_follow_topics_policy' set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + othello, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_follow_topics_policy' NOT set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, set()) + + # Hamlet creates a todo list. + payload = dict( + type="stream", + to=orjson.dumps(stream.name).decode(), + topic=topic_name, + content="/todo", + ) + result = self.api_post(hamlet, "/api/v1/messages", payload) + self.assert_json_success(result) + + # Othello edits the todo list. DO automatically follow the topic. + # Aaron edits the todo list. DON'T automatically follow the topic. + message = self.get_last_message() + + def edit_todo_list(user: UserProfile, data: Dict[str, object]) -> None: + content = orjson.dumps(data).decode() + payload = dict( + message_id=message.id, + msg_type="widget", + content=content, + ) + result = self.api_post(user, "/api/v1/submessage", payload) + self.assert_json_success(result) + + edit_todo_list(othello, dict(type="new_task", key=7, task="eat", desc="", completed=False)) + edit_todo_list(aaron, dict(type="strike", key="5,9")) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.FOLLOWED + ) + self.assertEqual(user_ids, {othello.id}) + + +class AutomaticallyUnmuteTopicsTests(ZulipTestCase): + def test_automatically_unmute_topic_on_initiation(self) -> None: + hamlet = self.example_user("hamlet") + cordelia = self.example_user("cordelia") + iago = self.example_user("iago") + stream = get_stream("Verona", hamlet.realm) + topic_name = "teST topic" + for user in [hamlet, cordelia, iago]: + sub = get_subscription(stream.name, user) + sub.is_muted = True + sub.save() + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + # For hamlet & cordelia, 'automatically_unmute_topics_in_muted_streams_policy' + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION'. + for user in [hamlet, cordelia]: + do_change_user_setting( + user, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + # Hamlet starts a topic. DO automatically unmute the topic. + self.send_stream_message(hamlet, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {hamlet.id}) + + # Cordelia sends a message to the topic which hamlet started. DON'T automatically unmute the topic. + self.send_stream_message(cordelia, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {hamlet.id}) + + # Iago has 'automatically_unmute_topics_in_muted_streams_policy' set to + # 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER'. + # DON'T automatically unmute the topic, even if he starts the topic. + do_change_user_setting( + iago, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) + self.send_stream_message(iago, stream_name=stream.name, topic_name="New Topic") + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name="New Topic", + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + # When a user sends the first message to a topic with protected history, + # the user starts that topic from their perspective. So, the user + # should unmute the topic if 'automatically_unmute_topics_in_muted_streams_policy' + # is set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION', even if + # the message is not the first message in the topic. + private_stream = self.make_stream(stream_name="private stream", invite_only=True) + self.subscribe(iago, private_stream.name) + self.send_stream_message(iago, private_stream.name) + + # Hamlet should automatically unmute the topic, even if it already has messages. + self.subscribe(hamlet, private_stream.name) + sub = get_subscription(private_stream.name, hamlet) + sub.is_muted = True + sub.save() + self.send_stream_message(hamlet, private_stream.name) + stream_topic_target = StreamTopicTarget( + stream_id=private_stream.id, + topic_name="test", + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {hamlet.id}) + + def test_automatically_unmute_topic_on_send(self) -> None: + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", hamlet.realm) + topic_name = "teST topic" + self.send_stream_message(aaron, stream.name, "hello", topic_name) + for user in [hamlet, aaron]: + sub = get_subscription(stream.name, user) + sub.is_muted = True + sub.save() + + # For hamlet, 'automatically_unmute_topics_in_muted_streams_policy' + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND'. + do_change_user_setting( + hamlet, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND, + acting_user=None, + ) + # For aaron, 'automatically_unmute_topics_in_muted_streams_policy' NOT + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND'. + do_change_user_setting( + aaron, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + # Hamlet sends a message. DO automatically unmute the topic. + # Aaron sends a message. DON'T automatically unmute the topic. + self.send_stream_message(hamlet, stream_name=stream.name, topic_name=topic_name) + self.send_stream_message(aaron, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {hamlet.id}) + + def test_automatically_unmute_topic_on_participation_send_message(self) -> None: + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", hamlet.realm) + topic_name = "teST topic" + for user in [hamlet, aaron]: + sub = get_subscription(stream.name, user) + sub.is_muted = True + sub.save() + + # For hamlet, 'automatically_unmute_topics_in_muted_streams_policy' + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + hamlet, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_unmute_topics_in_muted_streams_policy' NOT + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + # Hamlet sends a message. DO automatically unmute the topic. + # Aaron sends a message. DON'T automatically unmute the topic. + self.send_stream_message(hamlet, stream_name=stream.name, topic_name=topic_name) + self.send_stream_message(aaron, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {hamlet.id}) + + def test_automatically_unmute_topic_on_participation_add_reaction(self) -> None: + cordelia = self.example_user("cordelia") + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + for user in [cordelia, hamlet, aaron]: + sub = get_subscription(stream.name, user) + sub.is_muted = True + sub.save() + + # For cordelia, 'automatically_unmute_topics_in_muted_streams_policy' + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + cordelia, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_unmute_topics_in_muted_streams_policy' NOT + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + message_id = self.send_stream_message( + hamlet, stream_name=stream.name, topic_name=topic_name + ) + # Cordelia reacts to a message. DO automatically unmute the topic. + # Aaron reacts to a message. DON'T automatically unmute the topic. + check_add_reaction( + user_profile=cordelia, + message_id=message_id, + emoji_name="smile", + emoji_code=None, + reaction_type=None, + ) + check_add_reaction( + user_profile=aaron, + message_id=message_id, + emoji_name="smile", + emoji_code=None, + reaction_type=None, + ) + + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {cordelia.id}) + + def test_automatically_unmute_topic_on_participation_participate_in_poll(self) -> None: + iago = self.example_user("iago") + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + for user in [iago, hamlet, aaron]: + sub = get_subscription(stream.name, user) + sub.is_muted = True + sub.save() + + # For iago, 'automatically_unmute_topics_in_muted_streams_policy' + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + iago, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_unmute_topics_in_muted_streams_policy' NOT + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + # Hamlet creates a poll. + payload = dict( + type="stream", + to=orjson.dumps(stream.name).decode(), + topic=topic_name, + content="/poll Preference?\n\nyes\nno", + ) + result = self.api_post(hamlet, "/api/v1/messages", payload) + self.assert_json_success(result) + + # Iago participates in the poll. DO automatically unmute the topic. + # Aaron participates in the poll. DON'T automatically unmute the topic. + message = self.get_last_message() + + def participate_in_poll(user: UserProfile, data: Dict[str, object]) -> None: + content = orjson.dumps(data).decode() + payload = dict( + message_id=message.id, + msg_type="widget", + content=content, + ) + result = self.api_post(user, "/api/v1/submessage", payload) + self.assert_json_success(result) + + participate_in_poll(iago, dict(type="vote", key="1,1", vote=1)) + participate_in_poll(aaron, dict(type="new_option", idx=7, option="maybe")) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {iago.id}) + + def test_automatically_unmute_topic_on_participation_edit_todo_list(self) -> None: + othello = self.example_user("othello") + hamlet = self.example_user("hamlet") + aaron = self.example_user("aaron") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + for user in [othello, hamlet, aaron]: + sub = get_subscription(stream.name, user) + sub.is_muted = True + sub.save() + # For othello, 'automatically_unmute_topics_in_muted_streams_policy' + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + othello, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + # For aaron, 'automatically_unmute_topics_in_muted_streams_policy' NOT + # set to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION'. + do_change_user_setting( + aaron, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + # Hamlet creates a todo list. + payload = dict( + type="stream", + to=orjson.dumps(stream.name).decode(), + topic=topic_name, + content="/todo", + ) + result = self.api_post(hamlet, "/api/v1/messages", payload) + self.assert_json_success(result) + + # Othello edits the todo list. DO automatically unmute the topic. + # Aaron edits the todo list. DON'T automatically unmute the topic. + message = self.get_last_message() + + def edit_todo_list(user: UserProfile, data: Dict[str, object]) -> None: + content = orjson.dumps(data).decode() + payload = dict( + message_id=message.id, + msg_type="widget", + content=content, + ) + result = self.api_post(user, "/api/v1/submessage", payload) + self.assert_json_success(result) + + edit_todo_list(othello, dict(type="new_task", key=7, task="eat", desc="", completed=False)) + edit_todo_list(aaron, dict(type="strike", key="5,9")) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {othello.id}) + + def test_only_automatically_increase_visibility_policy(self) -> None: + aaron = self.example_user("aaron") + hamlet = self.example_user("hamlet") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + for user in [hamlet, aaron]: + sub = get_subscription(stream.name, user) + sub.is_muted = True + sub.save() + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + # If a topic is already FOLLOWED, we don't change the state to UNMUTED as the + # intent of these "automatically follow or unmute" policies is that they can only + # increase the user's visibility policy for the topic. + do_set_user_topic_visibility_policy( + aaron, + stream, + topic_name, + visibility_policy=UserTopic.VisibilityPolicy.FOLLOWED, + ) + do_change_user_setting( + aaron, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + self.send_stream_message(aaron, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + # increase visibility from MUTED to UNMUTED + topic_name = "new Topic" + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + do_set_user_topic_visibility_policy( + hamlet, + stream, + topic_name, + visibility_policy=UserTopic.VisibilityPolicy.MUTED, + ) + do_change_user_setting( + hamlet, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + self.send_stream_message(hamlet, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, {hamlet.id}) + + def test_automatically_unmute_policy_unmuted_stream(self) -> None: + aaron = self.example_user("aaron") + cordelia = self.example_user("cordelia") + stream = get_stream("Verona", aaron.realm) + topic_name = "teST topic" + + stream_topic_target = StreamTopicTarget( + stream_id=stream.id, + topic_name=topic_name, + ) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + # The 'automatically_unmute_topics_in_muted_streams_policy' setting has + # NO effect in unmuted streams. + do_change_user_setting( + aaron, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION, + acting_user=None, + ) + self.send_stream_message(aaron, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) + + do_set_user_topic_visibility_policy( + cordelia, + stream, + topic_name, + visibility_policy=UserTopic.VisibilityPolicy.MUTED, + ) + do_change_user_setting( + cordelia, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION, + acting_user=None, + ) + self.send_stream_message(cordelia, stream_name=stream.name, topic_name=topic_name) + user_ids = stream_topic_target.user_ids_with_visibility_policy( + UserTopic.VisibilityPolicy.UNMUTED + ) + self.assertEqual(user_ids, set()) diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py index 0064eb5218..5f79c52417 100644 --- a/zerver/tests/test_users.py +++ b/zerver/tests/test_users.py @@ -1910,6 +1910,7 @@ class RecipientInfoTest(ZulipTestCase): service_bot_tuples=[], all_bot_user_ids=set(), topic_participant_user_ids=set(), + sender_muted_stream=False, ) self.assertEqual(info, expected_info) diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 02deb51e54..684aa4a36f 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -556,6 +556,14 @@ def update_realm_user_settings_defaults( json_validator=check_int_in(UserProfile.REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_CHOICES), default=None, ), + automatically_follow_topics_policy: Optional[int] = REQ( + json_validator=check_int_in(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES), + default=None, + ), + automatically_unmute_topics_in_muted_streams_policy: Optional[int] = REQ( + json_validator=check_int_in(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES), + default=None, + ), presence_enabled: Optional[bool] = REQ(json_validator=check_bool, default=None), enter_sends: Optional[bool] = REQ(json_validator=check_bool, default=None), enable_drafts_synchronization: Optional[bool] = REQ(json_validator=check_bool, default=None), diff --git a/zerver/views/user_settings.py b/zerver/views/user_settings.py index f262ec548d..1f0f373582 100644 --- a/zerver/views/user_settings.py +++ b/zerver/views/user_settings.py @@ -258,6 +258,14 @@ def json_change_settings( json_validator=check_int_in(UserProfile.REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_CHOICES), default=None, ), + automatically_follow_topics_policy: Optional[int] = REQ( + json_validator=check_int_in(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES), + default=None, + ), + automatically_unmute_topics_in_muted_streams_policy: Optional[int] = REQ( + json_validator=check_int_in(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES), + default=None, + ), presence_enabled: Optional[bool] = REQ(json_validator=check_bool, default=None), enter_sends: Optional[bool] = REQ(json_validator=check_bool, default=None), send_private_typing_notifications: Optional[bool] = REQ( diff --git a/zilencer/management/commands/populate_db.py b/zilencer/management/commands/populate_db.py index 3a8e472124..270a02b99e 100644 --- a/zilencer/management/commands/populate_db.py +++ b/zilencer/management/commands/populate_db.py @@ -30,6 +30,7 @@ from zerver.actions.realm_linkifiers import do_add_linkifier from zerver.actions.scheduled_messages import check_schedule_message from zerver.actions.streams import bulk_add_subscriptions from zerver.actions.user_groups import create_user_group_in_database +from zerver.actions.user_settings import do_change_user_setting from zerver.actions.users import do_change_user_role from zerver.lib.bulk_create import bulk_create_streams from zerver.lib.generate_test_data import create_test_data, generate_topics @@ -825,6 +826,29 @@ class Command(BaseCommand): UserProfile.objects.filter(is_bot=False, realm=zulip_realm) ) + # As we plan to change the default values for 'automatically_follow_topics_policy' and + # 'automatically_unmute_topics_in_muted_streams_policy' in the future, it will lead to + # skewing a lot of our tests, which now need to take into account extra events and database queries. + # + # We explicitly set the values for both settings to 'AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER' + # to make the tests independent of the default values. + # + # We have separate tests to verify events generated, database query counts, + # and other important details related to the above-mentioned settings. + for user in user_profiles: + do_change_user_setting( + user, + "automatically_follow_topics_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) + do_change_user_setting( + user, + "automatically_unmute_topics_in_muted_streams_policy", + UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER, + acting_user=None, + ) + # Create a test realm emoji. IMAGE_FILE_PATH = static_path("images/test-images/checkbox.png") with open(IMAGE_FILE_PATH, "rb") as fp: