zulip/web/src/compose_validate.ts

818 lines
29 KiB
TypeScript

import $ from "jquery";
import * as resolved_topic from "../shared/src/resolved_topic.ts";
import render_compose_banner from "../templates/compose_banner/compose_banner.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_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 * 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 {$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 settings_config from "./settings_config.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 sub_store from "./sub_store.ts";
import type {StreamSubscription} from "./sub_store.ts";
import type {UserOrMention} from "./typeahead_helper.ts";
import * as user_groups from "./user_groups.ts";
import * as util from "./util.ts";
let user_acknowledged_stream_wildcard = false;
let upload_in_progress = false;
let message_too_long = false;
let recipient_disallowed = false;
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;
update_send_button_status();
}
function set_message_too_long(status: boolean): void {
message_too_long = status;
update_send_button_status();
}
export function set_recipient_disallowed(status: boolean): void {
recipient_disallowed = status;
update_send_button_status();
}
function update_send_button_status(): void {
$(".message-send-controls").toggleClass(
"disabled-message-send-controls",
message_too_long || upload_in_progress || recipient_disallowed,
);
}
export function get_disabled_send_tooltip(): string {
if (message_too_long) {
return $t(
{defaultMessage: `Message length shouldn't be greater than {max_length} characters.`},
{max_length: realm.max_message_length},
);
} else if (upload_in_progress) {
return $t({defaultMessage: "Cannot send message while files are being uploaded."});
}
return "";
}
export function needs_subscribe_warning(user_id: number, stream_id: number): 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 (stream_data.is_user_subscribed(stream_id, user_id)) {
// 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 function warn_if_private_stream_is_linked(
linked_stream: StreamSubscription,
$textarea: JQuery<HTMLTextAreaElement>,
): 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. (But always warn Zephyr users, since
// we may not know their stream's subscribers.)
if (
peer_data.is_subscriber_subset(stream_id, linked_stream.stream_id) &&
!realm.realm_is_zephyr_mirror_realm
) {
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 new_row_html = render_private_stream_warning({
stream_id: linked_stream.stream_id,
banner_type: compose_banner.WARNING,
channel_name: linked_stream.name,
classname: compose_banner.CLASSNAMES.private_stream_warning,
});
compose_banner.append_compose_banner_to_banner_list($(new_row_html), $banner_container);
}
}
export function warn_if_mentioning_unsubscribed_user(
mentioned: UserOrMention,
$textarea: JQuery<HTMLTextAreaElement>,
): void {
// Disable for Zephyr mirroring realms, since we never have subscriber lists there
if (realm.realm_is_zephyr_mirror_realm) {
return;
}
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 (needs_subscribe_warning(user_id, stream_id)) {
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 can_subscribe_other_users = settings_data.user_can_subscribe_other_users();
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 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 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 = settings_data.user_can_move_messages_to_another_topic()
? $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);
compose_banner.append_compose_banner_to_banner_list($(new_row_html), $("#compose_banners"));
compose_state.set_recipient_viewed_topic_resolved_banner(true);
} else {
clear_topic_resolved_warning();
}
}
export function warn_if_in_search_view(): void {
const filter = narrow_state.filter();
if (filter && !filter.supports_collapsing_recipients()) {
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"));
}
}
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;
}
export function get_invalid_recipient_emails(): string[] {
const private_recipients = util.extract_pm_recipients(
compose_state.private_message_recipient(),
);
const invalid_recipients = private_recipients.filter(
(email) => !people.is_valid_email_for_compose(email),
);
return invalid_recipients;
}
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());
}
// Exported for tests
export let wildcard_mention_policy_authorizes_user = (): boolean => {
if (
realm.realm_wildcard_mention_policy ===
settings_config.wildcard_mention_policy_values.by_everyone.code
) {
return true;
}
if (
realm.realm_wildcard_mention_policy ===
settings_config.wildcard_mention_policy_values.nobody.code
) {
return false;
}
if (
realm.realm_wildcard_mention_policy ===
settings_config.wildcard_mention_policy_values.by_admins_only.code
) {
return current_user.is_admin;
}
if (
realm.realm_wildcard_mention_policy ===
settings_config.wildcard_mention_policy_values.by_moderators_only.code
) {
return current_user.is_admin || current_user.is_moderator;
}
if (
realm.realm_wildcard_mention_policy ===
settings_config.wildcard_mention_policy_values.by_full_members.code
) {
if (current_user.is_admin) {
return true;
}
const person = people.get_by_user_id(current_user.user_id);
const current_datetime = new Date(Date.now()).getTime();
const person_date_joined = new Date(person.date_joined).getTime();
const days = (current_datetime - person_date_joined) / 1000 / 86400;
return days >= realm.realm_waiting_period_threshold && !current_user.is_guest;
}
return !current_user.is_guest;
};
export function rewire_wildcard_mention_policy_authorizes_user(
value: typeof wildcard_mention_policy_authorizes_user,
): void {
wildcard_mention_policy_authorizes_user = value;
}
export function stream_wildcard_mention_allowed(): boolean {
return !is_recipient_large_stream() || wildcard_mention_policy_authorizes_user();
}
export function topic_wildcard_mention_allowed(): boolean {
return !is_recipient_large_topic() || wildcard_mention_policy_authorizes_user();
}
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 (!wildcard_mention_policy_authorizes_user()) {
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,
);
return false;
}
if (!user_acknowledged_stream_wildcard) {
show_stream_wildcard_warnings(opts);
$("#compose-send-button").prop("disabled", false);
compose_ui.hide_compose_spinner();
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;
}
export function validate_stream_message_address_info(sub: StreamSubscription): boolean {
if (sub.is_archived) {
compose_banner.show_stream_does_not_exist_error(sub.name);
return false;
}
if (sub.subscribed) {
return true;
}
compose_banner.show_stream_not_subscribed_error(sub);
return false;
}
function validate_stream_message(scheduling_message: boolean): boolean {
const stream_id = compose_state.stream_id();
const $banner_container = $("#compose_banners");
if (stream_id === undefined) {
compose_banner.show_error_message(
$t({defaultMessage: "Please specify a channel."}),
compose_banner.CLASSNAMES.missing_stream,
$banner_container,
$("#compose_select_recipient_widget_wrapper"),
);
return false;
}
if (realm.realm_mandatory_topics) {
const topic = compose_state.topic();
// TODO: We plan to migrate the empty topic to only using the
// `""` representation for i18n reasons, but have not yet done so.
if (topic === "" || topic === "(no topic)") {
compose_banner.show_error_message(
$t({defaultMessage: "Topics are required in this organization."}),
compose_banner.CLASSNAMES.topic_missing,
$banner_container,
$("input#stream_message_recipient_topic"),
);
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());
return false;
}
if (!stream_data.can_post_messages_in_stream(sub)) {
compose_banner.show_error_message(
$t({
defaultMessage: "You do not have permission to post in this channel.",
}),
compose_banner.CLASSNAMES.no_post_permissions,
$banner_container,
);
return false;
}
const stream_wildcard_mention = util.find_stream_wildcard_mentions(
compose_state.message_content(),
);
if (
!validate_stream_message_address_info(sub) ||
!validate_stream_message_mentions({
stream_id: sub.stream_id,
$banner_container,
stream_wildcard_mention,
scheduling_message,
})
) {
return false;
}
return true;
}
// The function checks whether the recipients are users of the realm or cross realm users (bots
// 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");
if (compose_state.private_message_recipient().length === 0) {
compose_banner.show_error_message(
$t({defaultMessage: "Please specify at least one valid recipient."}),
compose_banner.CLASSNAMES.missing_private_message_recipient,
$banner_container,
$("#private_message_recipient"),
);
return false;
} else if (realm.realm_is_zephyr_mirror_realm) {
// For Zephyr mirroring realms, the frontend doesn't know which users exist
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 = {};
if (invalid_recipients.length === 1) {
context = {recipient: invalid_recipients.join(",")};
compose_banner.show_error_message(
$t({defaultMessage: "The recipient {recipient} is not valid."}, context),
compose_banner.CLASSNAMES.invalid_recipient,
$banner_container,
$("#private_message_recipient"),
);
return false;
} else if (invalid_recipients.length > 1) {
context = {recipients: invalid_recipients.join(",")};
compose_banner.show_error_message(
$t({defaultMessage: "The recipients {recipients} are not valid."}, context),
compose_banner.CLASSNAMES.invalid_recipients,
$banner_container,
$("#private_message_recipient"),
);
return false;
}
for (const user_id of user_ids) {
if (!people.is_person_active(user_id)) {
context = {full_name: people.get_by_user_id(user_id).full_name};
compose_banner.show_error_message(
$t({defaultMessage: "You cannot send messages to deactivated users."}, context),
compose_banner.CLASSNAMES.deactivated_user,
$banner_container,
$("#private_message_recipient"),
);
return false;
}
}
return true;
}
export function check_overflow_text(): number {
// This function is called when typing every character in the
// compose box, so it's important that it not doing anything
// expensive.
const text = compose_state.message_content();
const max_length = realm.max_message_length;
const remaining_characters = max_length - text.length;
const $indicator = $("#compose-limit-indicator");
if (text.length > max_length) {
$indicator.addClass("over_limit");
$("textarea#compose-textarea").addClass("over_limit");
$indicator.html(
render_compose_limit_indicator({
remaining_characters,
}),
);
set_message_too_long(true);
} else if (remaining_characters <= 900) {
$indicator.removeClass("over_limit");
$("textarea#compose-textarea").removeClass("over_limit");
$indicator.html(
render_compose_limit_indicator({
remaining_characters,
}),
);
set_message_too_long(false);
} else {
$indicator.text("");
$("textarea#compose-textarea").removeClass("over_limit");
set_message_too_long(false);
}
return text.length;
}
export function validate_message_length(): boolean {
if (compose_state.message_content().length > realm.max_message_length) {
$("textarea#compose-textarea").addClass("flash");
setTimeout(() => $("textarea#compose-textarea").removeClass("flash"), 1500);
return false;
}
return true;
}
export function validate(scheduling_message: boolean): boolean {
const message_content = compose_state.message_content();
if (/^\s*$/.test(message_content)) {
$("textarea#compose-textarea").toggleClass("invalid", true);
return false;
}
if ($("#zephyr-mirror-error").is(":visible")) {
compose_banner.show_error_message(
$t({
defaultMessage:
"You need to be running Zephyr mirroring in order to send messages!",
}),
compose_banner.CLASSNAMES.zephyr_not_running,
$("#compose_banners"),
);
return false;
}
if (!validate_message_length()) {
return false;
}
if (compose_state.get_message_type() === "private") {
return validate_private_message();
}
return validate_stream_message(scheduling_message);
}
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_id = compose_pm_pill.get_user_ids();
if (recipient_user_id.toString() !== user_id.toString()) {
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;
}