diff --git a/api_docs/changelog.md b/api_docs/changelog.md index e212931c28..d0c83d0e99 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,18 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 9.0 +**Feature level 270** + +* `PATCH /realm`, [`POST /register`](/api/register-queue), + [`GET /events`](/api/get-events): Added two new realm settings, + `direct_message_initiator_group`, which is a + [group-setting value](/api/group-setting-values) describing the + set of users with permission to initiate direct message thread, and + `direct_message_permission_group`, which is a + [group-setting value](/api/group-setting-values) describing the + set of users of which at least one member must be included as sender + or recipient in all personal and group direct messages. + **Feature level 269** * [`POST /register`](/api/register-queue), [`PATCH diff --git a/version.py b/version.py index 07ca1c5f74..76e556ef6c 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,9 @@ 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 = 269 + +API_FEATURE_LEVEL = 270 # Last bumped for direct_message_permission_group + # 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/web/src/admin.js b/web/src/admin.js index 46f9937704..24b3eb06f0 100644 --- a/web/src/admin.js +++ b/web/src/admin.js @@ -142,6 +142,8 @@ export function build_page() { language_list, realm_default_language_name: get_language_name(realm.realm_default_language), realm_default_language_code: realm.realm_default_language, + realm_direct_message_initiator_group_id: realm.realm_direct_message_initiator_group, + realm_direct_message_permission_group_id: realm.realm_direct_message_permission_group, realm_waiting_period_threshold: realm.realm_waiting_period_threshold, realm_new_stream_announcements_stream_id: realm.realm_new_stream_announcements_stream_id, realm_signup_announcements_stream_id: realm.realm_signup_announcements_stream_id, @@ -276,6 +278,10 @@ export function build_page() { tippy.default($("#realm_can_access_all_users_group_widget_container")[0], opts); } + + settings_org.check_disable_direct_message_initiator_group_dropdown( + realm.realm_direct_message_permission_group, + ); } export function launch(section, user_settings_tab) { diff --git a/web/src/compose_actions.ts b/web/src/compose_actions.ts index 28a7da92e8..27c3e8f7fc 100644 --- a/web/src/compose_actions.ts +++ b/web/src/compose_actions.ts @@ -15,6 +15,7 @@ import * as compose_validate from "./compose_validate"; import * as drafts from "./drafts"; import * as message_lists from "./message_lists"; import type {Message} from "./message_store"; +import * as message_util from "./message_util"; import * as message_viewport from "./message_viewport"; import * as narrow_state from "./narrow_state"; import {page_params} from "./page_params"; @@ -22,9 +23,7 @@ import * as people from "./people"; import * as popovers from "./popovers"; import * as reload_state from "./reload_state"; import * as resize from "./resize"; -import * as settings_config from "./settings_config"; import * as spectators from "./spectators"; -import {realm} from "./state_data"; import * as stream_data from "./stream_data"; // Opts sent to `compose_actions.start`. @@ -530,26 +529,21 @@ export function on_narrow(opts: NarrowActivateOpts): void { } return; } - // Do not open compose box if organization has disabled sending - // direct messages and recipient is not a bot. + // Do not open compose box if sender is not allowed to send direct message. + const recipient_ids_string = people.emails_strings_to_user_ids_string( + opts.private_message_recipient, + ); + if ( - realm.realm_private_message_policy === - settings_config.private_message_policy_values.disabled.code && - opts.private_message_recipient + recipient_ids_string && + !message_util.user_can_send_direct_message(recipient_ids_string) ) { - const emails = opts.private_message_recipient.split(","); - if ( - emails.length !== 1 || - emails[0] === undefined || - !people.get_by_email(emails[0])!.is_bot - ) { - // If we are navigating between direct message conversations, - // we want the compose box to close for non-bot users. - if (compose_state.composing()) { - cancel(); - } - return; + // If we are navigating between direct message conversation, + // we want the compose box to close for non-bot users. + if (compose_state.composing()) { + cancel(); } + return; } // Open the compose box, passing the option to skip attempting diff --git a/web/src/compose_banner.ts b/web/src/compose_banner.ts index 900a9f5307..08a07e69a0 100644 --- a/web/src/compose_banner.ts +++ b/web/src/compose_banner.ts @@ -1,5 +1,6 @@ import $ from "jquery"; +import render_cannot_send_direct_message_error from "../templates/compose_banner/cannot_send_direct_message_error.hbs"; import render_compose_banner from "../templates/compose_banner/compose_banner.hbs"; import render_stream_does_not_exist_error from "../templates/compose_banner/stream_does_not_exist_error.hbs"; @@ -48,7 +49,7 @@ export const CLASSNAMES = { stream_does_not_exist: "stream_does_not_exist", missing_stream: "missing_stream", no_post_permissions: "no_post_permissions", - private_messages_disabled: "private_messages_disabled", + cannot_send_direct_message: "cannot_send_direct_message", missing_private_message_recipient: "missing_private_message_recipient", invalid_recipient: "invalid_recipient", invalid_recipients: "invalid_recipients", @@ -183,6 +184,21 @@ export function show_error_message( } } +export function cannot_send_direct_message_error(error_message: string): void { + // Remove any existing banners with this warning. + $(`#compose_banners .${CSS.escape(CLASSNAMES.cannot_send_direct_message)}`).remove(); + + const new_row_html = render_cannot_send_direct_message_error({ + banner_type: ERROR, + error_message, + classname: CLASSNAMES.cannot_send_direct_message, + }); + append_compose_banner_to_banner_list($(new_row_html), $("#compose_banners")); + hide_compose_spinner(); + + $("#private_message_recipient").trigger("focus").trigger("select"); +} + export function show_stream_does_not_exist_error(stream_name: string): void { // Remove any existing banners with this warning. $(`#compose_banners .${CSS.escape(CLASSNAMES.stream_does_not_exist)}`).remove(); diff --git a/web/src/compose_closed_ui.ts b/web/src/compose_closed_ui.ts index 34439c3a8b..cf1067c427 100644 --- a/web/src/compose_closed_ui.ts +++ b/web/src/compose_closed_ui.ts @@ -4,6 +4,7 @@ import * as compose_actions from "./compose_actions"; import {$t} from "./i18n"; import * as message_lists from "./message_lists"; import * as message_store from "./message_store"; +import * as message_util from "./message_util"; import * as narrow_state from "./narrow_state"; import * as people from "./people"; import * as stream_data from "./stream_data"; @@ -132,7 +133,7 @@ export function update_buttons_for_private(): void { let disable_reply; - if (!pm_ids_string || people.user_can_direct_message(pm_ids_string)) { + if (!pm_ids_string || message_util.user_can_send_direct_message(pm_ids_string)) { disable_reply = false; } else { // disable the [Message X] button when in a private narrow diff --git a/web/src/compose_recipient.ts b/web/src/compose_recipient.ts index 12f419677c..f60d205cb6 100644 --- a/web/src/compose_recipient.ts +++ b/web/src/compose_recipient.ts @@ -19,12 +19,11 @@ import * as dropdown_widget from "./dropdown_widget"; import type {Option} from "./dropdown_widget"; import {$t} from "./i18n"; import * as narrow_state from "./narrow_state"; -import * as people from "./people"; -import * as settings_config from "./settings_config"; import {realm} from "./state_data"; import * as stream_data from "./stream_data"; import * as sub_store from "./sub_store"; import * as ui_util from "./ui_util"; +import * as user_groups from "./user_groups"; import * as util from "./util"; type MessageType = "stream" | "private"; @@ -116,12 +115,7 @@ export function update_on_recipient_change(): void { export function get_posting_policy_error_message(): string { if (compose_state.selected_recipient_id === "direct") { const recipients = compose_pm_pill.get_user_ids_string(); - if (!people.user_can_direct_message(recipients)) { - return $t({ - defaultMessage: "You are not allowed to send direct messages in this organization.", - }); - } - return ""; + return compose_validate.check_dm_permissions_and_get_error_string(recipients); } if (!isNumber(compose_state.selected_recipient_id)) { @@ -146,11 +140,13 @@ export function check_posting_policy_for_compose_box(): void { } let banner_classname = compose_banner.CLASSNAMES.no_post_permissions; - if (compose_state.selected_recipient_id === "direct") { - banner_classname = compose_banner.CLASSNAMES.private_messages_disabled; - } compose_validate.set_recipient_disallowed(true); - compose_banner.show_error_message(banner_text, banner_classname, $("#compose_banners")); + if (compose_state.selected_recipient_id === "direct") { + banner_classname = compose_banner.CLASSNAMES.cannot_send_direct_message; + compose_banner.cannot_send_direct_message_error(banner_text); + } else { + compose_banner.show_error_message(banner_text, banner_classname, $("#compose_banners")); + } } function switch_message_type(message_type: MessageType): void { @@ -263,10 +259,8 @@ function get_options_for_recipient_widget(): Option[] { name: $t({defaultMessage: "Direct message"}), }; - if ( - realm.realm_private_message_policy === - settings_config.private_message_policy_values.by_anyone.code - ) { + const {name} = user_groups.get_user_group_from_id(realm.realm_direct_message_permission_group); + if (name !== "role:nobody") { options.unshift(direct_messages_option); } else { options.push(direct_messages_option); diff --git a/web/src/compose_tooltips.ts b/web/src/compose_tooltips.ts index fcd1f7a1b1..8dff96cbc5 100644 --- a/web/src/compose_tooltips.ts +++ b/web/src/compose_tooltips.ts @@ -10,6 +10,7 @@ import * as compose_recipient from "./compose_recipient"; import * as compose_state from "./compose_state"; import * as compose_validate from "./compose_validate"; import {$t} from "./i18n"; +import {pick_empty_narrow_banner} from "./narrow_banner"; import * as narrow_state from "./narrow_state"; import * as popover_menus from "./popover_menus"; import {EXTRA_LONG_HOVER_DELAY, INSTANT_HOVER_DELAY, LONG_HOVER_DELAY} from "./tippyjs"; @@ -47,11 +48,7 @@ export function initialize(): void { const button_type = $elem.attr("data-reply-button-type"); switch (button_type) { case "direct_disabled": { - instance.setContent( - parse_html( - $("#compose_reply_direct_disabled_button_tooltip_template").html(), - ), - ); + instance.setContent(pick_empty_narrow_banner().title); return; } case "selected_message": { diff --git a/web/src/compose_validate.ts b/web/src/compose_validate.ts index 819a523ab1..ebd76f6371 100644 --- a/web/src/compose_validate.ts +++ b/web/src/compose_validate.ts @@ -14,6 +14,7 @@ import * as compose_state from "./compose_state"; import * as compose_ui from "./compose_ui"; import {$t} from "./i18n"; import * as message_store from "./message_store"; +import * as message_util from "./message_util"; import * as narrow_state from "./narrow_state"; import * as peer_data from "./peer_data"; import * as people from "./people"; @@ -26,6 +27,7 @@ import * as stream_data from "./stream_data"; import * as sub_store from "./sub_store"; import type {StreamSubscription} from "./sub_store"; import type {UserOrMention} from "./typeahead_helper"; +import * as user_groups from "./user_groups"; import * as util from "./util"; let user_acknowledged_stream_wildcard = false; @@ -110,6 +112,32 @@ export function needs_subscribe_warning(user_id: number, stream_id: number): boo return true; } +export function check_dm_permissions_and_get_error_string(user_ids_string: string): string { + if (!people.user_can_direct_message(user_ids_string)) { + const {name} = user_groups.get_user_group_from_id( + realm.realm_direct_message_permission_group, + ); + if (name === "role:nobody") { + return $t({ + defaultMessage: "Direct messages are disabled in this organization.", + }); + } + return $t({ + defaultMessage: "This conversation does not include any users who can authorize it.", + }); + } + if ( + message_util.get_direct_message_permission_hints(user_ids_string) + .is_known_empty_conversation && + !people.user_can_initiate_direct_message_thread(user_ids_string) + ) { + return $t({ + defaultMessage: "You are not allowed to start direct message conversations.", + }); + } + return ""; +} + function get_stream_id_for_textarea($textarea: JQuery): number | undefined { // Returns the stream ID, if any, associated with the textarea: // The recipient of a message being edited, or the target @@ -619,20 +647,9 @@ function validate_stream_message(scheduling_message: boolean): boolean { // for now) function validate_private_message(): boolean { const user_ids = compose_pm_pill.get_user_ids(); + const user_ids_string = util.sorted_ids(user_ids).join(","); const $banner_container = $("#compose_banners"); - const user_ids_string = user_ids.join(","); - - if (!people.user_can_direct_message(user_ids_string)) { - compose_banner.show_error_message( - $t({defaultMessage: "Direct messages are disabled in this organization."}), - compose_banner.CLASSNAMES.private_messages_disabled, - $banner_container, - $("#private_message_recipient"), - ); - return false; - } - if (compose_state.private_message_recipient().length === 0) { compose_banner.show_error_message( $t({defaultMessage: "Please specify at least one valid recipient."}), @@ -646,6 +663,12 @@ function validate_private_message(): boolean { return true; } + const direct_message_error_string = check_dm_permissions_and_get_error_string(user_ids_string); + if (direct_message_error_string) { + compose_banner.cannot_send_direct_message_error(direct_message_error_string); + return false; + } + const invalid_recipients = get_invalid_recipient_emails(); let context = {}; diff --git a/web/src/echo.js b/web/src/echo.js index ef6c7958a6..7e1a17051c 100644 --- a/web/src/echo.js +++ b/web/src/echo.js @@ -10,6 +10,7 @@ import * as markdown from "./markdown"; import * as message_lists from "./message_lists"; import * as message_live_update from "./message_live_update"; import * as message_store from "./message_store"; +import * as message_util from "./message_util"; import * as people from "./people"; import * as pm_list from "./pm_list"; import * as recent_view_data from "./recent_view_data"; @@ -224,6 +225,14 @@ export function try_deliver_locally(message_request, insert_new_messages) { // view; this is useful to ensure it will be visible in other // views that we might navigate to before we get a response from // the server. + if ( + message_request.to_user_ids && + !people.user_can_initiate_direct_message_thread(message_request.to_user_ids) && + !message_util.get_direct_message_permission_hints(message_request.to_user_ids) + .is_local_echo_safe + ) { + return undefined; + } if (markdown.contains_backend_only_syntax(message_request.content)) { return undefined; } diff --git a/web/src/message_fetch.js b/web/src/message_fetch.js index 6c6c10908b..2d9b7e55c5 100644 --- a/web/src/message_fetch.js +++ b/web/src/message_fetch.js @@ -3,6 +3,8 @@ import $ from "jquery"; import {all_messages_data} from "./all_messages_data"; import * as blueslip from "./blueslip"; import * as channel from "./channel"; +import * as compose_closed_ui from "./compose_closed_ui"; +import * as compose_recipient from "./compose_recipient"; import * as direct_message_group_data from "./direct_message_group_data"; import * as message_feed_loading from "./message_feed_loading"; import * as message_feed_top_notices from "./message_feed_top_notices"; @@ -106,6 +108,8 @@ function process_result(data, opts) { // Even after loading more messages, we have // no messages to display in this narrow. narrow_banner.show_empty_narrow_message(); + compose_closed_ui.update_buttons_for_private(); + compose_recipient.check_posting_policy_for_compose_box(); } if (opts.num_before > 0 && !has_found_oldest) { diff --git a/web/src/message_util.ts b/web/src/message_util.ts index 7910ca11d3..b624e15936 100644 --- a/web/src/message_util.ts +++ b/web/src/message_util.ts @@ -1,11 +1,21 @@ +import assert from "minimalistic-assert"; + import {all_messages_data} from "./all_messages_data"; import type {MessageListData} from "./message_list_data"; -import type {MessageList, RenderInfo} from "./message_lists"; +import {type MessageList, type RenderInfo} from "./message_lists"; +import * as message_lists from "./message_lists"; import * as message_store from "./message_store"; import type {Message} from "./message_store"; +import * as people from "./people"; +import * as pm_conversations from "./pm_conversations"; import * as unread from "./unread"; import * as unread_ui from "./unread_ui"; +type DirectMessagePermissionHints = { + is_known_empty_conversation: boolean; + is_local_echo_safe: boolean; +}; + export function do_unread_count_updates(messages: Message[], expect_no_new_unreads = false): void { const any_new_unreads = unread.process_loaded_messages(messages, expect_no_new_unreads); @@ -112,3 +122,45 @@ export function get_topics_for_message_ids(message_ids: number[]): MapLearn more.", + }, + { + "z-link": (content_html) => + `${content_html.join("")}`, + }, + ), }; } if (!first_operand.includes(",")) { @@ -409,16 +404,21 @@ function pick_empty_narrow_banner(): NarrowBannerData { title: $t({defaultMessage: "This user does not exist!"}), }; } - if ( - realm.realm_private_message_policy === - settings_config.private_message_policy_values.disabled.code && - !person_in_dms.is_bot - ) { + const person_id_string = person_in_dms.user_id.toString(); + const direct_message_error_string = + compose_validate.check_dm_permissions_and_get_error_string(person_id_string); + if (direct_message_error_string) { return { - title: $t({ - defaultMessage: - "You are not allowed to send direct messages in this organization.", - }), + title: direct_message_error_string, + html: $t_html( + { + defaultMessage: "Learn more.", + }, + { + "z-link": (content_html) => + `${content_html.join("")}`, + }, + ), }; } if (people.is_current_user(first_operand)) { diff --git a/web/src/people.ts b/web/src/people.ts index 9c2509e3b6..7c56edfe35 100644 --- a/web/src/people.ts +++ b/web/src/people.ts @@ -22,6 +22,7 @@ import type { } from "./state_data"; import {current_user, realm} from "./state_data"; import * as timerender from "./timerender"; +import {is_user_in_group} from "./user_groups"; import {user_settings} from "./user_settings"; import * as util from "./util"; @@ -760,28 +761,38 @@ export function should_add_guest_user_indicator(user_id: number): boolean { return user.is_guest; } -export function user_can_direct_message(recipient_ids_string: string): boolean { - // Common function for checking if a user can send a direct - // message to the target user (or group of users) represented by a - // user ids string. - - // Regardless of policy, we allow sending direct messages to bots and to self. +export function user_can_initiate_direct_message_thread(recipient_ids_string: string): boolean { + const direct_message_initiator_group_id = realm.realm_direct_message_initiator_group; const recipient_ids = user_ids_string_to_ids_array(recipient_ids_string); - if ( - recipient_ids.length === 1 && - recipient_ids[0] !== undefined && - (is_valid_bot_user(recipient_ids[0]) || is_my_user_id(recipient_ids[0])) - ) { + if (is_user_in_group(direct_message_initiator_group_id, my_user_id)) { + return true; + } + for (const recipient of recipient_ids) { + if (!is_valid_bot_user(recipient) && recipient !== my_user_id) { + return false; + } + } + return true; +} + +export function user_can_direct_message(recipient_ids_string: string): boolean { + const direct_message_permission_group_id = realm.realm_direct_message_permission_group; + const recipient_ids = user_ids_string_to_ids_array(recipient_ids_string); + if (is_user_in_group(direct_message_permission_group_id, my_user_id)) { return true; } - if ( - realm.realm_private_message_policy === - settings_config.private_message_policy_values.disabled.code - ) { - return false; + let other_human_recipients_exist = false; + for (const recipient_id of recipient_ids) { + if (is_valid_bot_user(recipient_id) || recipient_id === my_user_id) { + continue; + } + if (is_user_in_group(direct_message_permission_group_id, recipient_id)) { + return true; + } + other_human_recipients_exist = true; } - return true; + return !other_human_recipients_exist; } function gravatar_url_for_email(email: string): string { diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index 5e91a0085e..baccea2ac8 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -12,6 +12,7 @@ import * as browser_history from "./browser_history"; import {buddy_list} from "./buddy_list"; import * as compose_call from "./compose_call"; import * as compose_call_ui from "./compose_call_ui"; +import * as compose_closed_ui from "./compose_closed_ui"; import * as compose_pm_pill from "./compose_pm_pill"; import * as compose_recipient from "./compose_recipient"; import * as compose_state from "./compose_state"; @@ -212,6 +213,8 @@ export function dispatch_normal_event(event) { description: noop, digest_emails_enabled: noop, digest_weekday: noop, + direct_message_initiator_group: noop, + direct_message_permission_group: noop, email_changes_disabled: settings_account.update_email_change_display, disallow_disposable_email_addresses: noop, inline_image_preview: noop, @@ -229,7 +232,6 @@ export function dispatch_normal_event(event) { name_changes_disabled: settings_account.update_name_change_display, new_stream_announcements_stream_id: stream_ui_updates.update_announce_stream_option, org_type: noop, - private_message_policy: compose_recipient.check_posting_policy_for_compose_box, push_notifications_enabled: noop, require_unique_names: noop, send_welcome_emails: noop, @@ -296,6 +298,17 @@ export function dispatch_normal_event(event) { stream_settings_ui.update_stream_privacy_choices(key); } + if ( + key === "direct_message_initiator_group" || + key === "direct_message_permission_group" + ) { + settings_org.check_disable_direct_message_initiator_group_dropdown( + realm.realm_direct_message_permission_group, + ); + compose_closed_ui.update_buttons_for_private(); + compose_recipient.check_posting_policy_for_compose_box(); + } + if (key === "edit_topic_policy") { message_live_update.rerender_messages_view(); } diff --git a/web/src/settings_components.ts b/web/src/settings_components.ts index e18a64b715..c892589ec6 100644 --- a/web/src/settings_components.ts +++ b/web/src/settings_components.ts @@ -207,7 +207,6 @@ type simple_dropdown_realm_settings = Pick< | "realm_create_web_public_stream_policy" | "realm_invite_to_stream_policy" | "realm_user_group_edit_policy" - | "realm_private_message_policy" | "realm_add_custom_emoji_policy" | "realm_invite_to_realm_policy" | "realm_wildcard_mention_policy" @@ -474,6 +473,8 @@ const dropdown_widget_map = new Map([ ["can_mention_group", null], ["realm_can_create_public_channel_group", null], ["realm_can_create_private_channel_group", null], + ["realm_direct_message_initiator_group", null], + ["realm_direct_message_permission_group", null], ]); export function get_widget_for_dropdown_list_settings( @@ -781,6 +782,8 @@ export function check_realm_settings_property_changed(elem: HTMLElement): boolea case "realm_can_access_all_users_group": case "realm_can_create_public_channel_group": case "realm_can_create_private_channel_group": + case "realm_direct_message_initiator_group": + case "realm_direct_message_permission_group": proposed_val = get_dropdown_list_widget_setting_value($elem); break; case "realm_message_content_edit_limit_seconds": @@ -974,6 +977,8 @@ export function populate_data_for_realm_settings_request( const realm_group_settings_using_new_api_format = new Set([ "can_create_private_channel_group", "can_create_public_channel_group", + "direct_message_initiator_group", + "direct_message_permission_group", ]); if (realm_group_settings_using_new_api_format.has(property_name)) { const old_value = get_realm_settings_property_value( diff --git a/web/src/settings_config.ts b/web/src/settings_config.ts index 76589bba21..9e73f79dbd 100644 --- a/web/src/settings_config.ts +++ b/web/src/settings_config.ts @@ -256,19 +256,6 @@ export const email_invite_to_realm_policy_values = { }, }; -export const private_message_policy_values = { - by_anyone: { - order: 1, - code: 1, - description: $t({defaultMessage: "Admins, moderators, members and guests"}), - }, - disabled: { - order: 2, - code: 2, - description: $t({defaultMessage: "Direct messages disabled"}), - }, -}; - export const wildcard_mention_policy_values = { by_everyone: { order: 1, diff --git a/web/src/settings_org.js b/web/src/settings_org.js index 5333b47185..c39fb82c5e 100644 --- a/web/src/settings_org.js +++ b/web/src/settings_org.js @@ -95,9 +95,6 @@ export function get_organization_settings_options() { options.common_policy_values = settings_components.get_sorted_options_list( settings_config.common_policy_values, ); - options.private_message_policy_values = settings_components.get_sorted_options_list( - settings_config.private_message_policy_values, - ); options.wildcard_mention_policy_values = settings_components.get_sorted_options_list( settings_config.wildcard_mention_policy_values, ); @@ -129,7 +126,6 @@ const simple_dropdown_properties = [ "realm_create_web_public_stream_policy", "realm_invite_to_stream_policy", "realm_user_group_edit_policy", - "realm_private_message_policy", "realm_add_custom_emoji_policy", "realm_invite_to_realm_policy", "realm_wildcard_mention_policy", @@ -373,6 +369,14 @@ function set_create_web_public_stream_dropdown_visibility() { ); } +export function check_disable_direct_message_initiator_group_dropdown(current_value) { + if (current_value === user_groups.get_user_group_from_name("role:nobody").id) { + $("#realm_direct_message_initiator_group_widget").prop("disabled", true); + } else { + $("#realm_direct_message_initiator_group_widget").prop("disabled", false); + } +} + export function populate_realm_domains_label(realm_domains) { if (!meta.loaded) { return; @@ -472,6 +476,11 @@ function update_dependent_subsettings(property_name) { case "realm_enable_spectator_access": set_create_web_public_stream_dropdown_visibility(); break; + case "realm_direct_message_permission_group": + check_disable_direct_message_initiator_group_dropdown( + realm.realm_direct_message_permission_group, + ); + break; } } @@ -491,6 +500,8 @@ export function discard_realm_property_element_changes(elem) { case "realm_zulip_update_announcements_stream_id": case "realm_default_code_block_language": case "realm_create_multiuse_invite_group": + case "realm_direct_message_initiator_group": + case "realm_direct_message_permission_group": case "realm_can_access_all_users_group": case "realm_can_create_public_channel_group": case "realm_can_create_private_channel_group": @@ -744,7 +755,12 @@ export function set_up() { maybe_disable_widgets(); } -function set_up_dropdown_widget(setting_name, setting_options, setting_type) { +function set_up_dropdown_widget( + setting_name, + setting_options, + setting_type, + custom_dropdown_widget_callback, +) { const $save_discard_widget_container = $(`#id_${CSS.escape(setting_name)}`).closest( ".settings-subsection-parent", ); @@ -774,6 +790,9 @@ function set_up_dropdown_widget(setting_name, setting_options, setting_type) { settings_components.save_discard_realm_settings_widget_status_handler( $save_discard_widget_container, ); + if (custom_dropdown_widget_callback !== undefined) { + custom_dropdown_widget_callback(this.current_value); + } }, tippy_props: { placement: "bottom-start", @@ -799,7 +818,17 @@ export function set_up_dropdown_widget_for_realm_group_settings() { for (const setting_name of realm_group_permission_settings) { const get_setting_options = () => user_groups.get_realm_user_groups_for_dropdown_list_widget(setting_name, "realm"); - set_up_dropdown_widget("realm_" + setting_name, get_setting_options, "group"); + let dropdown_list_item_click_callback; + if (setting_name === "direct_message_permission_group") { + dropdown_list_item_click_callback = + check_disable_direct_message_initiator_group_dropdown; + } + set_up_dropdown_widget( + "realm_" + setting_name, + get_setting_options, + "group", + dropdown_list_item_click_callback, + ); } } diff --git a/web/src/state_data.ts b/web/src/state_data.ts index edafa8d31c..8e1328950b 100644 --- a/web/src/state_data.ts +++ b/web/src/state_data.ts @@ -278,6 +278,8 @@ const realm_schema = z.object({ realm_description: z.string(), realm_digest_emails_enabled: NOT_TYPED_YET, realm_digest_weekday: NOT_TYPED_YET, + realm_direct_message_initiator_group: z.number(), + realm_direct_message_permission_group: z.number(), realm_disallow_disposable_email_addresses: z.boolean(), realm_domains: z.array( z.object({ diff --git a/web/src/ui_init.js b/web/src/ui_init.js index 16a6260778..690e768463 100644 --- a/web/src/ui_init.js +++ b/web/src/ui_init.js @@ -568,7 +568,10 @@ export function initialize_everything(state_data) { user_status.initialize(state_data.user_status); compose_recipient.initialize(); compose_pm_pill.initialize({ - on_pill_create_or_remove: compose_recipient.update_placeholder_text, + on_pill_create_or_remove() { + compose_recipient.update_placeholder_text(); + compose_recipient.check_posting_policy_for_compose_box(); + }, }); compose_closed_ui.initialize(); compose_reply.initialize(); diff --git a/web/src/user_card_popover.js b/web/src/user_card_popover.js index 3d73bd6bd3..166d346e0b 100644 --- a/web/src/user_card_popover.js +++ b/web/src/user_card_popover.js @@ -24,6 +24,7 @@ import * as dialog_widget from "./dialog_widget"; import * as hash_util from "./hash_util"; import {$t, $t_html} from "./i18n"; import * as message_lists from "./message_lists"; +import {user_can_send_direct_message} from "./message_util"; import * as message_view from "./message_view"; import * as muted_users from "./muted_users"; import * as overlays from "./overlays"; @@ -32,7 +33,6 @@ import * as people from "./people"; import * as popover_menus from "./popover_menus"; import {hide_all} from "./popovers"; import * as rows from "./rows"; -import * as settings_config from "./settings_config"; import * as sidebar_ui from "./sidebar_ui"; import {current_user, realm} from "./state_data"; import * as timerender from "./timerender"; @@ -263,13 +263,13 @@ function get_user_card_popover_data( .map((f) => user_profile.get_custom_profile_field_data(user, f, field_types)) .filter((f) => f.display_in_profile_summary && f.value !== undefined && f.value !== null); + const user_id_string = user.user_id.toString(); + const can_send_private_message = + user_can_send_direct_message(user_id_string) && is_active && !is_me; + const args = { invisible_mode, - can_send_private_message: - is_active && - !is_me && - realm.realm_private_message_policy !== - settings_config.private_message_policy_values.disabled.code, + can_send_private_message, display_profile_fields, has_message_context, is_active, diff --git a/web/src/user_groups.ts b/web/src/user_groups.ts index e1245f1ffc..7971bcaf5e 100644 --- a/web/src/user_groups.ts +++ b/web/src/user_groups.ts @@ -3,6 +3,7 @@ import type {z} from "zod"; import * as blueslip from "./blueslip"; import {FoldDict} from "./fold_dict"; import * as group_permission_settings from "./group_permission_settings"; +import {$t} from "./i18n"; import * as settings_config from "./settings_config"; import type {StateData, user_group_schema} from "./state_data"; import {current_user} from "./state_data"; @@ -225,6 +226,14 @@ export function is_user_in_group(user_group_id: number, user_id: number): boolea return false; } +function get_display_name_for_system_group_option(setting_name: string, name: string): string { + // We use a special label for the "Nobody" system group for clarity. + if (setting_name === "direct_message_permission_group" && name === "Nobody") { + return $t({defaultMessage: "Direct messages disabled"}); + } + return name; +} + export function get_realm_user_groups_for_dropdown_list_widget( setting_name: string, setting_type: "realm" | "stream" | "group", @@ -277,7 +286,7 @@ export function get_realm_user_groups_for_dropdown_list_widget( throw new Error(`Unknown group name: ${group.name}`); } return { - name: group.display_name, + name: get_display_name_for_system_group_option(setting_name, group.display_name), unique_id: user_group.id, }; }); diff --git a/web/templates/compose_banner/cannot_send_direct_message_error.hbs b/web/templates/compose_banner/cannot_send_direct_message_error.hbs new file mode 100644 index 0000000000..71527388fc --- /dev/null +++ b/web/templates/compose_banner/cannot_send_direct_message_error.hbs @@ -0,0 +1,9 @@ +{{#> compose_banner }} + +{{/compose_banner}} diff --git a/web/templates/settings/organization_permissions_admin.hbs b/web/templates/settings/organization_permissions_admin.hbs index 2af872cd7a..583c0bf9fc 100644 --- a/web/templates/settings/organization_permissions_admin.hbs +++ b/web/templates/settings/organization_permissions_admin.hbs @@ -110,6 +110,24 @@ +
+
+

{{t "Direct message permissions" }} + {{> ../help_link_widget link="/help/restrict-direct-messages" }} +

+ {{> settings_save_discard_widget section_name="direct-message-permissions" }} +
+ {{> ../dropdown_widget_with_label + widget_name="realm_direct_message_permission_group" + label=(t 'Who can authorize a direct message conversation') + value_type="number" }} + + {{> ../dropdown_widget_with_label + widget_name="realm_direct_message_initiator_group" + label=(t 'Who can start a direct message conversation') + value_type="number" }} +
+

{{t "Message editing" }} @@ -336,15 +354,6 @@ {{> dropdown_options_widget option_values=common_policy_values}}

- -
- - -
diff --git a/web/templates/tooltip_templates.hbs b/web/templates/tooltip_templates.hbs index 1078dea5b9..27024c43e4 100644 --- a/web/templates/tooltip_templates.hbs +++ b/web/templates/tooltip_templates.hbs @@ -18,9 +18,6 @@ {{t 'Reply to selected conversation' }} {{tooltip_hotkey_hints "R"}} -