diff --git a/api_docs/unmerged.d/ZF-1abcca.md b/api_docs/unmerged.d/ZF-1abcca.md new file mode 100644 index 0000000000000..57897c514155b --- /dev/null +++ b/api_docs/unmerged.d/ZF-1abcca.md @@ -0,0 +1,9 @@ +* [`GET /users/me/subscriptions`](/api/get-subscriptions), + [`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events), + [`POST /register`](/api/register-queue): Added `can_unsubscribe_group` + field to Stream and Subscription objects. +* [`POST /users/me/subscriptions`](/api/subscribe), + [`PATCH /streams/{stream_id}`](/api/update-stream): Added `can_unsubscribe_group` + parameter for setting the user group whose members can unsubscribe themselves from channel. +* [`DELETE /users/me/subscriptions`](/api/unsubscribe): Unsubscription is allowed for organization administrators, + users who can administer the channel or remove other subscribers, and members of the channel's `can_unsubscribe_group`. diff --git a/web/src/group_permission_settings.ts b/web/src/group_permission_settings.ts index f89e543ce491c..0beaf1541c964 100644 --- a/web/src/group_permission_settings.ts +++ b/web/src/group_permission_settings.ts @@ -90,6 +90,7 @@ export const stream_group_setting_name_schema = z.enum([ "can_remove_subscribers_group", "can_send_message_group", "can_subscribe_group", + "can_unsubscribe_group", "can_resolve_topics_group", ]); export type StreamGroupSettingName = z.infer; diff --git a/web/src/settings_config.ts b/web/src/settings_config.ts index 0ef5443732e02..8f3ba65dce52d 100644 --- a/web/src/settings_config.ts +++ b/web/src/settings_config.ts @@ -786,6 +786,9 @@ export const all_group_setting_labels = { can_send_message_group: $t({defaultMessage: "Who can post to this channel"}), can_administer_channel_group: $t({defaultMessage: "Who can administer this channel"}), can_subscribe_group: $t({defaultMessage: "Who can subscribe to this channel"}), + can_unsubscribe_group: $t({ + defaultMessage: "Who can unsubscribe from this channel", + }), can_remove_subscribers_group: $t({ defaultMessage: "Who can unsubscribe anyone from this channel", }), @@ -888,6 +891,7 @@ export const stream_group_permission_settings: StreamGroupSettingName[] = [ "can_move_messages_out_of_channel_group", "can_move_messages_within_channel_group", "can_subscribe_group", + "can_unsubscribe_group", "can_add_subscribers_group", "can_remove_subscribers_group", "can_resolve_topics_group", diff --git a/web/src/stream_data.ts b/web/src/stream_data.ts index 6562a483529f2..41b801f6a6475 100644 --- a/web/src/stream_data.ts +++ b/web/src/stream_data.ts @@ -690,14 +690,30 @@ export function user_can_set_topics_policy(sub?: StreamSubscription): boolean { return user_can_set_topics_policy && can_administer_channel(sub); } +export function can_unsubscribe(sub: StreamSubscription): boolean { + // Handles if the user is an organization admin, or in one of these groups: + // can_administer_channel_group or can_remove_subscribers_group. + if (can_unsubscribe_others(sub)) { + return true; + } + + return settings_data.user_has_permission_for_group_setting( + sub.can_unsubscribe_group, + "can_unsubscribe_group", + "stream", + ); +} + export function can_toggle_subscription(sub: StreamSubscription): boolean { if (page_params.is_spectator) { return false; } - // Currently, you can always remove your subscription if you're subscribed. + // If the user is subscribed, they can unsubscribe themselves only if they are + // an organization admin, or in one of these groups: can_administer_channel_group, + // can_remove_subscribers_group, or can_unsubscribe_group. if (sub.subscribed) { - return true; + return can_unsubscribe(sub); } if (has_content_access(sub)) { diff --git a/web/src/stream_edit.ts b/web/src/stream_edit.ts index 630a1d09359b4..8362a9eccd8f8 100644 --- a/web/src/stream_edit.ts +++ b/web/src/stream_edit.ts @@ -172,7 +172,7 @@ function show_subscription_settings(sub: SettingsSubscription): void { stream_ui_updates.update_add_subscriptions_elements(sub); if (!stream_data.can_toggle_subscription(sub)) { - stream_ui_updates.initialize_cant_subscribe_popover(); + stream_ui_updates.initialize_subscription_toggle_disabled_popover(); } const $subscriber_container = $edit_container.find(".edit_subscribers_for_stream"); diff --git a/web/src/stream_events.ts b/web/src/stream_events.ts index 5868ecd31f41c..80fcc0a524328 100644 --- a/web/src/stream_events.ts +++ b/web/src/stream_events.ts @@ -108,7 +108,13 @@ export function update_property

( sub, group_setting_value_schema.parse(value), ); - if (property === "can_subscribe_group" || property === "can_add_subscribers_group") { + if ( + property === "can_subscribe_group" || + property === "can_add_subscribers_group" || + property === "can_unsubscribe_group" || + property === "can_remove_subscribers_group" || + property === "can_administer_channel_group" + ) { stream_settings_ui.update_subscription_elements(sub); } if (property === "can_administer_channel_group") { diff --git a/web/src/stream_popover.ts b/web/src/stream_popover.ts index ab344e294360c..62e1668b50bb6 100644 --- a/web/src/stream_popover.ts +++ b/web/src/stream_popover.ts @@ -121,12 +121,15 @@ function build_stream_popover(opts: {elt: HTMLElement; stream_id: number}): void const stream_unread = unread.unread_count_info_for_stream(stream_id); const stream_unread_count = stream_unread.unmuted_count + stream_unread.muted_count; const has_unread_messages = stream_unread_count > 0; + const stream_subscription = sub_store.get(stream_id); + assert(stream_subscription !== undefined); const content = render_left_sidebar_stream_actions_popover({ stream: { - ...sub_store.get(stream_id), + ...stream_subscription, url: browser_history.get_full_url(stream_hash), list_of_topics_view_url: hash_util.by_channel_topic_list_url(stream_id), }, + should_display_unsubscribe_button: stream_data.can_unsubscribe(stream_subscription), has_unread_messages, show_go_to_channel_feed, show_go_to_list_of_topics, diff --git a/web/src/stream_types.ts b/web/src/stream_types.ts index ce62c4f45c1ff..3418ed5d7fd59 100644 --- a/web/src/stream_types.ts +++ b/web/src/stream_types.ts @@ -21,6 +21,7 @@ export const stream_permission_group_settings_schema = z.enum([ "can_resolve_topics_group", "can_send_message_group", "can_subscribe_group", + "can_unsubscribe_group", ]); export type StreamPermissionGroupSetting = z.infer; @@ -44,6 +45,7 @@ export const stream_schema = z.object({ can_resolve_topics_group: group_setting_value_schema, can_send_message_group: group_setting_value_schema, can_subscribe_group: group_setting_value_schema, + can_unsubscribe_group: group_setting_value_schema, creator_id: z.nullable(z.number()), date_created: z.number(), description: z.string(), diff --git a/web/src/stream_ui_updates.ts b/web/src/stream_ui_updates.ts index defcc5370ee6a..8bef79ae511cc 100644 --- a/web/src/stream_ui_updates.ts +++ b/web/src/stream_ui_updates.ts @@ -139,7 +139,7 @@ export function update_private_stream_privacy_option_state( .toggleClass("default_stream_private_tooltip", is_default_stream); } -export function initialize_cant_subscribe_popover(): void { +export function initialize_subscription_toggle_disabled_popover(): void { const $button_wrapper = $(".settings .stream_settings_header .sub_unsub_button_wrapper"); settings_components.initialize_disable_button_hint_popover($button_wrapper, undefined); } @@ -210,6 +210,7 @@ export function update_settings_button_for_sub(sub: StreamSubscription): void { .addClass("unsubscribed action-button-quiet-brand") .removeClass("action-button-quiet-neutral"); } + const $parent = $settings_button.parent(); if (stream_data.can_toggle_subscription(sub)) { $settings_button.prop("disabled", false); const $parent_element: tippy.ReferenceElement & HTMLElement = util.the( @@ -220,7 +221,12 @@ export function update_settings_button_for_sub(sub: StreamSubscription): void { $settings_button.addClass("toggle-subscription-tooltip"); } else { $settings_button.attr("title", ""); - initialize_cant_subscribe_popover(); + if (sub.subscribed) { + $parent.attr("data-tooltip-template-id", "cannot-unsubscribe-tooltip-template"); + } else { + $parent.attr("data-tooltip-template-id", "cannot-subscribe-tooltip-template"); + } + initialize_subscription_toggle_disabled_popover(); $settings_button.prop("disabled", true); $settings_button.removeClass("toggle-subscription-tooltip"); } diff --git a/web/src/user_profile.ts b/web/src/user_profile.ts index e73e962229d5d..dffaedb1653d8 100644 --- a/web/src/user_profile.ts +++ b/web/src/user_profile.ts @@ -283,7 +283,8 @@ export function get_user_unsub_streams(user_id: number): dropdown_widget.Option[ function format_user_stream_list_item_html(stream: StreamSubscription, user: User): string { const show_unsubscribe_button = - people.can_admin_user(user) || stream_data.can_unsubscribe_others(stream); + stream_data.can_unsubscribe_others(stream) || + (people.is_my_user_id(user.user_id) && stream_data.can_unsubscribe(stream)); const show_private_stream_unsub_tooltip = people.is_my_user_id(user.user_id) && stream.invite_only; const show_last_user_in_private_stream_unsub_tooltip = diff --git a/web/styles/subscriptions.css b/web/styles/subscriptions.css index 5dc3b14699349..30c9aa7e18965 100644 --- a/web/styles/subscriptions.css +++ b/web/styles/subscriptions.css @@ -780,6 +780,14 @@ h4.user_group_setting_subsection_title { color: var(--color-stream-group-row-plus-icon-disabled); } + &.checked svg { + opacity: 0.5; + } + + &.checked svg { + opacity: 0.5; + } + .sub-unsub-icon { color: var(--color-stream-group-row-checked-icon-disabled); cursor: not-allowed; diff --git a/web/templates/popovers/left_sidebar/left_sidebar_stream_actions_popover.hbs b/web/templates/popovers/left_sidebar/left_sidebar_stream_actions_popover.hbs index 6c421ccb3196b..26cf391483aaf 100644 --- a/web/templates/popovers/left_sidebar/left_sidebar_stream_actions_popover.hbs +++ b/web/templates/popovers/left_sidebar/left_sidebar_stream_actions_popover.hbs @@ -77,12 +77,14 @@ {{#unless stream.is_archived}} + {{#if should_display_unsubscribe_button}}

+ {{/if}}