diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 768a0776f4..8db0118b9f 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 348** + +* [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events), + `PATCH /realm`: Added `enable_guest_user_dm_warning` setting to decide + whether clients should show a warning when a user is composing to a + guest user in the organization. + **Feature level 347** * [Markdown message formatting](/api/message-formatting#links-to-channels-topics-and-messages): diff --git a/help/guest-users.md b/help/guest-users.md index e99c56d1f1..8f6ed9efd6 100644 --- a/help/guest-users.md +++ b/help/guest-users.md @@ -42,6 +42,20 @@ pricing](/help/zulip-cloud-billing#temporary-users-and-guests) for guest users. {end_tabs} +## Configure warning for direct messages to guest users + +{start_tabs} + +{tab|desktop-web} + +{settings_tab|organization-permissions} + +1. Under **Guests**, toggle **Display a warning when composing a direct message with guest user recipients**. + +{!save-changes.md!} + +{end_tabs} + ## Configure whether guests can see all other users {!cloud-plus-only.md!} diff --git a/version.py b/version.py index 2dda916b0d..1eaa91ddd9 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 347 # Last bumped for /with/ in topic links. +API_FEATURE_LEVEL = 348 # Last bumped for enable_guest_user_dm_warning. # 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.ts b/web/src/admin.ts index e4edad6dd9..cef449a983 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -73,6 +73,10 @@ const admin_settings_label = { realm_enable_guest_user_indicator: $t({ defaultMessage: "Display “(guest)” after names of guest users", }), + realm_enable_guest_user_dm_warning: $t({ + defaultMessage: + "Display a warning when composing a direct message with guest user recipients", + }), }; function insert_tip_box(): void { @@ -246,6 +250,7 @@ export function build_page(): void { automatically_unmute_topics_in_muted_streams_policy_values: settings_config.automatically_follow_or_unmute_topics_policy_values, realm_enable_guest_user_indicator: realm.realm_enable_guest_user_indicator, + realm_enable_guest_user_dm_warning: realm.realm_enable_guest_user_dm_warning, active_user_list_dropdown_widget_name: settings_users.active_user_list_dropdown_widget_name, deactivated_user_list_dropdown_widget_name: settings_users.deactivated_user_list_dropdown_widget_name, diff --git a/web/src/compose_actions.ts b/web/src/compose_actions.ts index 77b8a9f52c..0688b840a1 100644 --- a/web/src/compose_actions.ts +++ b/web/src/compose_actions.ts @@ -127,6 +127,7 @@ function clear_box(): void { // TODO: Better encapsulate at-mention warnings. compose_validate.clear_topic_resolved_warning(); compose_validate.clear_stream_wildcard_warnings($("#compose_banners")); + compose_validate.clear_guest_in_dm_recipient_warning(); compose_validate.set_user_acknowledged_stream_wildcard_flag(false); compose_state.set_recipient_edited_manually(false); @@ -408,6 +409,8 @@ export let start = (raw_opts: ComposeActionsStartOpts): void => { // Show a warning if topic is resolved compose_validate.warn_if_topic_resolved(true); + // Show a warning if dm recipient contains guest + compose_validate.warn_if_guest_in_dm_recipient(); // Show a warning if the user is in a search narrow when replying to a message if (opts.is_reply) { compose_validate.warn_if_in_search_view(); diff --git a/web/src/compose_banner.ts b/web/src/compose_banner.ts index f07be80424..5208b25a2b 100644 --- a/web/src/compose_banner.ts +++ b/web/src/compose_banner.ts @@ -43,6 +43,7 @@ export const CLASSNAMES = { recipient_not_subscribed: "recipient_not_subscribed", wildcard_warning: "wildcard_warning", private_stream_warning: "private_stream_warning", + guest_in_dm_recipient_warning: "guest_in_dm_recipient_warning", unscheduled_message: "unscheduled_message", search_view: "search_view", // errors diff --git a/web/src/compose_recipient.ts b/web/src/compose_recipient.ts index 38ef7061f0..90b602cc8e 100644 --- a/web/src/compose_recipient.ts +++ b/web/src/compose_recipient.ts @@ -114,6 +114,7 @@ function update_fade(): void { export function update_on_recipient_change(): void { update_fade(); update_narrow_to_recipient_visibility(); + compose_validate.warn_if_guest_in_dm_recipient(); drafts.update_compose_draft_count(); check_posting_policy_for_compose_box(); } diff --git a/web/src/compose_state.ts b/web/src/compose_state.ts index 090ca525fa..2c8e692628 100644 --- a/web/src/compose_state.ts +++ b/web/src/compose_state.ts @@ -17,6 +17,7 @@ let last_focused_compose_type_input: HTMLTextAreaElement | undefined; // the narrow and the user should still be able to see the banner once after // performing these actions let recipient_viewed_topic_resolved_banner = false; +let recipient_guest_ids_for_dm_warning: number[] = []; export function set_recipient_edited_manually(flag: boolean): void { recipient_edited_manually = flag; @@ -58,6 +59,14 @@ export function has_recipient_viewed_topic_resolved_banner(): boolean { return recipient_viewed_topic_resolved_banner; } +export function set_recipient_guest_ids_for_dm_warning(guest_ids: number[]): void { + recipient_guest_ids_for_dm_warning = guest_ids; +} + +export function get_recipient_guest_ids_for_dm_warning(): number[] { + return recipient_guest_ids_for_dm_warning; +} + export function composing(): boolean { // This is very similar to get_message_type(), but it returns // a boolean. diff --git a/web/src/compose_validate.ts b/web/src/compose_validate.ts index 20a411651b..005ffb25d4 100644 --- a/web/src/compose_validate.ts +++ b/web/src/compose_validate.ts @@ -1,7 +1,9 @@ import $ from "jquery"; +import _ from "lodash"; import * as resolved_topic from "../shared/src/resolved_topic.ts"; import render_compose_banner from "../templates/compose_banner/compose_banner.hbs"; +import render_guest_in_dm_recipient_warning from "../templates/compose_banner/guest_in_dm_recipient_warning.hbs"; import render_not_subscribed_warning from "../templates/compose_banner/not_subscribed_warning.hbs"; import render_private_stream_warning from "../templates/compose_banner/private_stream_warning.hbs"; import render_stream_wildcard_warning from "../templates/compose_banner/stream_wildcard_warning.hbs"; @@ -389,6 +391,72 @@ export function warn_if_in_search_view(): void { } } +export function clear_guest_in_dm_recipient_warning(): void { + // We don't call set_recipient_guest_ids_for_dm_warning here, so + // that reopening the same draft won't make the banner reappear. + const classname = compose_banner.CLASSNAMES.guest_in_dm_recipient_warning; + $(`#compose_banners .${CSS.escape(classname)}`).remove(); +} + +// Only called on recipient change. Adds new banner if not already +// exists or updates the existing banner or removes banner if no +// guest in the dm. +export function warn_if_guest_in_dm_recipient(): void { + if (!compose_state.composing()) { + return; + } + const recipient_ids = compose_pm_pill.get_user_ids(); + const guest_ids = people.filter_other_guest_ids(recipient_ids); + + if ( + !realm.realm_enable_guest_user_dm_warning || + compose_state.get_message_type() !== "private" || + guest_ids.length === 0 + ) { + clear_guest_in_dm_recipient_warning(); + compose_state.set_recipient_guest_ids_for_dm_warning([]); + return; + } + // If warning was shown earlier for same guests in the recipients, do nothing. + if (_.isEqual(compose_state.get_recipient_guest_ids_for_dm_warning(), guest_ids)) { + return; + } + + const guest_names = people.user_ids_to_full_names_array(guest_ids); + let banner_text: string; + + if (guest_names.length === 1) { + banner_text = $t( + {defaultMessage: "{name} is a guest in this organization."}, + {name: guest_names[0]}, + ); + } else { + const names_string = util.format_array_as_list(guest_names, "long", "conjunction"); + banner_text = $t( + {defaultMessage: "{names} are guests in this organization."}, + {names: names_string}, + ); + } + + const classname = compose_banner.CLASSNAMES.guest_in_dm_recipient_warning; + let $banner = $(`#compose_banners .${CSS.escape(classname)}`); + + compose_state.set_recipient_guest_ids_for_dm_warning(guest_ids); + // Update banner text if banner already exists. + if ($banner.length === 1) { + $banner.find(".banner_content").text(banner_text); + return; + } + + $banner = $( + render_guest_in_dm_recipient_warning({ + banner_text, + classname: compose_banner.CLASSNAMES.guest_in_dm_recipient_warning, + }), + ); + compose_banner.append_compose_banner_to_banner_list($banner, $("#compose_banners")); +} + function show_stream_wildcard_warnings(opts: StreamWildcardOptions): void { const subscriber_count = peer_data.get_subscriber_count(opts.stream_id) || 0; const stream_name = sub_store.maybe_get_stream_name(opts.stream_id); diff --git a/web/src/people.ts b/web/src/people.ts index ce807e65ed..600eb2d129 100644 --- a/web/src/people.ts +++ b/web/src/people.ts @@ -687,6 +687,17 @@ export function pm_with_operand_ids(operand: string): number[] | undefined { return user_ids; } +export function filter_other_guest_ids(user_ids: number[]): number[] { + return sort_numerically( + user_ids.filter((id) => id !== current_user.user_id && get_by_user_id(id)?.is_guest), + ); +} + +export function user_ids_to_full_names_array(user_ids: number[]): string[] { + const names = user_ids.map((user_id) => get_by_user_id(user_id).full_name).sort(util.strcmp); + return names; +} + export function emails_to_slug(emails_string: string): string | undefined { let slug = reply_to_to_user_ids_string(emails_string); diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index fb75da1e0e..594bec9d7f 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -16,6 +16,7 @@ import * as compose_closed_ui from "./compose_closed_ui.ts"; import * as compose_pm_pill from "./compose_pm_pill.ts"; import * as compose_recipient from "./compose_recipient.ts"; import * as compose_state from "./compose_state.ts"; +import * as compose_validate from "./compose_validate.ts"; import {electron_bridge} from "./electron_bridge.ts"; import * as emoji from "./emoji.ts"; import * as emoji_picker from "./emoji_picker.ts"; @@ -262,6 +263,7 @@ export function dispatch_normal_event(event) { want_advertise_in_communities_directory: noop, wildcard_mention_policy: noop, enable_read_receipts: settings_account.update_send_read_receipts_tooltip, + enable_guest_user_dm_warning: compose_validate.warn_if_guest_in_dm_recipient, enable_guest_user_indicator: noop, }; switch (event.op) { diff --git a/web/src/state_data.ts b/web/src/state_data.ts index 7f5b6a4521..0732dfefc5 100644 --- a/web/src/state_data.ts +++ b/web/src/state_data.ts @@ -337,6 +337,7 @@ export const realm_schema = z.object({ }), ), realm_empty_topic_display_name: z.string(), + realm_enable_guest_user_dm_warning: z.boolean(), realm_enable_guest_user_indicator: z.boolean(), realm_enable_read_receipts: z.boolean(), realm_enable_spectator_access: z.boolean(), diff --git a/web/templates/compose_banner/guest_in_dm_recipient_warning.hbs b/web/templates/compose_banner/guest_in_dm_recipient_warning.hbs new file mode 100644 index 0000000000..3458ce7308 --- /dev/null +++ b/web/templates/compose_banner/guest_in_dm_recipient_warning.hbs @@ -0,0 +1,6 @@ +
diff --git a/web/templates/settings/organization_permissions_admin.hbs b/web/templates/settings/organization_permissions_admin.hbs index 185829d2ae..d1c75323e1 100644 --- a/web/templates/settings/organization_permissions_admin.hbs +++ b/web/templates/settings/organization_permissions_admin.hbs @@ -321,6 +321,12 @@ is_checked=realm_enable_guest_user_indicator label=admin_settings_label.realm_enable_guest_user_indicator}} + {{> settings_checkbox + setting_name="realm_enable_guest_user_dm_warning" + prefix="id_" + is_checked=realm_enable_guest_user_dm_warning + label=admin_settings_label.realm_enable_guest_user_dm_warning}} + {{> ../dropdown_widget_with_label widget_name="realm_can_access_all_users_group" label=group_setting_labels.can_access_all_users_group diff --git a/web/tests/compose.test.cjs b/web/tests/compose.test.cjs index 27f8f56c00..363e555eb6 100644 --- a/web/tests/compose.test.cjs +++ b/web/tests/compose.test.cjs @@ -630,6 +630,7 @@ test_ui("update_fade", ({override, override_rewire}) => { update_narrow_to_recipient_visibility_called = true; }); override_rewire(drafts, "update_compose_draft_count", noop); + override(compose_pm_pill, "get_user_ids", () => []); compose_state.set_message_type(undefined); compose_recipient.update_on_recipient_change(); diff --git a/web/tests/compose_actions.test.cjs b/web/tests/compose_actions.test.cjs index de6d78aaa1..e0094f88b3 100644 --- a/web/tests/compose_actions.test.cjs +++ b/web/tests/compose_actions.test.cjs @@ -42,6 +42,7 @@ const compose_fade = mock_esm("../src/compose_fade", { }); const compose_pm_pill = mock_esm("../src/compose_pm_pill", { get_user_ids_string: () => "", + get_user_ids: () => [], }); const compose_ui = mock_esm("../src/compose_ui", { autosize_textarea: noop, diff --git a/web/tests/compose_validate.test.cjs b/web/tests/compose_validate.test.cjs index ea3db1f8e1..2f74ec052e 100644 --- a/web/tests/compose_validate.test.cjs +++ b/web/tests/compose_validate.test.cjs @@ -22,6 +22,11 @@ const {set_current_user, set_realm} = zrequire("state_data"); const stream_data = zrequire("stream_data"); const compose_recipient = zrequire("/compose_recipient"); const user_groups = zrequire("user_groups"); +const {initialize_user_settings} = zrequire("user_settings"); + +mock_esm("../src/ui_util", { + place_caret_at_end: noop, +}); mock_esm("../src/group_permission_settings", { get_group_permission_setting_config: () => ({ @@ -33,6 +38,8 @@ const realm = {}; set_realm(realm); const current_user = {}; set_current_user(current_user); +const user_settings = {defualt_language: "en"}; +initialize_user_settings({user_settings}); const me = { email: "me@example.com", @@ -54,6 +61,13 @@ const bob = { is_admin: true, }; +const guest = { + email: "guest@example.com", + user_id: 33, + full_name: "Guest", + is_guest: true, +}; + const social_sub = { stream_id: 101, name: "social", @@ -66,6 +80,7 @@ people.initialize_current_user(me.user_id); people.add_active_user(alice); people.add_active_user(bob); +people.add_active_user(guest); const welcome_bot = { email: "welcome-bot@example.com", @@ -116,6 +131,30 @@ function stub_message_row($textarea) { }; } +function initialize_pm_pill(mock_template) { + $.clear_all_elements(); + + $("#compose-send-button").prop("disabled", false); + $("#compose-send-button").trigger("focus"); + $("#compose-send-button .loader").hide(); + + const $pm_pill_container = $.create("fake-pm-pill-container"); + $("#private_message_recipient")[0] = {}; + $("#private_message_recipient").set_parent($pm_pill_container); + $pm_pill_container.set_find_results(".input", $("#private_message_recipient")); + $("#private_message_recipient").before = noop; + + compose_pm_pill.initialize({ + on_pill_create_or_remove: compose_recipient.update_placeholder_text, + }); + + $("#zephyr-mirror-error").is = noop; + + mock_template("input_pill.hbs", false, () => "