Repository: zulip/zulip

Issue:
Add an option to make a stream default for new users in stream creation and editing UIs
At present, default streams are configured via the Settings > Default streams panel. There is no indication in stream settings of whether a stream is on the default streams list. When creating a stream, a user has to navigate to a separate menu if they want to add it to the defaults.

To address this, we should add a new setting to the stream creation and stream editing UI.

- Setting: [checkbox] Default stream for new users, with a question mark linking to https://zulip.com/help/set-default-streams-for-new-users
- Placement:
  - Just below "Announce stream" in stream creation UI
  - Just below "Who can access the stream?" in General stream panel
- Set to "off" by default

If user doesn't have permission to set it (currently, non-admin), the setting should be:
- Not shown in stream creation UI
- Shown but disabled in the General stream settings panel 

Additionally, https://zulip.com/help/set-default-streams-for-new-users should be updated to document this feature. This can be done as a follow-up.


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

<!-- areaLabelAddition -->

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

We look forward to your valuable contributions!

Hello @alya, may I be assigned to this issue? Currently my other two issues are under intergration/CZO review. I would like to work on this issue on the side while waiting. 
Claiming the issue since I think I can tackle it.
@zulipbot claim
@HemantUmre12 You have been unassigned from this issue because you have not made any updates for over 14 days. Please feel free to reclaim the issue if you decide to pick up again. Thanks!

Still working on it.

Resolving patch:
diff --git a/version.py b/version.py
--- a/version.py
+++ b/version.py
@@ -33,7 +33,7 @@
 # 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 = 199
+API_FEATURE_LEVEL = 200
 
 # 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/lib/streams.py b/zerver/lib/streams.py
