realm: Add setting to notify user on DMing guest.

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.

Fixes #30078.

Co-authored-by: adnan-td <generaladnan139@gmail.com>
This commit is contained in:
Vector73
2025-02-06 14:13:13 +00:00
committed by Tim Abbott
parent d852aeffc4
commit cb6f0fd63c
23 changed files with 323 additions and 29 deletions

View File

@@ -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):

View File

@@ -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!}

View File

@@ -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

View File

@@ -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,

View File

@@ -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();

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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.

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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(),

View File

@@ -0,0 +1,6 @@
<div class="above_compose_banner main-view-banner warning-style {{classname}}">
<p class="banner_content">
{{banner_text}}
</p>
<a role="button" class="zulip-icon zulip-icon-close main-view-banner-close-button"></a>
</div>

View File

@@ -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

View File

@@ -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();

View File

@@ -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,

View File

@@ -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, () => "<div>pill-html</div>");
mock_banners();
}
test_ui("validate_stream_message_address_info", ({mock_template}) => {
// For this test we basically only use FakeComposeBox
// to set up the DOM environment. We don't assert about
@@ -153,30 +192,6 @@ test_ui("validate_stream_message_address_info", ({mock_template}) => {
});
test_ui("validate", ({mock_template, override}) => {
function initialize_pm_pill() {
$.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, () => "<div>pill-html</div>");
mock_banners();
}
function add_content_to_compose_box() {
$("textarea#compose-textarea").val("foobarfoobar");
}
@@ -184,7 +199,7 @@ test_ui("validate", ({mock_template, override}) => {
// test validating direct messages
compose_state.set_message_type("private");
initialize_pm_pill();
initialize_pm_pill(mock_template);
add_content_to_compose_box();
compose_state.private_message_recipient("");
let pm_recipient_error_rendered = false;
@@ -238,7 +253,7 @@ test_ui("validate", ({mock_template, override}) => {
assert.ok(compose_validate.validate());
override(realm, "realm_is_zephyr_mirror_realm", false);
initialize_pm_pill();
initialize_pm_pill(mock_template);
add_content_to_compose_box();
compose_state.private_message_recipient("welcome-bot@example.com");
$("#send_message_form").set_find_results(".message-textarea", $("textarea#compose-textarea"));
@@ -258,7 +273,7 @@ test_ui("validate", ({mock_template, override}) => {
}
return "<banner-stub>";
});
initialize_pm_pill();
initialize_pm_pill(mock_template);
compose_state.private_message_recipient("welcome-bot@example.com");
$("textarea#compose-textarea").toggleClass = (classname, value) => {
assert.equal(classname, "invalid");
@@ -281,7 +296,7 @@ test_ui("validate", ({mock_template, override}) => {
assert.ok(zephyr_checked);
assert.ok(zephyr_error_rendered);
initialize_pm_pill();
initialize_pm_pill(mock_template);
add_content_to_compose_box();
// test validating stream messages
@@ -842,3 +857,68 @@ test_ui("test warn_if_topic_resolved", ({override, mock_template}) => {
compose_validate.warn_if_topic_resolved(false);
assert.ok(!error_shown);
});
test_ui("test_warn_if_guest_in_dm_recipient", ({mock_template}) => {
let is_active = false;
mock_template("compose_banner/guest_in_dm_recipient_warning.hbs", false, (data) => {
assert.equal(data.classname, compose_banner.CLASSNAMES.guest_in_dm_recipient_warning);
assert.equal(
data.banner_text,
$t({defaultMessage: "Guest is a guest in this organization."}),
);
is_active = true;
return "<banner-stub>";
});
compose_state.set_message_type("private");
initialize_pm_pill(mock_template);
compose_state.private_message_recipient("guest@example.com");
const classname = compose_banner.CLASSNAMES.guest_in_dm_recipient_warning;
let $banner = $(`#compose_banners .${CSS.escape(classname)}`);
// if setting is disabled, remove warning if exists
realm.realm_enable_guest_user_dm_warning = false;
compose_validate.warn_if_guest_in_dm_recipient();
assert.ok(!is_active);
// to show warning for guest emails, banner should be created
realm.realm_enable_guest_user_dm_warning = true;
$banner.length = 0;
compose_validate.warn_if_guest_in_dm_recipient();
assert.ok(is_active);
assert.deepEqual(compose_state.get_recipient_guest_ids_for_dm_warning(), [33]);
// don't show warning for same guests if user closed the banner.
is_active = false;
compose_validate.warn_if_guest_in_dm_recipient();
assert.ok(!is_active);
// on modifying the guest recipient, update banner if already shown.
is_active = true;
const new_guest = {
email: "new_guest@example.com",
user_id: 34,
full_name: "New Guest",
is_guest: true,
};
people.add_active_user(new_guest);
initialize_pm_pill(mock_template);
compose_state.private_message_recipient("guest@example.com, new_guest@example.com");
$banner = $(`#compose_banners .${CSS.escape(classname)}`);
$banner.length = 1;
let is_updated = false;
$banner.set_find_results(".banner_content", {
text(content) {
assert.equal(
content,
$t({defaultMessage: "Guest and New Guest are guests in this organization."}),
);
is_updated = true;
},
});
compose_validate.warn_if_guest_in_dm_recipient();
assert.ok(is_updated);
assert.deepEqual(compose_state.get_recipient_guest_ids_for_dm_warning(), [33, 34]);
});

View File

@@ -835,6 +835,45 @@ test_people("dm_matches_search_string", () => {
assert.ok(!result);
});
test_people("filter_other_guest_ids", ({override}) => {
people.add_active_user(emp401);
people.add_active_user(emp402);
people.add_active_user(guest);
assert.equal(emp401.user_id, 401);
assert.equal(emp402.user_id, 402);
assert.equal(guest.user_id, 33);
let ids = [401, 402];
let guest_ids = people.filter_other_guest_ids(ids);
assert.equal(guest_ids.length, 0);
ids = [401, 402, 33];
guest_ids = people.filter_other_guest_ids(ids);
assert.equal(guest_ids.length, 1);
assert.equal(guest_ids[0], 33);
override(current_user, "is_guest", true);
override(current_user, "user_id", 1);
// User ID of the current user will not be included in result.
guest_ids = people.filter_other_guest_ids([1]);
assert.equal(guest_ids.length, 0);
});
test_people("user_ids_to_full_names_array", () => {
people.add_active_user(emp401);
people.add_active_user(emp402);
assert.equal(emp401.user_id, 401);
assert.equal(emp402.user_id, 402);
const ids = [401, 402];
const names = people.user_ids_to_full_names_array(ids);
assert.equal(names.length, 2);
assert.equal(names[0], emp401.full_name);
assert.equal(names[1], emp402.full_name);
});
test_people("multi_user_methods", () => {
people.add_active_user(emp401);
people.add_active_user(emp402);

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.0.10 on 2025-02-06 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0662_clear_realm_channel_fields_if_configured_channel_deactivated"),
]
operations = [
migrations.AddField(
model_name="realm",
name="enable_guest_user_dm_warning",
field=models.BooleanField(default=True),
),
]

View File

@@ -627,6 +627,9 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
# Whether clients should display "(guest)" after names of guest users.
enable_guest_user_indicator = models.BooleanField(default=True)
# Whether to notify client when a DM has a guest recipient.
enable_guest_user_dm_warning = models.BooleanField(default=True)
# Define the types of the various automatically managed properties
property_types: dict[str, type | UnionType] = dict(
allow_edit_history=bool,
@@ -640,6 +643,7 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
disallow_disposable_email_addresses=bool,
email_changes_disabled=bool,
emails_restricted_to_domains=bool,
enable_guest_user_dm_warning=bool,
enable_guest_user_indicator=bool,
enable_read_receipts=bool,
enable_spectator_access=bool,

View File

@@ -4748,6 +4748,13 @@ paths:
Whether [new users joining](/help/restrict-account-creation#configuring-email-domain-restrictions)
this organization are required to have an email
address in one of the `realm_domains` configured for the organization.
enable_guest_user_dm_warning:
type: boolean
description: |
Whether clients should show a warning when a user is composing
a DM to a guest user in this organization.
**Changes**: New in Zulip 10.0 (feature level 348).
enable_guest_user_indicator:
type: boolean
description: |
@@ -17609,6 +17616,15 @@ paths:
- 2 = Zulip Cloud free plan (LIMITED)
- 3 = Zulip Cloud Standard plan (STANDARD)
- 4 = Zulip Cloud Standard plan, sponsored for free (STANDARD_FREE)
realm_enable_guest_user_dm_warning:
type: boolean
description: |
Present if `realm` is present in `fetch_event_types`.
Whether clients should show a warning when a user is composing
a DM to a guest user in this organization.
**Changes**: New in Zulip 10.0 (feature level 348).
realm_enable_guest_user_indicator:
type: boolean
description: |

View File

@@ -165,6 +165,7 @@ class HomeTest(ZulipTestCase):
"realm_embedded_bots",
"realm_emoji",
"realm_empty_topic_display_name",
"realm_enable_guest_user_dm_warning",
"realm_enable_guest_user_indicator",
"realm_enable_read_receipts",
"realm_enable_spectator_access",

View File

@@ -163,6 +163,7 @@ def update_realm(
Json[int | str] | None,
ApiParamConfig("move_messages_between_streams_limit_seconds"),
] = None,
enable_guest_user_dm_warning: Json[bool] | None = None,
enable_guest_user_indicator: Json[bool] | None = None,
can_access_all_users_group: Json[GroupSettingChangeRequest] | None = None,
) -> HttpResponse: