/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/compose_validate.ts
1 300 строк
48 KB
Sairam Bisoyi
compose: Clarify warning when linking to a private channel.
04 авг 2026, 10:46
04 авг 2026, 10:46
b466f0c
Код
Авторство
О чём код?
import {$} from "jquery"; import _ from "lodash"; import type {ReferenceElement} from "tippy.js"; import render_compose_banner from "../templates/compose_banner/compose_banner.hbs"; import render_compose_mention_group_warning from "../templates/compose_banner/compose_mention_group_warning.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"; import render_topic_moved_banner from "../templates/compose_banner/topic_moved_banner.hbs"; import render_wildcard_mention_not_allowed_error from "../templates/compose_banner/wildcard_mention_not_allowed_error.hbs"; import render_compose_limit_indicator from "../templates/compose_limit_indicator.hbs"; import render_topics_required_error_message from "../templates/topics_required_error_message.hbs"; import * as blueslip from "./blueslip.ts"; import * as compose_banner from "./compose_banner.ts"; import * as compose_pm_pill from "./compose_pm_pill.ts"; import * as compose_state from "./compose_state.ts"; import * as compose_ui from "./compose_ui.ts"; import * as hash_util from "./hash_util.ts"; import {$t} from "./i18n.ts"; import * as message_store from "./message_store.ts"; import * as message_util from "./message_util.ts"; import * as narrow_state from "./narrow_state.ts"; import * as peer_data from "./peer_data.ts"; import * as people from "./people.ts"; import * as reactions from "./reactions.ts"; import * as recent_senders from "./recent_senders.ts"; import * as resolved_topic from "./resolved_topic.ts"; import * as settings_data from "./settings_data.ts"; import {current_user, realm} from "./state_data.ts"; import * as stream_data from "./stream_data.ts"; import * as stream_topic_history from "./stream_topic_history.ts"; import * as sub_store from "./sub_store.ts"; import type {StreamSubscription} from "./sub_store.ts"; import type {UserOrMention} from "./typeahead_helper.ts"; import {toggle_user_group_info_popover} from "./user_group_popover.ts"; import * as user_groups from "./user_groups.ts"; import type {UserGroup} from "./user_groups.ts"; import * as util from "./util.ts"; let user_acknowledged_stream_wildcard = false; let upload_in_progress = false; let no_message_content = false; let message_too_long = false; // Since same functions are used for both compose and message edit, // we need to track when we are validating compose box. let is_validating_compose_box = false; let disabled_send_tooltip_message_html = ""; export const NO_PERMISSION_TO_POST_IN_CHANNEL_ERROR_MESSAGE = $t({ defaultMessage: "You do not have permission to post in this channel.", }); export const NO_PRIVATE_RECIPIENT_ERROR_MESSAGE = $t({ defaultMessage: "Please add a valid recipient.", }); export const NO_CHANNEL_SELECTED_ERROR_MESSAGE = $t({defaultMessage: "Please select a channel."}); export const get_topics_required_error_tooltip_message_html = (): string => { const error_message_html = render_topics_required_error_message({ empty_string_topic_display_name: util.get_final_topic_display_name(""), }); // The `.tippy-content` element uses `display: flex`, which prevents the empty // topic name from appearing italicized and inline within the error message text. // To resolve this, we wrap the message in a `.tooltip-inner-content` div, // allowing inline formatting and benefiting from the line height adjustment // accompanying the class suitable for multi-line tooltips such as this one. return `<div class="tooltip-inner-content">${error_message_html}</div>`; }; export const get_message_too_long_for_compose_error = (): string => $t( {defaultMessage: "Message length shouldn't be greater than {max_length} characters."}, {max_length: realm.max_message_length}, ); export const NO_MESSAGE_CONTENT_ERROR_MESSAGE = $t({defaultMessage: "Compose a message."}); export const UNSUBSCRIBED_CHANNEL_ERROR_MESSAGE = $t({ defaultMessage: "You're not subscribed to this channel. You will not be notified if other users reply to your message.", }); export const CHANNEL_WILDCARD_ACKNOWLEDGE_MISSING_ERROR_TOOLTIP_MESSAGE = $t({ defaultMessage: "Please acknowledge the warning to send the message.", }); // Only used in tooltips. export const INVALID_CHANNEL_ERROR_TOOLTIP_MESSAGE = $t({ defaultMessage: "Please select a valid channel.", }); export const UPLOAD_IN_PROGRESS_ERROR_TOOLTIP_MESSAGE = $t({ defaultMessage: "Cannot send message while files are being uploaded.", }); export const WILDCARD_MENTION_ERROR_TOOLTIP_MESSAGE = $t({ defaultMessage: "You do not have permission to use wildcard mentions in large streams.", }); export const CANNOT_CREATE_NEW_TOPIC_TOOLTIP_MESSAGE = $t({ defaultMessage: "You are not allowed to start new topics in this channel. Choose an existing topic from the typeahead.", }); type StreamWildcardOptions = { stream_id: number; $banner_container: JQuery; scheduling_message: boolean; stream_wildcard_mention: string | null; }; export let wildcard_mention_threshold = 15; export function set_upload_in_progress(status: boolean): void { upload_in_progress = status; validate_and_update_send_button_status(); } function set_no_message_content(status: boolean): void { no_message_content = status; } function set_message_too_long_for_compose(status: boolean): void { message_too_long = status; } function set_message_too_long_for_edit(status: boolean, $container: JQuery): void { const message_too_long = status; const $message_edit_save_container = $container.find(".message_edit_save_container"); $message_edit_save_container.toggleClass("message-too-long-for-edit", message_too_long); const save_is_disabled = message_too_long || $message_edit_save_container.hasClass("message-edit-time-limit-expired"); $container.find(".message_edit_save").prop("disabled", save_is_disabled); $message_edit_save_container.toggleClass("disabled-message-edit-save", save_is_disabled); } export function get_disabled_send_tooltip_html(): string { return disabled_send_tooltip_message_html; } export function get_disabled_save_tooltip($container: JQuery): string { const $button_wrapper = $container.find(".message_edit_save_container"); // The time limit expiry tooltip takes precedence over the message // length exceeded tooltip. if ($button_wrapper.hasClass("message-edit-time-limit-expired")) { return $t({ defaultMessage: "You can no longer save changes to this message.", }); } if ($button_wrapper.hasClass("message-too-long-for-edit")) { return get_message_too_long_for_compose_error(); } return ""; } export async function needs_subscribe_warning( user_id: number, stream_id: number, ): Promise<boolean> { // This returns true if all of these conditions are met: // * the user is valid // * the user is not already subscribed to the stream // * the user has no back-door way to see stream messages // (i.e. bots on public/private streams) // // You can think of this as roughly answering "is there an // actionable way to subscribe the user and do they actually // need it?". // // We expect the caller to already have verified that we're // sending to a valid stream and trying to mention the user. const user = people.maybe_get_user_by_id(user_id); if (!user) { return false; } if (user.is_bot) { // Bots may receive messages on public/private streams even if they are // not subscribed. return false; } if (await stream_data.maybe_fetch_is_user_subscribed(stream_id, user_id, false)) { // If our user is already subscribed return false; } 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)) { if (user_groups.is_setting_group_empty(realm.realm_direct_message_permission_group)) { 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<HTMLTextAreaElement>): number | undefined { // Returns the stream ID, if any, associated with the textarea: // The recipient of a message being edited, or the target // recipient of a message being drafted in the compose box. // Returns undefined if the appropriate context is a direct // message conversation. const is_in_editing_area = $textarea.closest(".message_row").length > 0; if (is_in_editing_area) { const stream_id_str = $textarea .closest(".recipient_row") .find(".message_header") .attr("data-stream-id"); if (stream_id_str === undefined) { // Direct messages don't have a data-stream-id. return undefined; } return Number.parseInt(stream_id_str, 10); } return compose_state.stream_id(); } export async function warn_if_private_stream_is_linked( linked_stream: StreamSubscription, $textarea: JQuery<HTMLTextAreaElement>, ): Promise<void> { const stream_id = get_stream_id_for_textarea($textarea); if (!stream_id) { // There are two cases in which the `stream_id` will be // omitted, and we want to exclude the warning banner: // // 1. We currently do not warn about links to private streams // in direct messages; it would probably be an improvement to // do so when one of the recipients is not subscribed. // // 2. If we have an invalid stream name, we do not warn about // it here; we will show an error to the user when they try to // send the message. return; } // If the stream we're linking to is not invite-only, then it's // public, and there is no need to warn about it, since all // members can already see all the public streams. // // Theoretically, we could still do a warning if there are any // guest users subscribed to the stream we're posting to; we may // change this policy if user feedback suggests it'd be an // improvement. if (!linked_stream.invite_only) { return; } // Don't warn if subscribers list of current compose_stream is // a subset of linked_stream's subscribers list, because // everyone will be subscribed to the linked stream and so // knows it exists. // Note: `is_subscriber_subset` can return `null` if we encounter // an error fetching subscriber data. In that case, we just show // the banner. if (await peer_data.is_subscriber_subset(stream_id, linked_stream.stream_id)) { return; } // If we've changed streams since fetching subscriber data, // don't update the UI anymore. // Note: The user might have removed the mention of the private // stream during the fetch, and in that case the banner will // still (possibly) show up. If we add logic in the future to // remove banners as the user edits their message, we could use // that here as well. if (stream_id !== get_stream_id_for_textarea($textarea)) { return; } const $banner_container = compose_banner.get_compose_banner_container($textarea); const $existing_stream_warnings_area = $banner_container.find( `.${CSS.escape(compose_banner.CLASSNAMES.private_stream_warning)}`, ); const existing_stream_warnings = [...$existing_stream_warnings_area].map((stream_row) => Number($(stream_row).attr("data-stream-id")), ); if (!existing_stream_warnings.includes(linked_stream.stream_id)) { const audience_channel = stream_data.get_sub_by_id(stream_id)!; const new_row_html = render_private_stream_warning({ stream_id: linked_stream.stream_id, banner_type: compose_banner.WARNING, channel_name: linked_stream.name, audience_channel_name: audience_channel.name, classname: compose_banner.CLASSNAMES.private_stream_warning, }); compose_banner.append_compose_banner_to_banner_list($(new_row_html), $banner_container); } } export async function warn_if_mentioning_unsubscribed_user( mentioned: UserOrMention, $textarea: JQuery<HTMLTextAreaElement>, ): Promise<void> { if (mentioned.type === "broadcast") { return; // don't check if @all/@everyone/@stream } const user_id = mentioned.user.user_id; const stream_id = get_stream_id_for_textarea($textarea); if (!stream_id) { return; } if (await needs_subscribe_warning(user_id, stream_id)) { // Double check that we're still composing to the same stream id // after the awaited fetch for subscriber data. if (get_stream_id_for_textarea($textarea) !== stream_id) { return; } const $banner_container = compose_banner.get_compose_banner_container($textarea); const $existing_invites_area = $banner_container.find( `.${CSS.escape(compose_banner.CLASSNAMES.recipient_not_subscribed)}`, ); const existing_invites = [...$existing_invites_area].map((user_row) => Number($(user_row).attr("data-user-id")), ); const sub = stream_data.get_sub_by_id(stream_id)!; const can_subscribe_other_users = stream_data.can_subscribe_others(sub); if (!existing_invites.includes(user_id)) { const context = { user_id, stream_id, banner_type: compose_banner.WARNING, button_text: can_subscribe_other_users ? $t({defaultMessage: "Subscribe them"}) : null, can_subscribe_other_users, name: mentioned.user.full_name, classname: compose_banner.CLASSNAMES.recipient_not_subscribed, should_add_guest_user_indicator: people.should_add_guest_user_indicator(user_id), }; const new_row_html = render_not_subscribed_warning(context); const $container = compose_banner.get_compose_banner_container($textarea); compose_banner.append_compose_banner_to_banner_list($(new_row_html), $container); } } } // Called on every compose input event to remove any "recipient not // subscribed" banners whose mention is no longer in the compose text. // We check for the three mention syntaxes the markdown parser accepts // (@**Name**, @**Name|id**, @**|id**) rather than re-parsing markdown // on every input event. export function maybe_clear_stale_recipient_not_subscribed_warnings( $textarea: JQuery<HTMLTextAreaElement>, ): void { const $banner_container = compose_banner.get_compose_banner_container($textarea); const $existing_banners = $banner_container.find( `.${CSS.escape(compose_banner.CLASSNAMES.recipient_not_subscribed)}`, ); if ($existing_banners.length === 0) { return; } const compose_text = $textarea.val() ?? ""; for (const banner of $existing_banners) { const user_id = Number($(banner).attr("data-user-id")); if (!user_id) { $(banner).remove(); continue; } const user = people.maybe_get_user_by_id(user_id, true); if (user === undefined) { $(banner).remove(); continue; } // get_mention_syntax produces the canonical @**Name** form (or // @**Name|id** for duplicate names), matching what the typeahead // inserts. const mention_syntax = people.get_mention_syntax(user.full_name, user_id, false); const name_and_id_syntax = `@**${user.full_name}|${user_id}**`; const id_only_syntax = `@**|${user_id}**`; if ( !compose_text.includes(mention_syntax) && !compose_text.includes(name_and_id_syntax) && !compose_text.includes(id_only_syntax) ) { $(banner).remove(); } } } export async function warn_if_mentioning_unsubscribed_group( mentioned_group: UserGroup, $textarea: JQuery<HTMLTextAreaElement>, is_silent: boolean, ): Promise<void> { if (is_silent) { return; } const stream_id = get_stream_id_for_textarea($textarea); if (!stream_id) { return; } const group_members = user_groups.get_recursive_group_members(mentioned_group); let any_member_subscribed = false; for (const user_id of group_members) { if ( (await stream_data.maybe_fetch_is_user_subscribed(stream_id, user_id, false)) && people.is_person_active(user_id) ) { any_member_subscribed = true; break; } } if (any_member_subscribed) { return; } // Double check that we're still composing to the same stream id // after the awaited fetches for subscriber data. if (get_stream_id_for_textarea($textarea) !== stream_id) { return; } const $banner_container = compose_banner.get_compose_banner_container($textarea); // Check if a banner for this specific group already exists const $existing_banners = $banner_container.find( `.${CSS.escape(compose_banner.CLASSNAMES.group_entirely_not_subscribed)} a[data-user-group-id="${mentioned_group.id}"]`, ); if ($existing_banners.length > 0) { return; // Avoid duplicate banners } const context = { group_id: mentioned_group.id, group_name: mentioned_group.name, banner_type: compose_banner.WARNING, classname: compose_banner.CLASSNAMES.group_entirely_not_subscribed, }; const new_row_html = render_compose_mention_group_warning(context); compose_banner.append_compose_banner_to_banner_list($(new_row_html), $banner_container); } // Called when clearing the compose box and similar contexts to clear // the warning for composing to a resolved topic, if present. Also clears // the state for whether this warning has already been shown in the // current narrow. export function clear_topic_resolved_warning(): void { compose_state.set_recipient_viewed_topic_resolved_banner(false); $(`#compose_banners .${CSS.escape(compose_banner.CLASSNAMES.topic_resolved)}`).remove(); } export function warn_if_topic_resolved(topic_changed: boolean): void { // This function is called with topic_changed=false on every // keypress when typing a message, so it should not do anything // expensive in that case. // // Pass topic_changed=true if this function was called in response // to a topic being edited. const recipient_widget_hidden = $(".compose_select_recipient-dropdown-list-container").length === 0; if (compose_state.get_is_processing_forward_message() && recipient_widget_hidden) { // This is for the case of forwarding a message when the // channel picker is opened. There is a possibility that // this banner might be displayed at the same time the // channel picker is opened. We don't want that situation. // Therefore, we are preventing the display of this // banner when the channel picker is opened. return; } const stream_id = compose_state.stream_id(); if (stream_id === undefined) { return; } const topic_name = compose_state.topic(); if (!topic_changed && !resolved_topic.is_resolved(topic_name)) { // The resolved topic warning will only ever appear when // composing to a resolve topic, so we return early without // inspecting additional fields in this case. return; } const message_content = compose_state.message_content(); const sub = stream_data.get_sub_by_id(stream_id); if (sub && message_content !== "" && resolved_topic.is_resolved(topic_name)) { if (compose_state.has_recipient_viewed_topic_resolved_banner()) { // We display the resolved topic banner at most once per narrow. return; } const button_text = stream_data.can_resolve_topics(sub) ? $t({defaultMessage: "Unresolve topic"}) : null; const context = { banner_type: compose_banner.WARNING, stream_id: sub.stream_id, topic_name, banner_text: $t({ defaultMessage: "You are sending a message to a resolved topic. You can send as-is or unresolve the topic first.", }), button_text, classname: compose_banner.CLASSNAMES.topic_resolved, }; const new_row_html = render_compose_banner(context); const appended = compose_banner.append_compose_banner_to_banner_list( $(new_row_html), $("#compose_banners"), ); if (appended) { compose_state.set_recipient_viewed_topic_resolved_banner(true); } } else { clear_topic_resolved_warning(); } } export function clear_topic_moved_info(): void { compose_state.set_recipient_viewed_topic_moved_banner(false); $(`#compose_banners .${CSS.escape(compose_banner.CLASSNAMES.topic_is_moved)}`).remove(); } export function inform_if_topic_is_moved( orig_topic: string, old_stream_id: number, acting_user_id: number | null, ): void { const stream_id = compose_state.stream_id(); if (stream_id === undefined || acting_user_id === current_user.user_id) { clear_topic_moved_info(); return; } const sub = stream_data.get_sub_by_id(stream_id); if (sub === undefined) { clear_topic_moved_info(); return; } const message_content = compose_state.message_content(); if (message_content === "") { // Don't warn if the compose box is empty, as you've not done anything yet. clear_topic_moved_info(); return; } const topic_name = compose_state.topic(); const stream_edited = stream_id !== old_stream_id; const topic_edited = topic_name !== orig_topic; const topic_is_renamed = topic_edited && resolved_topic.unresolve_name(orig_topic) !== resolved_topic.unresolve_name(topic_name); if (!(stream_edited || topic_is_renamed)) { clear_topic_moved_info(); return; } const old_stream = stream_data.get_sub_by_id(old_stream_id); if (!old_stream) { clear_topic_moved_info(); return; } let is_empty_string_topic; if (orig_topic !== "") { is_empty_string_topic = false; } else { is_empty_string_topic = true; } const narrow_url = hash_util.by_stream_topic_url(old_stream_id, orig_topic); const context = { banner_type: compose_banner.INFO, stream_id: sub.stream_id, topic_name, narrow_url, orig_topic, old_stream, classname: compose_banner.CLASSNAMES.topic_is_moved, show_colored_icon: false, is_empty_string_topic, }; const new_row_html = render_topic_moved_banner(context); if (compose_state.has_recipient_viewed_topic_moved_banner()) { // Replace any existing banner of this type to avoid showing // two banners if a conversation is moved twice in quick succession. clear_topic_moved_info(); } const appended = compose_banner.append_compose_banner_to_banner_list( $(new_row_html), $("#compose_banners"), ); if (appended) { compose_state.set_recipient_viewed_topic_moved_banner(true); } } export function warn_if_in_search_view(): void { const filter = narrow_state.filter(); if (filter && !filter.contains_no_partial_conversations()) { const context = { banner_type: compose_banner.WARNING, banner_text: $t({ defaultMessage: "This conversation may have additional messages not shown in this view.", }), button_text: $t({defaultMessage: "Go to conversation"}), classname: compose_banner.CLASSNAMES.search_view, }; const new_row_html = render_compose_banner(context); compose_banner.append_compose_banner_to_banner_list($(new_row_html), $("#compose_banners")); } } 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); const is_edit_container = opts.$banner_container.closest(".edit_form_banners").length > 0; const classname = compose_banner.CLASSNAMES.wildcard_warning; let button_text = opts.scheduling_message ? $t({defaultMessage: "Yes, schedule"}) : $t({defaultMessage: "Yes, send"}); if (is_edit_container) { button_text = $t({defaultMessage: "Yes, save"}); } const stream_wildcard_html = render_stream_wildcard_warning({ banner_type: compose_banner.WARNING, subscriber_count, channel_name: stream_name, wildcard_mention: opts.stream_wildcard_mention, button_text, hide_close_button: true, classname, scheduling_message: opts.scheduling_message, }); // only show one error for any number of @all or @everyone mentions if (opts.$banner_container.find(`.${CSS.escape(classname)}`).length === 0) { compose_banner.append_compose_banner_to_banner_list( $(stream_wildcard_html), opts.$banner_container, ); } else { // if there is already a banner, replace it with the new one compose_banner.update_or_append_banner( $(stream_wildcard_html), classname, opts.$banner_container, ); } user_acknowledged_stream_wildcard = false; } export function clear_stream_wildcard_warnings($banner_container: JQuery): void { const classname = compose_banner.CLASSNAMES.wildcard_warning; $banner_container.find(`.${CSS.escape(classname)}`).remove(); } export function set_user_acknowledged_stream_wildcard_flag(value: boolean): void { user_acknowledged_stream_wildcard = value; } function is_recipient_large_stream(): boolean { const stream_id = compose_state.stream_id(); if (stream_id === undefined) { return false; } return peer_data.get_subscriber_count(stream_id) > wildcard_mention_threshold; } export function topic_participant_count_more_than_threshold( stream_id: number, topic: string, ): boolean { // Topic participants: // Users who either sent or reacted to the messages in the topic. const participant_ids = new Set(); const sender_ids = recent_senders.get_topic_recent_senders(stream_id, topic); for (const id of sender_ids) { participant_ids.add(id); } // If senders count is greater than threshold, no need to calculate reactors. if (participant_ids.size > wildcard_mention_threshold) { return true; } for (const sender_id of sender_ids) { const message_ids = recent_senders.get_topic_message_ids_for_sender( stream_id, topic, sender_id, ); for (const message_id of message_ids) { const message = message_store.get(message_id); if (message) { const message_reactions = reactions.get_message_reactions(message); const reactor_ids = message_reactions.flatMap((obj) => obj.user_ids); for (const id of reactor_ids) { participant_ids.add(id); } if (participant_ids.size > wildcard_mention_threshold) { return true; } } } } return false; } function is_recipient_large_topic(): boolean { const stream_id = compose_state.stream_id(); if (stream_id === undefined) { return false; } return topic_participant_count_more_than_threshold(stream_id, compose_state.topic()); } function user_can_mention_many_users(): boolean { return settings_data.user_has_permission_for_group_setting( realm.realm_can_mention_many_users_group, "can_mention_many_users_group", "realm", ); } export function stream_wildcard_mention_allowed(): boolean { return !is_recipient_large_stream() || user_can_mention_many_users(); } export function topic_wildcard_mention_allowed(): boolean { return !is_recipient_large_topic() || user_can_mention_many_users(); } export function set_wildcard_mention_threshold(value: number): void { wildcard_mention_threshold = value; } export function validate_stream_message_mentions(opts: StreamWildcardOptions): boolean { const subscriber_count = peer_data.get_subscriber_count(opts.stream_id) || 0; // If the user is attempting to do a wildcard mention in a large // stream, check if they permission to do so. If yes, warn them // if they haven't acknowledged the wildcard warning yet. if (opts.stream_wildcard_mention !== null && subscriber_count > wildcard_mention_threshold) { if (!user_can_mention_many_users()) { const new_row_html = render_wildcard_mention_not_allowed_error({ banner_type: compose_banner.ERROR, classname: compose_banner.CLASSNAMES.wildcards_not_allowed, wildcard_mention_string: opts.stream_wildcard_mention, }); compose_banner.append_compose_banner_to_banner_list( $(new_row_html), opts.$banner_container, ); if (is_validating_compose_box) { disabled_send_tooltip_message_html = WILDCARD_MENTION_ERROR_TOOLTIP_MESSAGE; } return false; } if (!user_acknowledged_stream_wildcard) { show_stream_wildcard_warnings(opts); compose_ui.hide_compose_spinner(); if (is_validating_compose_box) { disabled_send_tooltip_message_html = CHANNEL_WILDCARD_ACKNOWLEDGE_MISSING_ERROR_TOOLTIP_MESSAGE; } return false; } } else { // the message no longer contains @all or @everyone clear_stream_wildcard_warnings(opts.$banner_container); } // at this point, the user has either acknowledged the warning or removed @all / @everyone user_acknowledged_stream_wildcard = false; return true; } function validate_permission_to_post_messages_in_stream(sub: StreamSubscription): boolean { if (sub.is_archived) { compose_banner.show_stream_does_not_exist_error(sub.name); if (is_validating_compose_box) { disabled_send_tooltip_message_html = INVALID_CHANNEL_ERROR_TOOLTIP_MESSAGE; } return false; } if (!sub.subscribed) { compose_banner.show_stream_not_subscribed_error(sub, UNSUBSCRIBED_CHANNEL_ERROR_MESSAGE); if (is_validating_compose_box) { disabled_send_tooltip_message_html = UNSUBSCRIBED_CHANNEL_ERROR_MESSAGE; } return false; } if (!stream_data.can_post_messages_in_stream(sub)) { compose_banner.show_error_message( NO_PERMISSION_TO_POST_IN_CHANNEL_ERROR_MESSAGE, compose_banner.CLASSNAMES.no_post_permissions, $("#compose_banners"), ); if (is_validating_compose_box) { disabled_send_tooltip_message_html = NO_PERMISSION_TO_POST_IN_CHANNEL_ERROR_MESSAGE; } return false; } return true; } function validate_stream_message(scheduling_message: boolean, show_banner = true): boolean { const $banner_container = $("#compose_banners"); const stream_id = compose_state.stream_id(); const no_channel_selected = stream_id === undefined; if (no_channel_selected) { report_validation_error( NO_CHANNEL_SELECTED_ERROR_MESSAGE, compose_banner.CLASSNAMES.missing_stream, $banner_container, $("#compose_select_recipient_widget_wrapper"), show_banner, ); if (is_validating_compose_box) { disabled_send_tooltip_message_html = NO_CHANNEL_SELECTED_ERROR_MESSAGE; } return false; } if (!stream_data.can_use_empty_topic(compose_state.stream_id())) { const topic = compose_state.topic(); const missing_topic = util.is_topic_name_considered_empty(topic); if (missing_topic) { if (show_banner) { compose_banner.topic_missing_error(util.get_final_topic_display_name("")); } if (is_validating_compose_box) { disabled_send_tooltip_message_html = get_topics_required_error_tooltip_message_html(); } return false; } } const sub = stream_data.get_sub_by_id(stream_id); if (!sub) { compose_banner.show_stream_does_not_exist_error(stream_id.toString()); if (is_validating_compose_box) { // show_stream_does_not_exist_error already opens the channel selection dropdown. disabled_send_tooltip_message_html = INVALID_CHANNEL_ERROR_TOOLTIP_MESSAGE; } return false; } if (!validate_permission_to_post_messages_in_stream(sub)) { return false; } if (!stream_data.can_create_new_topics_in_stream(stream_id)) { const topic = compose_state.topic(); const existing_topics_in_stream = stream_topic_history .get_recent_topic_names(stream_id) .map((topic) => topic.toLowerCase()); if ( !existing_topics_in_stream.includes(topic.trim().toLowerCase()) && stream_topic_history.has_history_for(stream_id) ) { if (is_validating_compose_box) { disabled_send_tooltip_message_html = CANNOT_CREATE_NEW_TOPIC_TOOLTIP_MESSAGE; } return false; } } const stream_wildcard_mention = util.find_stream_wildcard_mentions( compose_state.message_content(), ); if ( !validate_stream_message_mentions({ stream_id: sub.stream_id, $banner_container, stream_wildcard_mention, scheduling_message, }) ) { return false; } return true; } function set_compose_textarea_disabled(disabled: boolean): void { $("textarea#compose-textarea").prop("disabled", disabled); // Also toggle keyboard navigation on compose control buttons. $("#compose .disable-on-invalid-recipient .compose_control_button").prop( "tabindex", disabled ? -1 : 0, ); } export function validate_private_message(show_banner = true): 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 missing_direct_message_recipient = user_ids.length === 0; if (missing_direct_message_recipient) { report_validation_error( NO_PRIVATE_RECIPIENT_ERROR_MESSAGE, compose_banner.CLASSNAMES.missing_private_message_recipient, $banner_container, $("#private_message_recipient"), show_banner, ); if (is_validating_compose_box) { disabled_send_tooltip_message_html = NO_PRIVATE_RECIPIENT_ERROR_MESSAGE; } return false; } 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); set_compose_textarea_disabled(true); if (is_validating_compose_box) { disabled_send_tooltip_message_html = direct_message_error_string; } return false; } const non_active_user_ids = user_ids.filter((id) => !people.is_person_active(id)); if (non_active_user_ids.length > 0) { const error_message = non_active_user_ids.some((id) => people.get_by_user_id(id).is_deleted) ? $t({defaultMessage: "You cannot send messages to deleted users."}) : $t({defaultMessage: "You cannot send messages to deactivated users."}); compose_banner.show_error_message( error_message, compose_banner.CLASSNAMES.deactivated_user, $banner_container, ); set_compose_textarea_disabled(true); if (is_validating_compose_box) { disabled_send_tooltip_message_html = error_message; } return false; } // Re-enable the compose textarea in case it was disabled by a // previous call for an invalid recipient that has since been fixed. set_compose_textarea_disabled(false); return true; } export function check_overflow_text($container: JQuery): number { // This function is called when typing every character in the // compose box, so it's important that it not doing anything // expensive. const $textarea = $container.find<HTMLTextAreaElement>(".message-textarea"); // Match the behavior of compose_state.message_content of trimming trailing whitespace const text = $textarea.val()!.trimEnd(); const max_length = realm.max_message_length; const remaining_characters = max_length - text.length; const $indicator = $container.find(".message-limit-indicator"); const is_edit_container = $textarea.closest(".message_row").length > 0; const old_no_message_content = no_message_content; const old_message_too_long = message_too_long; if (text.length > max_length) { $indicator.removeClass("textarea-approaching-limit"); $textarea.removeClass("textarea-approaching-limit"); $indicator.addClass("textarea-over-limit"); $textarea.addClass("textarea-over-limit"); $indicator.html( render_compose_limit_indicator({ remaining_characters, }), ); if (is_edit_container) { set_message_too_long_for_edit(true, $container); } else { set_message_too_long_for_compose(true); } } else if (remaining_characters <= 900) { $indicator.removeClass("textarea-over-limit"); $textarea.removeClass("textarea-over-limit"); $indicator.addClass("textarea-approaching-limit"); $textarea.addClass("textarea-approaching-limit"); $indicator.html( render_compose_limit_indicator({ remaining_characters, }), ); if (is_edit_container) { set_message_too_long_for_edit(false, $container); } else { set_message_too_long_for_compose(false); } } else { $indicator.text(""); $textarea.removeClass("textarea-over-limit"); $textarea.removeClass("textarea-approaching-limit"); if (is_edit_container) { set_message_too_long_for_edit(false, $container); } else { set_message_too_long_for_compose(false); } } if (!is_edit_container) { // Update the state for whether the message is empty. set_no_message_content(text.length === 0); if ( message_too_long !== old_message_too_long || old_no_message_content !== no_message_content ) { // If this keystroke changed the truth status for whether // the message is empty or too long, then we need to // refresh the send button status from scratch. This is // expensive, but naturally debounced by the fact this // changes rarely. validate_and_update_send_button_status(); } } return text.length; } export let validate_and_update_send_button_status = function (): void { const is_valid = validate(false, false); const $send_button = $("#compose-send-button"); $send_button.toggleClass("disabled-message-send-controls", !is_valid); const send_button_element: ReferenceElement = util.the($send_button); if (send_button_element._tippy?.state.isVisible) { // If the tooltip is displayed, we update tooltip content // and other properties by hiding and showing the tooltip again. send_button_element._tippy.hide(); send_button_element._tippy.show(); } }; export function rewire_validate_and_update_send_button_status( value: typeof validate_and_update_send_button_status, ): void { validate_and_update_send_button_status = value; } export function validate_message_length($container: JQuery, trigger_flash = true): boolean { const $textarea = $container.find<HTMLTextAreaElement>(".message-textarea"); // Match the behavior of compose_state.message_content of trimming trailing whitespace const text = $textarea.val()!.trimEnd(); const message_too_long_for_compose = text.length > realm.max_message_length; // Usually, check_overflow_text maintains this, but since we just // did the check, make sure it's up to date. set_message_too_long_for_compose(message_too_long_for_compose); if (message_too_long_for_compose) { if (trigger_flash) { $textarea.addClass("flash"); // This must be synchronized with the `flash` CSS. setTimeout(() => $textarea.removeClass("flash"), 500); } return false; } return true; } function report_validation_error( message: string, classname: string, $container: JQuery, $bad_input: JQuery, show_banner: boolean, precursor?: () => void, ): void { if (show_banner) { if (precursor) { precursor(); } compose_banner.show_error_message(message, classname, $container, $bad_input); } } export let validate = (scheduling_message: boolean, show_banner = true): boolean => { is_validating_compose_box = true; disabled_send_tooltip_message_html = ""; // Clear previous banners from the previous compose state; the // validation checks below will re-add any that are still relevant. compose_banner.clear_validation_errors(); // Reset compose textarea state; validate_private_message may // disable it if the recipient is invalid. set_compose_textarea_disabled(false); const message_content = compose_state.message_content(); // The validation checks in this function are in a specific priority order. Don't // change their order unless you want to change which priority they're shown in. if ( compose_state.get_message_type() !== "private" && !validate_stream_message(scheduling_message, show_banner) ) { blueslip.debug("Invalid compose state: Stream message validation failed"); is_validating_compose_box = false; return false; } if (compose_state.get_message_type() === "private" && !validate_private_message(show_banner)) { blueslip.debug("Invalid compose state: Private message validation failed"); is_validating_compose_box = false; return false; } const no_message_content = /^\s*$/.test(message_content); set_no_message_content(no_message_content); if (no_message_content) { if (show_banner) { // If you tried actually sending a message with empty // compose, flash the textarea as invalid. $("textarea#compose-textarea").toggleClass("invalid", true); $("textarea#compose-textarea").trigger("focus"); } blueslip.debug("Invalid compose state: Empty message"); if (is_validating_compose_box) { disabled_send_tooltip_message_html = NO_MESSAGE_CONTENT_ERROR_MESSAGE; } is_validating_compose_box = false; return false; } if ($("textarea#compose-textarea").hasClass("invalid")) { // Hide the invalid indicator now that it's non-empty. $("textarea#compose-textarea").toggleClass("invalid", false); } // TODO: This doesn't actually show a banner, it triggers a flash const trigger_flash = show_banner; if (!validate_message_length($("#send_message_form"), trigger_flash)) { if (is_validating_compose_box) { disabled_send_tooltip_message_html = get_message_too_long_for_compose_error(); } blueslip.debug("Invalid compose state: Message too long"); is_validating_compose_box = false; return false; } if (upload_in_progress) { if (is_validating_compose_box) { disabled_send_tooltip_message_html = UPLOAD_IN_PROGRESS_ERROR_TOOLTIP_MESSAGE; } blueslip.debug("Invalid compose state: Upload in progress"); is_validating_compose_box = false; return false; } is_validating_compose_box = false; return true; }; export function rewire_validate(value: typeof validate): void { validate = value; } export function convert_mentions_to_silent_in_direct_messages( mention_text: string, full_name: string, user_id: number, ): string { if (compose_state.get_message_type() !== "private") { return mention_text; } const recipient_user_ids = compose_pm_pill.get_user_ids(); if (recipient_user_ids.length !== 1 || recipient_user_ids[0] !== user_id) { return mention_text; } const user = people.get_user_by_id_assert_valid(user_id); if (user.is_bot) { // Since bots often process mentions as requests for them to // do things, prefer non-silent mentions when DMing them. return mention_text; } const silent_mention_text = people.get_mention_syntax(full_name, user_id, true); return silent_mention_text; } export function initialize(): void { $("body").on( "click", ".view_user_group_mention", function (this: HTMLElement, e: JQuery.ClickEvent) { e.preventDefault(); e.stopPropagation(); toggle_user_group_info_popover(this, undefined); }, ); }