--- a/zerver/lib/streams.py
+++ b/zerver/lib/streams.py
@@ -670,6 +670,7 @@ def list_to_streams(
     user_profile: UserProfile,
     autocreate: bool = False,
     unsubscribing_others: bool = False,
+    is_default_stream: bool = False,
 ) -> Tuple[List[Stream], List[Stream]]:
     """Converts list of dicts to a list of Streams, validating input in the process
 
@@ -736,6 +737,10 @@ def list_to_streams(
                 raise JsonableError(_("Insufficient permission"))
             if not invite_only and not user_profile.can_create_public_streams():
                 raise JsonableError(_("Insufficient permission"))
+            if is_default_stream and not user_profile.is_realm_admin:
+                raise JsonableError(_("Insufficient permission"))
+            if invite_only and is_default_stream:
+                raise JsonableError(_("A default stream cannot be private."))
 
         if not autocreate:
             raise JsonableError(
diff --git a/zerver/views/streams.py b/zerver/views/streams.py
--- a/zerver/views/streams.py
+++ b/zerver/views/streams.py
@@ -262,6 +262,7 @@ def update_stream_backend(
     ),
     is_private: Optional[bool] = REQ(json_validator=check_bool, default=None),
     is_announcement_only: Optional[bool] = REQ(json_validator=check_bool, default=None),
+    is_default_stream: Optional[bool] = REQ(json_validator=check_bool, default=None),
     stream_post_policy: Optional[int] = REQ(
         json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES), default=None
     ),
@@ -290,6 +291,12 @@ def update_stream_backend(
     else:
         proposed_is_web_public = stream.is_web_public
 
+    if is_default_stream is not None:
+        proposed_is_default_stream = is_default_stream
+    else:
+        default_stream_ids = get_default_stream_ids_for_realm(stream.realm_id)
+        proposed_is_default_stream = stream.id in default_stream_ids
+
     if stream.realm.is_zephyr_mirror_realm:
         # In the Zephyr mirroring model, history is unconditionally
         # not public to subscribers, even for public streams.
@@ -319,12 +326,11 @@ def update_stream_backend(
         else:
             raise JsonableError(_("Invalid parameters"))
 
-    if is_private is not None:
-        # Default streams cannot be made private.
-        default_stream_ids = get_default_stream_ids_for_realm(stream.realm_id)
-        if is_private and stream.id in default_stream_ids:
-            raise JsonableError(_("Default streams cannot be made private."))
+    # Ensure that a stream cannot be both a default stream for new users and private
+    if proposed_is_private and proposed_is_default_stream:
+        raise JsonableError(_("A default stream cannot be private."))
 
+    if is_private is not None:
         # We require even realm administrators to be actually
         # subscribed to make a private stream public, via this
         # stricted access_stream check.
@@ -352,6 +358,12 @@ def update_stream_backend(
             acting_user=user_profile,
         )
 
+    if is_default_stream is not None:
+        if is_default_stream:
+            do_add_default_stream(stream)
+        else:
+            do_remove_default_stream(stream)
+
     if message_retention_days is not None:
         if not user_profile.is_realm_owner:
             raise OrganizationOwnerRequiredError
@@ -566,6 +578,7 @@ def add_subscriptions_backend(
     ),
     invite_only: bool = REQ(json_validator=check_bool, default=False),
     is_web_public: bool = REQ(json_validator=check_bool, default=False),
+    is_default_stream: bool = REQ(json_validator=check_bool, default=False),
     stream_post_policy: int = REQ(
         json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES),
         default=Stream.STREAM_POST_POLICY_EVERYONE,
@@ -648,7 +661,9 @@ def add_subscriptions_backend(
     # Validation of the streams arguments, including enforcement of
     # can_create_streams policy and check_stream_name policy is inside
     # list_to_streams.
-    existing_streams, created_streams = list_to_streams(stream_dicts, user_profile, autocreate=True)
+    existing_streams, created_streams = list_to_streams(
+        stream_dicts, user_profile, autocreate=True, is_default_stream=is_default_stream
+    )
     authorized_streams, unauthorized_streams = filter_stream_authorization(
         user_profile, existing_streams
     )
@@ -670,6 +685,10 @@ def add_subscriptions_backend(
             _("You can only invite other Zephyr mirroring users to private streams.")
         )
 
+    if is_default_stream:
+        for stream in created_streams:
+            do_add_default_stream(stream)
+
     (subscribed, already_subscribed) = bulk_add_subscriptions(
         realm, streams, subscribers, acting_user=user_profile, color_map=color_map
     )


Test patch:
diff --git a/web/tests/dispatch.test.js b/web/tests/dispatch.test.js
--- a/web/tests/dispatch.test.js
+++ b/web/tests/dispatch.test.js
@@ -293,6 +293,7 @@ run_test("custom profile fields", ({override}) => {
 run_test("default_streams", ({override}) => {
     const event = event_fixtures.default_streams;
     override(settings_streams, "update_default_streams_table", noop);
+    override(stream_settings_ui, "update_is_default_stream", noop);
     const stub = make_stub();
     override(stream_data, "set_realm_default_streams", stub.f);
     dispatch(event);
diff --git a/zerver/tests/test_subs.py b/zerver/tests/test_subs.py
--- a/zerver/tests/test_subs.py
+++ b/zerver/tests/test_subs.py
@@ -383,6 +383,47 @@ def test_history_public_to_subscribers_on_stream_creation(self) -> None:
             if stream.name == "publictrywithouthistory":
                 self.assertTrue(stream.history_public_to_subscribers)
 
+    def test_add_stream_as_default_on_stream_creation(self) -> None:
+        user_profile = self.example_user("hamlet")
+        self.login_user(user_profile)
+        realm = user_profile.realm
+
+        post_data = {
+            "subscriptions": orjson.dumps(
+                [{"name": "default_stream", "description": "This stream is default for new users"}]
+            ).decode(),
+            "is_default_stream": orjson.dumps(True).decode(),
+        }
+        result = self.api_post(
+            user_profile, "/api/v1/users/me/subscriptions", post_data, subdomain="zulip"
+        )
+        self.assert_json_error(result, "Insufficient permission")
+
+        do_change_user_role(user_profile, UserProfile.ROLE_REALM_ADMINISTRATOR, acting_user=None)
+        result = self.api_post(
+            user_profile, "/api/v1/users/me/subscriptions", post_data, subdomain="zulip"
+        )
+        self.assert_json_success(result)
+        default_stream = get_stream("default_stream", realm)
+        self.assertTrue(default_stream.id in get_default_stream_ids_for_realm(realm.id))
+
+        post_data = {
+            "subscriptions": orjson.dumps(
+                [
+                    {
+                        "name": "private_default_stream",
+                        "description": "This stream is private and default for new users",
+                    }
+                ]
+            ).decode(),
+            "invite_only": orjson.dumps(True).decode(),
+            "is_default_stream": orjson.dumps(True).decode(),
+        }
+        result = self.api_post(
+            user_profile, "/api/v1/users/me/subscriptions", post_data, subdomain="zulip"
+        )
+        self.assert_json_error(result, "A default stream cannot be private.")
+
     def test_history_public_to_subscribers_zephyr_realm(self) -> None:
         realm = get_realm("zephyr")
 
@@ -714,7 +755,7 @@ def test_make_stream_private(self) -> None:
             "is_private": orjson.dumps(True).decode(),
         }
         result = self.client_patch(f"/json/streams/{default_stream.id}", params)
-        self.assert_json_error(result, "Default streams cannot be made private.")
+        self.assert_json_error(result, "A default stream cannot be private.")
         self.assertFalse(default_stream.invite_only)
 
         do_change_user_role(user_profile, UserProfile.ROLE_MEMBER, acting_user=None)
@@ -1051,6 +1092,76 @@ def test_change_history_access_for_private_streams(self) -> None:
         ).decode()
         self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
 
+    def test_add_and_remove_stream_as_default(self) -> None:
+        user_profile = self.example_user("hamlet")
+        self.login_user(user_profile)
+        realm = user_profile.realm
+        stream = self.make_stream("stream", realm=realm)
+        stream_id = self.subscribe(user_profile, "stream").id
+
+        params = {
+            "is_default_stream": orjson.dumps(True).decode(),
+        }
+        result = self.client_patch(f"/json/streams/{stream_id}", params)
+        self.assert_json_error(result, "Must be an organization administrator")
+        self.assertFalse(stream_id in get_default_stream_ids_for_realm(realm.id))
+
+        do_change_user_role(user_profile, UserProfile.ROLE_REALM_ADMINISTRATOR, acting_user=None)
+        result = self.client_patch(f"/json/streams/{stream_id}", params)
+        self.assert_json_success(result)
+        self.assertTrue(stream_id in get_default_stream_ids_for_realm(realm.id))
+
+        params = {
+            "is_private": orjson.dumps(True).decode(),
+        }
+        result = self.client_patch(f"/json/streams/{stream_id}", params)
+        self.assert_json_error(result, "A default stream cannot be private.")
+        stream.refresh_from_db()
+        self.assertFalse(stream.invite_only)
+
+        params = {
+            "is_private": orjson.dumps(True).decode(),
+            "is_default_stream": orjson.dumps(False).decode(),
+        }
+        result = self.client_patch(f"/json/streams/{stream_id}", params)
+        self.assert_json_success(result)
+        stream.refresh_from_db()
+        self.assertTrue(stream.invite_only)
+        self.assertFalse(stream_id in get_default_stream_ids_for_realm(realm.id))
+
+        stream_2 = self.make_stream("stream_2", realm=realm)
+        stream_2_id = self.subscribe(user_profile, "stream_2").id
+
+        bad_params = {
+            "is_default_stream": orjson.dumps(True).decode(),
+            "is_private": orjson.dumps(True).decode(),
+        }
+        result = self.client_patch(f"/json/streams/{stream_2_id}", bad_params)
+        self.assert_json_error(result, "A default stream cannot be private.")
+        stream.refresh_from_db()
+        self.assertFalse(stream_2.invite_only)
+        self.assertFalse(stream_2_id in get_default_stream_ids_for_realm(realm.id))
+
+        private_stream = self.make_stream("private_stream", realm=realm, invite_only=True)
+        private_stream_id = self.subscribe(user_profile, "private_stream").id
+
+        params = {
+            "is_default_stream": orjson.dumps(True).decode(),
+        }
+        result = self.client_patch(f"/json/streams/{private_stream_id}", params)
+        self.assert_json_error(result, "A default stream cannot be private.")
+        self.assertFalse(private_stream_id in get_default_stream_ids_for_realm(realm.id))
+
+        params = {
+            "is_private": orjson.dumps(False).decode(),
+            "is_default_stream": orjson.dumps(True).decode(),
+        }
+        result = self.client_patch(f"/json/streams/{private_stream_id}", params)
+        self.assert_json_success(result)
+        private_stream.refresh_from_db()
+        self.assertFalse(private_stream.invite_only)
+        self.assertTrue(private_stream_id in get_default_stream_ids_for_realm(realm.id))
+
     def test_stream_permission_changes_updates_updates_attachments(self) -> None:
         self.login("desdemona")
         fp = StringIO("zulip!")