/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/message_edit.ts
1 858 строк
69 KB
Anders Kaseorg
eslint: Fix no-useless-assignment.
29 июл 2026, 02:35
29 июл 2026, 02:35
539e85c
Код
Авторство
О чём код?
import autosize from "autosize"; import ClipboardJS from "clipboard"; import {$} from "jquery"; import _ from "lodash"; import assert from "minimalistic-assert"; import * as tippy from "tippy.js"; import * as z from "zod/mini"; import render_wildcard_mention_not_allowed_error from "../templates/compose_banner/wildcard_mention_not_allowed_error.hbs"; import render_confirm_edit_messages from "../templates/confirm_dialog/confirm_edit_messages.hbs"; import render_confirm_merge_topics_with_rename from "../templates/confirm_dialog/confirm_merge_topics_with_rename.hbs"; import render_confirm_moving_messages_modal from "../templates/confirm_dialog/confirm_moving_messages.hbs"; import render_intro_resolve_topic_modal from "../templates/confirm_dialog/intro_resolve_topic.hbs"; import render_message_edit_form from "../templates/message_edit_form.hbs"; import render_message_moved_widget_body from "../templates/message_moved_widget_body.hbs"; import render_resolve_topic_time_limit_error_modal from "../templates/resolve_topic_time_limit_error_modal.hbs"; import render_topic_edit_form from "../templates/topic_edit_form.hbs"; import {detached_uploads_api_response_schema} from "./attachments.ts"; import * as attachments_ui from "./attachments_ui.ts"; import * as blueslip from "./blueslip.ts"; import type {Typeahead} from "./bootstrap_typeahead.ts"; import * as buttons from "./buttons.ts"; import * as channel from "./channel.ts"; import * as compose_actions from "./compose_actions.ts"; import * as compose_banner from "./compose_banner.ts"; import * as compose_call from "./compose_call.ts"; import {compose_call_session_manager} from "./compose_call_session.ts"; import * as compose_tooltips from "./compose_tooltips.ts"; import * as compose_ui from "./compose_ui.ts"; import * as compose_validate from "./compose_validate.ts"; import * as composebox_typeahead from "./composebox_typeahead.ts"; import * as condense from "./condense.ts"; import * as confirm_dialog from "./confirm_dialog.ts"; import {show_copied_confirmation} from "./copied_tooltip.ts"; import * as dialog_widget from "./dialog_widget.ts"; import * as echo from "./echo.ts"; import * as feedback_widget from "./feedback_widget.ts"; import * as gif_state from "./gif_state.ts"; import * as hash_util from "./hash_util.ts"; import {$t, $t_html} from "./i18n.ts"; import * as keydown_util from "./keydown_util.ts"; import * as loading from "./loading.ts"; import * as markdown from "./markdown.ts"; import * as message_fetch_raw_content from "./message_fetch_raw_content.ts"; import * as message_lists from "./message_lists.ts"; import * as message_live_update from "./message_live_update.ts"; import * as message_store from "./message_store.ts"; import type {Message} from "./message_store.ts"; import * as message_viewport from "./message_viewport.ts"; import * as onboarding_steps from "./onboarding_steps.ts"; import * as resize from "./resize.ts"; import * as resolved_topic from "./resolved_topic.ts"; import * as rows from "./rows.ts"; import * as saved_snippets_ui from "./saved_snippets_ui.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 * as timerender from "./timerender.ts"; import * as typing from "./typing.ts"; import * as ui_report from "./ui_report.ts"; import * as upload from "./upload.ts"; import {the} from "./util.ts"; import * as util from "./util.ts"; // Stores the message ID of the message being edited, and the // textarea element which has the modified content. // Storing textarea makes it easy to get the current content. export const currently_editing_messages = new Map<number, JQuery<HTMLTextAreaElement>>(); const resized_edit_box_height = new Map<number, number>(); let currently_topic_editing_message_ids: number[] = []; const currently_echoing_messages = new Map<number, EchoedMessageData>(); type EchoedMessageData = { raw_content: string; orig_content: string; orig_raw_content: string; // Store flags that are about user interaction with the // message so that echo.edit_locally() can restore these // flags. starred: boolean; historical: boolean; collapsed: boolean; // These flags are rendering artifacts we'll want if the // edit fails and we need to revert to the original // rendering of the message. alerted: boolean; mentioned: boolean; mentioned_me_directly: boolean; }; // These variables are designed to preserve the user's most recent // choices when editing a group of messages, to make it convenient to // move several topics in a row with the same settings. export let notify_old_thread_default = false; export let notify_new_thread_default = true; export function is_topic_editable(message: Message, edit_limit_seconds_buffer = 0): boolean { if (!is_message_editable_ignoring_permissions(message)) { return false; } if (message.type !== "stream") { return false; } if (message.type === "stream" && stream_data.is_stream_archived_by_id(message.stream_id)) { return false; } // Cannot edit topics only in the channel with topics disabled. if (stream_data.is_empty_topic_only_channel(message.stream_id)) { return false; } const stream = stream_data.get_sub_by_id(message.stream_id); assert(stream !== undefined); if (!stream_data.user_can_move_messages_within_channel(stream)) { return false; } // Organization admins and moderators can edit message topics indefinitely, // irrespective of the topic editing deadline, if they are in the // can_move_messages_between_topics_group. if (current_user.is_moderator) { return true; } if (realm.realm_move_messages_within_stream_limit_seconds === null) { // This means no time limit for editing topics. return true; } // If you're using community topic editing, there's a deadline. return ( realm.realm_move_messages_within_stream_limit_seconds + edit_limit_seconds_buffer + (message.timestamp - Date.now() / 1000) > 0 ); } export function maybe_autosize_message_edit_box(): void { const message_ids = resized_edit_box_height.keys(); for (const message_id of message_ids) { const $edit_container = currently_editing_messages.get(message_id); if ($edit_container) { autosize($edit_container); } } resized_edit_box_height.clear(); } function is_widget_message(message: Message): boolean { if (message.submessages.length > 0) { return true; } return false; } export function is_message_editable_ignoring_permissions(message: Message): boolean { if (!message) { return false; } if (message.failed_request) { // TODO: For completely failed requests, we should be able // to "edit" the message, but it won't really be like // other message updates. This commit changed the result // from FULL to NO, since the prior implementation was // buggy. return false; } // Locally echoed messages are not editable, since the message hasn't // finished being sent yet. if (message.locally_echoed) { return false; } // Messages where we're currently locally echoing an edit not yet acknowledged // by the server. if (currently_echoing_messages.has(message.id)) { return false; } return true; } export function is_content_editable(message: Message, edit_limit_seconds_buffer = 0): boolean { if (!is_message_editable_ignoring_permissions(message)) { return false; } if (!realm.realm_allow_message_editing) { return false; } if (!message.sent_by_me) { return false; } if (is_widget_message(message)) { return false; } if (message.type === "stream" && stream_data.is_stream_archived_by_id(message.stream_id)) { return false; } if (realm.realm_message_content_edit_limit_seconds === null) { return true; } if ( realm.realm_message_content_edit_limit_seconds + edit_limit_seconds_buffer + (message.timestamp - Date.now() / 1000) > 0 ) { return true; } return false; } export function remaining_content_edit_time(message: Message): number { if (!is_content_editable(message)) { return 0; } const limit_seconds = realm.realm_message_content_edit_limit_seconds ?? Infinity; return limit_seconds + (message.timestamp - Date.now() / 1000); } export function is_stream_editable(message: Message, edit_limit_seconds_buffer = 0): boolean { if (!is_message_editable_ignoring_permissions(message)) { return false; } if (message.type !== "stream") { return false; } if (message.type === "stream" && stream_data.is_stream_archived_by_id(message.stream_id)) { return false; } const stream = stream_data.get_sub_by_id(message.stream_id); assert(stream !== undefined); if (!stream_data.user_can_move_messages_out_of_channel(stream)) { return false; } // Organization admins and moderators can edit stream indefinitely, // irrespective of the stream editing deadline, if they are in the // can_move_messages_between_channels_group. if (current_user.is_moderator) { return true; } if (realm.realm_move_messages_between_streams_limit_seconds === null) { // This means no time limit for editing streams. return true; } return ( realm.realm_move_messages_between_streams_limit_seconds + edit_limit_seconds_buffer + (message.timestamp - Date.now() / 1000) > 0 ); } export function can_move_message(message: Message): boolean { return is_topic_editable(message) || is_stream_editable(message); } export function remaining_message_move_time(message: Message): number { if (!can_move_message(message)) { return 0; } const limit_seconds = realm.realm_move_messages_within_stream_limit_seconds ?? Infinity; return limit_seconds + (message.timestamp - Date.now() / 1000); } export function stream_and_topic_exist_in_edit_history( message: Message, stream_id: number, topic: string, ): boolean { /* Checks to see if a stream_id and a topic match any historical stream_id and topic state in the message's edit history. Does not check the message's current stream_id and topic for a match to the stream_id and topic parameters. */ if (message.type !== "stream") { return false; } const narrow_dict = {stream_id, topic}; const message_dict = {stream_id: message.stream_id, topic: message.topic}; if (!message.edit_history) { // If message edit history is disabled in the organization, // the client does not have the information locally to answer // this question correctly. return false; } for (const edit_history_event of message.edit_history) { if (!edit_history_event.prev_stream && edit_history_event.prev_topic === undefined) { // Message was not moved in this edit event. continue; } if (edit_history_event.prev_stream) { // This edit event changed the stream. We expect the // following to be true due to the invariants of the edit // history data structure: // edit_history_event.stream === message_dict.stream_id message_dict.stream_id = edit_history_event.prev_stream; } if (edit_history_event.prev_topic !== undefined) { // This edit event changed the topic. We expect the // following to be true due to the invariants of the edit // history data structure: // util.lower_same(edit_history_event.topic, message_dict.topic) message_dict.topic = edit_history_event.prev_topic; } if (util.same_stream_and_topic(narrow_dict, message_dict)) { return true; } } return false; } export function hide_message_edit_spinner($row: JQuery): void { $row.find(".loader").hide(); $row.find(".message_edit_save span").removeClass("showing-button-spinner"); $row.find(".message_edit_save").removeClass("message-edit-button-disabled"); $row.find(".message_edit_cancel").removeClass("message-edit-button-disabled"); } export function show_message_edit_spinner($row: JQuery): void { // Always show the white spinner like we // do for send button in compose box. loading.show_button_spinner($row.find(".loader"), true); $row.find(".message_edit_save span").addClass("showing-button-spinner"); $row.find(".message_edit_save").addClass("message-edit-button-disabled"); $row.find(".message_edit_cancel").addClass("message-edit-button-disabled"); } export function show_topic_edit_spinner($row: JQuery): void { const $spinner = $row.find(".topic_edit_spinner"); loading.make_indicator($spinner); $spinner.css({height: ""}); $(".topic_edit_save").hide(); $(".topic_edit_cancel").hide(); $(".topic_edit_spinner").show(); } export function hide_topic_edit_spinner($row: JQuery): void { const $spinner = $row.find(".topic_edit_spinner"); loading.destroy_indicator($spinner); $spinner.css({height: ""}); $(".topic_edit_save").show(); $(".topic_edit_cancel").show(); $(".topic_edit_spinner").hide(); } export function end_if_focused_on_inline_topic_edit(): void { const $focused_elem = $(".topic_edit").find(":focus"); if ($focused_elem.length === 1) { $focused_elem.trigger("blur"); const $recipient_row = $focused_elem.closest(".recipient_row"); end_inline_topic_edit($recipient_row); } } export function end_if_focused_on_message_row_edit(): void { const $focused_elem = $(".message_edit").find(":focus"); if ($focused_elem.length === 1) { $focused_elem.trigger("blur"); const $row = $focused_elem.closest(".message_row"); end_message_row_edit($row); } } export function update_inline_topic_edit_ui(): void { // This function is called when // "realm_move_messages_within_stream_limit_seconds" setting is // changed. This is a rare event, so it's OK to be lazy and just // do a full rerender, even though the only thing we need to // change is the inline topic edit icons in recipient bars. message_live_update.rerender_messages_view(); } function handle_message_edit_enter( e: JQuery.KeyDownEvent, $message_edit_content: JQuery<HTMLTextAreaElement>, ): void { // Pressing Enter to save edits is coupled with Enter to send if (composebox_typeahead.should_enter_send(e)) { const $row = $message_edit_content.closest(".message_row"); const $message_edit_save_button = $row.find(".message_edit_save"); if ($message_edit_save_button.prop("disabled")) { // In cases when the save button is disabled // we need to disable save on pressing Enter // Prevent default to avoid new-line on pressing // Enter inside the textarea in this case e.preventDefault(); compose_validate.validate_message_length($row); return; } void save_message_row_edit($row); e.stopPropagation(); e.preventDefault(); } else { composebox_typeahead.handle_enter($message_edit_content, e); return; } } function handle_message_row_edit_escape(e: JQuery.KeyDownEvent): void { end_if_focused_on_message_row_edit(); e.stopPropagation(); e.preventDefault(); } function handle_inline_topic_edit_keydown( $form: JQuery, typeahead: Typeahead<string>, e: JQuery.KeyDownEvent, ): void { e.stopPropagation(); const $form_inline_input = $form.find<HTMLInputElement>("input.inline_topic_edit"); if ($form_inline_input.is(":focus") && keydown_util.is_enter_event(e)) { // Handle Enter key event in the inline topic edit UI. e.preventDefault(); if (typeahead.shown) { // Accepting a suggestion from the typeahead should not trigger a save. return; } const $recipient_row = $form.closest(".recipient_row"); try_save_inline_topic_edit($recipient_row); } else if (e.key === "Escape") { // Handle Escape key event in the inline topic edit UI. e.preventDefault(); end_if_focused_on_inline_topic_edit(); } } function update_inline_topic_edit_input_max_width( $inline_topic_edit_input: JQuery<HTMLInputElement>, ): void { // We use a hidden span element, which we update with the value // of the input field on every input change to calculate the // width of the topic value. This allows us to dynamically adjust // the max-width of the input field. const $topic_value_mirror = $inline_topic_edit_input .closest(".topic_edit_form") .find(".topic_value_mirror"); const input_value = $inline_topic_edit_input.val()!; $topic_value_mirror.text(input_value); const topic_width = $topic_value_mirror.width(); if (input_value.length > 0) { // When the user starts typing in the inline topic edit input field, // we dynamically adjust the max-width of the input field to match // width of the text in the input field + 1ch width for some cushion. $inline_topic_edit_input.css("max-width", `calc(${topic_width}px + 1ch)`); } else { // When the user deletes all the text in the inline topic edit input field, // we check if the input field has a placeholder and if it does, we set the // max-width of the input field to the length of the placeholder + 1ch // width for some cushion. const $placeholder = $inline_topic_edit_input .closest(".topic_edit_form") .find(".inline-topic-edit-placeholder"); if ($placeholder.length > 0) { const placeholder_width = $placeholder.width(); $inline_topic_edit_input.css("max-width", `calc(${placeholder_width}px + 1ch)`); } else { // Otherwise, we set the max-width to a reasonable 20ch width. $inline_topic_edit_input.css("max-width", "20ch"); } } } function handle_inline_topic_edit_change(elem: HTMLInputElement, stream_id: number): void { const $inline_topic_edit_input = $(elem); update_inline_topic_edit_input_max_width($inline_topic_edit_input); if ($inline_topic_edit_input.hasClass("invalid-input")) { // If invalid-input class is present on the inline topic edit // input field, remove it as soon as the user starts typing // as that probably means the user is trying to fix the error. $inline_topic_edit_input.removeClass("invalid-input"); } const $topic_edit_save_button = $inline_topic_edit_input .closest(".topic_edit_form") .find(".topic_edit_save"); if ( !stream_data.can_use_empty_topic(stream_id) && util.is_topic_name_considered_empty(elem.value) ) { // When the topic is mandatory in a realm and the new topic is considered empty, // we disable the save button and show a tooltip with an error message. $topic_edit_save_button.prop("disabled", true); $topic_edit_save_button.addClass("topic-required"); return; } $topic_edit_save_button.removeClass("topic-required"); if (!stream_data.can_create_new_topics_in_stream(stream_id)) { const topic_val = $inline_topic_edit_input.val()!; 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_val.trim().toLowerCase()) && stream_topic_history.has_history_for(stream_id) ) { $topic_edit_save_button.prop("disabled", true); $topic_edit_save_button .closest(".topic-edit-save-wrapper") .attr( "data-tippy-content", compose_validate.CANNOT_CREATE_NEW_TOPIC_TOOLTIP_MESSAGE, ); return; } } // If we reach here, it means the save button was disabled previously // and the user has started typing in the input field, probably to fix // the error. So, we re-enable the save button. $topic_edit_save_button.prop("disabled", false); $topic_edit_save_button.closest(".topic-edit-save-wrapper").removeAttr("data-tippy-content"); if (stream_data.can_use_empty_topic(stream_id)) { const $topic_not_mandatory_placeholder = $(".inline-topic-edit-placeholder"); $topic_not_mandatory_placeholder.toggleClass( "inline-topic-edit-placeholder-visible", $inline_topic_edit_input.val() === "", ); } } function timer_text(seconds_left: number): string { const minutes = Math.floor(seconds_left / 60); const seconds = seconds_left % 60; if (minutes >= 1) { return $t({defaultMessage: "{minutes} min to edit"}, {minutes: minutes.toString()}); } if (seconds_left >= 10) { return $t( {defaultMessage: "{seconds} sec to edit"}, {seconds: (seconds - (seconds % 5)).toString()}, ); } return $t({defaultMessage: "{seconds} sec to edit"}, {seconds: seconds.toString()}); } function create_copy_to_clipboard_handler( $row: JQuery, source: HTMLElement, $message_edit_content: JQuery, ): void { const clipboard = new ClipboardJS(source, { target: () => the($message_edit_content), }); clipboard.on("success", () => { // Hide the Tippy and source box after a 1000ms delay const tippy_timeout_in_ms = 1000; show_copied_confirmation(the($row.find(".copy_message")), { show_check_icon: true, timeout_in_ms: tippy_timeout_in_ms, on_hide_callback() { end_message_row_edit($row); }, }); }); } // We store manually resized edit box height in case events rerender // the message list and restore it. function store_resized_height(message_id: number): (height: number) => void { return (height) => { resized_edit_box_height.set(message_id, height); }; } function edit_message($row: JQuery, raw_content: string): void { // Open the message-edit UI for a given message. // // Notably, when switching views, this can be called for a row // that hasn't been added to the DOM yet so, keep all the selector // queries and events to operate on `$row` or `$form`. assert(message_lists.current !== undefined); const message = message_lists.current.get(rows.id($row)); assert(message !== undefined); condense.hide_message_length_toggle($row); // We potentially got to this function by clicking a button that implied the // user would be able to edit their message. Give a little bit of buffer in // case the button has been around for a bit, e.g. we show the // edit_content_button (hovering pencil icon) as long as the user would have // been able to click it at the time the mouse entered the message_row. Also // a buffer in case their computer is slow, or stalled for a second, etc // If you change this number also change edit_limit_buffer in // zerver.actions.message_edit.check_update_message const seconds_left_buffer = 5; const max_file_upload_size = realm.max_file_upload_size_mib; let file_upload_enabled = false; if (max_file_upload_size > 0 && upload.feature_check()) { file_upload_enabled = true; } const is_editable = is_content_editable(message, seconds_left_buffer); const $form = $( render_message_edit_form({ message_id: message.id, is_editable, content: raw_content, file_upload_enabled, giphy_enabled: gif_state.is_giphy_enabled(), tenor_enabled: gif_state.is_tenor_enabled(), minutes_to_edit: Math.floor((realm.realm_message_content_edit_limit_seconds ?? 0) / 60), max_message_length: realm.max_message_length, }), ); const $button_bar = $form.find(".compose-scrollable-buttons"); const $message_edit_content = $form.find<HTMLTextAreaElement>("textarea.message_edit_content"); assert($message_edit_content.length === 1); currently_editing_messages.set(message.id, $message_edit_content); const previous_height = resized_edit_box_height.get(message.id); const do_autosize = previous_height === undefined; message_lists.current.show_edit_message($row, $form, do_autosize); if (previous_height) { $(the($message_edit_content)).height(previous_height + "px"); } // Attach event handlers to `form` instead of `textarea` to allow // typeahead to call stopPropagation if it can handle the event // and prevent the form from submitting. $form.on("keydown", (e) => { if (e.target.classList.contains("message_edit_content") && keydown_util.is_enter_event(e)) { handle_message_edit_enter(e, $message_edit_content); } }); $form.on("keydown", (e) => { if (e.key === "Escape") { handle_message_row_edit_escape(e); } }); $form.on("input", () => { compose_validate.check_overflow_text($row); }); $form .find(".message-edit-feature-group .video_link") .toggle(compose_call.compute_show_video_chat_button()); $form .find(".message-edit-feature-group .audio_link") .toggle(compose_call.compute_show_audio_chat_button()); $button_bar.on( "scroll", _.throttle((e: JQuery.ScrollEvent) => { compose_ui.handle_scrolling_formatting_buttons(e); }, 150), ); const $message_edit_countdown_timer = $row.find(".message_edit_countdown_timer"); const $copy_message = $row.find(".copy_message"); if (!is_editable) { $message_edit_content.attr("readonly", "readonly"); create_copy_to_clipboard_handler($row, the($copy_message), $message_edit_content); } else { $copy_message.remove(); resize.watch_manual_resize_for_element( the($message_edit_content), store_resized_height(message.id), ); composebox_typeahead.initialize_compose_typeahead($message_edit_content); compose_ui.handle_keyup(null, $message_edit_content); $message_edit_content.on("keydown", (event) => { compose_ui.handle_keydown(event, $message_edit_content); }); $message_edit_content.on("keyup", (event) => { compose_ui.handle_keyup(event, $message_edit_content); }); compose_tooltips.initialize_compose_tooltips( `edit_message:${message.id}`, ".message_edit .compose_button_tooltip", ); } // Add tooltip and timer const realm_message_content_edit_limit_seconds = realm.realm_message_content_edit_limit_seconds ?? 0; if (is_editable && realm_message_content_edit_limit_seconds > 0) { $row.find(".message-edit-timer").show(); // Give them at least 10 seconds. // If you change this number also change edit_limit_buffer in // zerver.actions.message_edit.check_update_message const min_seconds_to_edit = 10; let seconds_left = realm_message_content_edit_limit_seconds + (message.timestamp - Date.now() / 1000); seconds_left = Math.floor(Math.max(seconds_left, min_seconds_to_edit)); // I believe this needs to be defined outside the countdown_timer, since // row just refers to something like the currently selected message, and // can change out from under us const $message_edit_save_container = $row.find(".message_edit_save_container"); const $message_edit_save = $row.find("button.message_edit_save"); // Do this right away, rather than waiting for the timer to do its first update, // since otherwise there is a noticeable lag $message_edit_countdown_timer.text(timer_text(seconds_left)); const countdown_timer = setInterval(() => { seconds_left -= 1; if (seconds_left <= 0) { clearInterval(countdown_timer); // We don't go directly to a "TOPIC_ONLY" type state (with an active Save button), // since it isn't clear what to do with the half-finished edit. It's nice to keep // the half-finished edit around so that they can copy-paste it, but we don't want // people to think "Save" will save the half-finished edit. $message_edit_save.prop("disabled", true); $message_edit_save_container.addClass("message-edit-time-limit-expired"); $message_edit_save_container.addClass("disabled-message-edit-save"); $message_edit_countdown_timer.addClass("expired"); $message_edit_countdown_timer.text($t({defaultMessage: "Time's up!"})); } else { $message_edit_countdown_timer.text(timer_text(seconds_left)); } }, 1000); } if (!is_editable) { $row.find(".message_edit_close").trigger("focus"); } else { $message_edit_content.trigger("focus"); // Put cursor at end of input. const contents = $message_edit_content.val(); $message_edit_content.val(""); if (contents) { $message_edit_content.val(contents); } compose_validate.check_overflow_text($row); } } function start_edit_maintaining_scroll($row: JQuery, content: string): void { // This function makes the bottom of the edit form visible, so // call this for cases where it is important to show the bottom // like showing error messages or upload status. edit_message($row, content); const row_bottom = $row.get_offset_to_window().bottom; const composebox_top = $("#compose").get_offset_to_window().top; if (row_bottom > composebox_top) { message_viewport.scrollTop(message_viewport.scrollTop() + row_bottom - composebox_top); } } function start_edit_with_content( $row: JQuery, content: string, edit_box_open_callback?: () => void, ): void { start_edit_maintaining_scroll($row, content); if (edit_box_open_callback) { edit_box_open_callback(); } const row_id = rows.id($row); upload.setup_upload(upload.edit_config(row_id)); // Setup dropdown for saved snippets button in the current // message edit control buttons tray. saved_snippets_ui.setup_saved_snippets_dropdown_widget( `.saved-snippets-message-edit-widget[data-message-id="${CSS.escape(row_id.toString())}"]`, ); } export function start($row: JQuery, edit_box_open_callback?: () => void): void { assert(message_lists.current !== undefined); const message = message_lists.current.get(rows.id($row)); if (message === undefined) { blueslip.error("Couldn't find message ID for edit", {row_id: rows.id($row)}); return; } if ($row.find(".message_edit_form form").length > 0) { return; } if (message.raw_content) { start_edit_with_content($row, message.raw_content, edit_box_open_callback); return; } const msg_list = message_lists.current; message_fetch_raw_content.get_raw_content_for_single_message({ message_id: message.id, on_success(raw_content) { if (message_lists.current === msg_list) { start_edit_with_content($row, raw_content, edit_box_open_callback); } }, on_error() { // do nothing }, }); } function show_toggle_resolve_topic_spinner($row: JQuery, topic_is_resolved: boolean): void { let $button: JQuery; if (topic_is_resolved) { // Since we don't show a separate unresolve topic button in the recipient bar, // this code path is only reached when the user unresolves a topic using the // "Mark as unresolved" option from the recipient bar topic actions menu. $button = $row.find(".on-hover-unresolve-loading-indicator").expectOne(); $button.removeClass("hide"); } else { $button = $row.find(".on_hover_topic_resolve").expectOne(); } $button.addClass("loading-resolve-topic-state"); // While we call the show_button_loading_indicator method to // show the spinner, we don't need to call the corresponding // hide_button_loading_indicator method later in the code // for a successful resolve request, as that results // in a rerender of the message feed which replaces the button. buttons.show_button_loading_indicator($button); } function get_resolve_topic_time_limit_error_string( time_limit: number, time_limit_unit: string, topic_is_resolved: boolean, ): string { if (topic_is_resolved) { if (time_limit_unit === "minute") { return $t( { defaultMessage: "You do not have permission to unresolve topics with messages older than {N, plural, one {# minute} other {# minutes}} in this organization.", }, {N: time_limit}, ); } if (time_limit_unit === "hour") { return $t( { defaultMessage: "You do not have permission to unresolve topics with messages older than {N, plural, one {# hour} other {# hours}} in this organization.", }, {N: time_limit}, ); } return $t( { defaultMessage: "You do not have permission to unresolve topics with messages older than {N, plural, one {# day} other {# days}} in this organization.", }, {N: time_limit}, ); } if (time_limit_unit === "minute") { return $t( { defaultMessage: "You do not have permission to resolve topics with messages older than {N, plural, one {# minute} other {# minutes}} in this organization.", }, {N: time_limit}, ); } if (time_limit_unit === "hour") { return $t( { defaultMessage: "You do not have permission to resolve topics with messages older than {N, plural, one {# hour} other {# hours}} in this organization.", }, {N: time_limit}, ); } return $t( { defaultMessage: "You do not have permission to resolve topics with messages older than {N, plural, one {# day} other {# days}} in this organization.", }, {N: time_limit}, ); } function handle_resolve_topic_failure_due_to_time_limit(topic_is_resolved: boolean): void { const time_limit_for_resolving_topic = timerender.get_time_limit_setting_in_appropriate_unit( realm.realm_move_messages_within_stream_limit_seconds ?? 0, ); const resolve_topic_time_limit_error_string = get_resolve_topic_time_limit_error_string( time_limit_for_resolving_topic.value, time_limit_for_resolving_topic.unit, topic_is_resolved, ); const modal_content_html = render_resolve_topic_time_limit_error_modal({ topic_is_resolved, resolve_topic_time_limit_error_string, }); let modal_heading; if (topic_is_resolved) { modal_heading = $t_html({defaultMessage: "Could not unresolve topic"}); } else { modal_heading = $t_html({defaultMessage: "Could not resolve topic"}); } dialog_widget.launch({ modal_title_html: modal_heading, modal_content_html, modal_submit_button_text: $t({defaultMessage: "Close"}), on_click() { /* Nothing extra needs to happen when the dialog is closed. */ }, single_footer_button: true, focus_submit_on_open: true, }); } function show_intro_resolve_topic_modal(topic_name: string, cb: () => void): void { confirm_dialog.launch({ modal_title_html: $t_html({defaultMessage: "Mark topic as resolved"}), modal_content_html: render_intro_resolve_topic_modal({topic_name}), id: "intro_resolve_topic_modal", on_click: cb, modal_submit_button_text: $t({defaultMessage: "Got it — Confirm"}), modal_exit_button_text: $t({defaultMessage: "Got it — Cancel"}), }); } export function toggle_resolve_topic( message_id: number, old_topic_name: string, report_errors_in_global_banner: boolean, $row?: JQuery, ): void { let new_topic_name; const topic_is_resolved = resolved_topic.is_resolved(old_topic_name); if (topic_is_resolved) { new_topic_name = resolved_topic.unresolve_name(old_topic_name); } else { new_topic_name = resolved_topic.resolve_name(old_topic_name); } if ( !topic_is_resolved && onboarding_steps.ONE_TIME_NOTICES_TO_DISPLAY.has("intro_resolve_topic") ) { show_intro_resolve_topic_modal(old_topic_name, () => { do_toggle_resolve_topic( message_id, new_topic_name, topic_is_resolved, report_errors_in_global_banner, $row, ); }); onboarding_steps.post_onboarding_step_as_read("intro_resolve_topic"); return; } do_toggle_resolve_topic( message_id, new_topic_name, topic_is_resolved, report_errors_in_global_banner, $row, ); } function do_toggle_resolve_topic( message_id: number, new_topic_name: string, topic_is_resolved: boolean, report_errors_in_global_banner: boolean, $row?: JQuery, ): void { if ($row) { show_toggle_resolve_topic_spinner($row, topic_is_resolved); } const request = { propagate_mode: "change_all", topic: new_topic_name, send_notification_to_old_thread: false, send_notification_to_new_thread: true, }; void channel.patch({ url: "/json/messages/" + message_id, data: request, error(xhr) { if ($row) { const $button = $row.find(".on_hover_topic_resolve"); buttons.hide_button_loading_indicator($button); $button.removeClass("loading-resolve-topic-state"); // Remove any existing tippy instance on the button. const reference: tippy.ReferenceElement = util.the($button); if (reference._tippy) { reference._tippy.destroy(); } const instance = tippy.default(util.the($button), { trigger: "manual", appendTo: () => document.body, onShow(instance) { instance.setContent( $t({defaultMessage: "Error: Could not resolve topic."}), ); }, }); // Manually trigger the error tooltip, and remove it after 2 seconds. instance.show(); setTimeout(() => { instance.destroy(); }, 2000); } if (xhr.responseJSON) { const {code} = z.object({code: z.string()}).parse(xhr.responseJSON); if (code === "MOVE_MESSAGES_TIME_LIMIT_EXCEEDED") { handle_resolve_topic_failure_due_to_time_limit(topic_is_resolved); return; } if (report_errors_in_global_banner) { const {msg} = z.object({msg: z.string()}).parse(xhr.responseJSON); ui_report.generic_embed_error(_.escape(msg), 3500); } } }, }); } export function start_inline_topic_edit($recipient_row: JQuery): void { assert(message_lists.current !== undefined); const msg_id = rows.id_for_recipient_row($recipient_row); const message = message_lists.current.get(msg_id); assert(message?.type === "stream"); const $form = $( render_topic_edit_form({ max_topic_length: realm.max_topic_length, is_mandatory_topics: !stream_data.can_use_empty_topic(message.stream_id), empty_string_topic_display_name: util.get_final_topic_display_name(""), }), ); message_lists.current.show_edit_topic_on_recipient_row($recipient_row, $form); $(".topic_edit_spinner").hide(); const topic = message.topic; const $inline_topic_edit_input = $form.find<HTMLInputElement>("input.inline_topic_edit"); $inline_topic_edit_input.val(topic).trigger("select").trigger("focus"); update_inline_topic_edit_input_max_width($inline_topic_edit_input); const stream_name = stream_data.get_stream_name_from_id(message.stream_id); const typeahead = composebox_typeahead.initialize_topic_edit_typeahead( $inline_topic_edit_input, stream_name, false, ); $form.on("keydown", (event) => { handle_inline_topic_edit_keydown($form, typeahead, event); }); $inline_topic_edit_input.on("input", function (this: HTMLInputElement) { handle_inline_topic_edit_change(this, message.stream_id); }); if (stream_data.can_use_empty_topic(message.stream_id)) { const $topic_not_mandatory_placeholder = $(".inline-topic-edit-placeholder"); if (topic === "") { $topic_not_mandatory_placeholder.addClass("inline-topic-edit-placeholder-visible"); } $inline_topic_edit_input.on("blur", () => { if ($inline_topic_edit_input.val() === "") { $topic_not_mandatory_placeholder.removeClass( "inline-topic-edit-placeholder-visible", ); $inline_topic_edit_input.attr("placeholder", util.get_final_topic_display_name("")); $inline_topic_edit_input.addClass("empty-topic-display"); } }); $inline_topic_edit_input.on("focus", () => { if ($inline_topic_edit_input.val() === "") { $inline_topic_edit_input.attr("placeholder", ""); $inline_topic_edit_input.removeClass("empty-topic-display"); $topic_not_mandatory_placeholder.addClass("inline-topic-edit-placeholder-visible"); } }); } } export function end_inline_topic_edit($row: JQuery): void { assert(message_lists.current !== undefined); message_lists.current.hide_edit_topic_on_recipient_row($row); } export function end_message_row_edit($row: JQuery): void { assert(message_lists.current !== undefined); const row_id = rows.id($row); // Clean up the upload handler upload.deactivate_upload(upload.edit_config(row_id)); // Check if the row is in preview mode, and clear the preview area if it is. if ($row.hasClass("preview_mode")) { clear_preview_area($row); } const message = message_lists.current.get(row_id); if (message !== undefined && currently_editing_messages.has(message.id)) { typing.stop_message_edit_notifications(message.id); currently_editing_messages.delete(message.id); resized_edit_box_height.delete(message.id); message_lists.current.hide_edit_message($row); compose_call_session_manager.abandon_session(message.id.toString()); } if ($row.find(".could-be-condensed").length > 0) { if ($row.find(".condensed").length > 0) { condense.show_message_expander($row); } else { condense.show_message_condenser($row); } } // We have to blur out text fields, or else hotkeys.js // thinks we are still editing. $row.find(".message_edit").trigger("blur"); // We should hide the editing typeahead if it is visible $row.find("input.message_edit_topic").trigger("blur"); // Hide the edit box tooltips compose_tooltips.clean_up_compose_singleton_tooltip( `edit_message:${$row.attr("data-message-id")}`, ); } export function end_message_edit(message_id: number): void { const $row = message_lists.current?.get_row(message_id); if ($row !== undefined && $row.length > 0) { end_message_row_edit($row); } else if (currently_editing_messages.has(message_id)) { // We should delete the message_id from currently_editing_messages // if it exists there but we cannot find the row. currently_editing_messages.delete(message_id); } } export function try_save_inline_topic_edit($row: JQuery): void { assert(message_lists.current !== undefined); const message_id = rows.id_for_recipient_row($row); const message = message_lists.current.get(message_id); assert(message?.type === "stream"); const old_topic = message.topic; const $inline_topic_edit_input = $row.find<HTMLInputElement>("input.inline_topic_edit"); const new_topic = $inline_topic_edit_input.val()?.trim(); assert(new_topic !== undefined); const topic_changed = new_topic !== old_topic; if (!topic_changed) { // this means the inline_topic_edit was opened and submitted without // changing anything, therefore, we should just close the inline topic edit. end_inline_topic_edit($row); return; } if ( !stream_data.can_use_empty_topic(message.stream_id) && util.is_topic_name_considered_empty(new_topic) ) { // When the topic is mandatory in a realm and the new topic is considered // empty, we don't allow the user to save the topic. Instead, we show the // error visually via the invalid-input class and focus on the input field. $inline_topic_edit_input.addClass("invalid-input"); $inline_topic_edit_input.trigger("focus"); return; } const $message_header = $row.find(".message_header").expectOne(); const stream_id = Number.parseInt($message_header.attr("data-stream-id")!, 10); const stream_topics = stream_topic_history.get_recent_topic_names(stream_id); if (stream_topics.includes(new_topic)) { confirm_dialog.launch({ modal_title_html: $t_html({defaultMessage: "Merge with another topic?"}), modal_content_html: render_confirm_merge_topics_with_rename({ topic_display_name: util.get_final_topic_display_name(new_topic), is_empty_string_topic: new_topic === "", }), on_click() { do_save_inline_topic_edit($row, message, new_topic); }, }); } else { do_save_inline_topic_edit($row, message, new_topic); } } export function do_save_inline_topic_edit($row: JQuery, message: Message, new_topic: string): void { show_topic_edit_spinner($row); if (message.locally_echoed) { echo.edit_locally(message, {new_topic}); assert(message_lists.current !== undefined); $row = message_lists.current.get_row(message.id); end_inline_topic_edit($row); return; } const request = { topic: new_topic, propagate_mode: "change_all", send_notification_to_old_thread: false, send_notification_to_new_thread: false, }; void channel.patch({ url: "/json/messages/" + message.id, data: request, success() { const $spinner = $row.find(".topic_edit_spinner"); loading.destroy_indicator($spinner); }, error(xhr) { const $spinner = $row.find(".topic_edit_spinner"); if (xhr.responseJSON === undefined) { return; } const {code} = z.object({code: z.string()}).parse(xhr.responseJSON); if (code === "MOVE_MESSAGES_TIME_LIMIT_EXCEEDED") { const {first_message_id_allowed_to_move} = z .object({first_message_id_allowed_to_move: z.number()}) .parse(xhr.responseJSON); const send_notification_to_old_thread = false; const send_notification_to_new_thread = false; // We are not changing stream in this UI. const new_stream_id = undefined; function handle_confirm(): void { move_topic_containing_message_to_stream( first_message_id_allowed_to_move, new_stream_id, new_topic, send_notification_to_new_thread, send_notification_to_old_thread, "change_later", ); } const on_hide_callback = (): void => { loading.destroy_indicator($spinner); end_inline_topic_edit($row); }; handle_message_move_failure_due_to_time_limit( xhr, handle_confirm, on_hide_callback, ); return; } hide_topic_edit_spinner($row); const message = channel.xhr_error_message($t({defaultMessage: "Failed"}), xhr); $row.find(".topic_edit_save").prop("disabled", true); $row.find(".topic-edit-save-wrapper").attr("data-tippy-content", message); }, }); } export async function save_message_row_edit($row: JQuery): Promise<void> { compose_tooltips.hide_compose_control_button_tooltips($row); assert(message_lists.current !== undefined); const $banner_container = compose_banner.get_compose_banner_container( $row.find(".message_edit_form textarea"), ); let stream_id: number | undefined; const stream_id_data = rows.get_message_recipient_header($row).attr("data-stream-id"); if (stream_id_data !== undefined) { stream_id = Number.parseInt(stream_id_data, 10); } const msg_list = message_lists.current; let message_id = rows.id($row); const message = message_lists.current.get(message_id); assert(message !== undefined); let changed = false; let edit_locally_echoed = false; let new_content; const old_content = message.raw_content; assert(old_content !== undefined); const $edit_content_input = $row.find<HTMLTextAreaElement>("textarea.message_edit_content"); const can_edit_content = $edit_content_input.attr("readonly") !== "readonly"; if (can_edit_content) { new_content = $edit_content_input.val(); changed = old_content !== new_content; } const already_has_stream_wildcard_mention = message.stream_wildcard_mentioned; if (stream_id !== undefined && !already_has_stream_wildcard_mention) { const stream_wildcard_mention = util.find_stream_wildcard_mentions(new_content ?? ""); const is_stream_message_mentions_valid = compose_validate.validate_stream_message_mentions({ stream_id, $banner_container, stream_wildcard_mention, scheduling_message: false, }); if (!is_stream_message_mentions_valid) { return; } } show_message_edit_spinner($row); // Editing a not-yet-acked message (because the original send attempt failed) // just results in the in-memory message being changed if (message.locally_echoed) { if (new_content !== message.raw_content) { // `edit_locally` handles the case where `new_topic/new_stream_id` is undefined echo.edit_locally(message, { raw_content: new_content, }); $row = message_lists.current.get_row(message_id); } end_message_row_edit($row); return; } if (!changed) { // If they didn't change anything, just cancel it. end_message_row_edit($row); return; } const request = { content: new_content, prev_content_sha256: await util.sha256_hash(old_content), }; if (!markdown.contains_backend_only_syntax(new_content ?? "")) { // If the new message content could have been locally echoed, // than we can locally echo the edit. currently_echoing_messages.set(message_id, { raw_content: new_content ?? "", orig_content: message.content, orig_raw_content: message.raw_content ?? "", starred: message.starred, historical: message.historical, collapsed: message.collapsed, alerted: message.alerted, mentioned: message.mentioned, mentioned_me_directly: message.mentioned_me_directly, }); edit_locally_echoed = true; // Settings these attributes causes a "SAVING" notice to // briefly appear where "EDITED" would normally appear until // the message is acknowledged by the server. message.local_edit_timestamp = Math.round(Date.now() / 1000); echo.edit_locally(message, currently_echoing_messages.get(message_id)!); $row = message_lists.current.get_row(message_id); end_message_row_edit($row); } assert(message !== undefined); void channel.patch({ url: "/json/messages/" + message.id, data: request, success(res) { if (edit_locally_echoed) { delete message.local_edit_timestamp; currently_echoing_messages.delete(message_id); } // Ordinarily, in a code path like this, we'd make // a call to `hide_message_edit_spinner()`. But in // this instance, we want to avoid a momentary flash // of the Save button text before the edited message // re-renders. Note that any subsequent editing will // create a fresh Save button, without the spinner // class attached. const {detached_uploads} = detached_uploads_api_response_schema.parse(res); if (detached_uploads.length > 0) { attachments_ui.suggest_delete_detached_attachments(detached_uploads); } }, error(xhr) { if (msg_list === message_lists.current) { message_id = rows.id($row); if (edit_locally_echoed) { const echoed_message = message_store.get(message_id); assert(echoed_message !== undefined); const echo_data = currently_echoing_messages.get(message_id); assert(echo_data !== undefined); delete echoed_message.local_edit_timestamp; currently_echoing_messages.delete(message_id); // Restore the original content. echo.edit_locally(echoed_message, { content: echo_data.orig_content, raw_content: echo_data.orig_raw_content, mentioned: echo_data.mentioned, mentioned_me_directly: echo_data.mentioned_me_directly, alerted: echo_data.alerted, }); $row = message_lists.current.get_row(message_id); if (!currently_editing_messages.has(message_id)) { // Return to the message editing open UI state with the edited content. start_edit_maintaining_scroll($row, echo_data.raw_content); } } hide_message_edit_spinner($row); if (xhr.readyState !== 0) { const $container = compose_banner.get_compose_banner_container( $row.find("textarea"), ); if (xhr.responseJSON !== undefined) { const {code} = z.object({code: z.string()}).parse(xhr.responseJSON); if (code === "TOPIC_WILDCARD_MENTION_NOT_ALLOWED") { const new_row_html = render_wildcard_mention_not_allowed_error({ banner_type: compose_banner.ERROR, classname: compose_banner.CLASSNAMES.wildcards_not_allowed, }); compose_banner.append_compose_banner_to_banner_list( $(new_row_html), $container, ); return; } if (code === "EXPECTATION_MISMATCH") { const message = $t({ defaultMessage: "Error editing message: Message was edited by another client.", }); compose_banner.show_error_message( message, compose_banner.CLASSNAMES.generic_compose_error, $container, ); return; } } const message = channel.xhr_error_message( $t({defaultMessage: "Error editing message"}), xhr, ); compose_banner.show_error_message( message, compose_banner.CLASSNAMES.generic_compose_error, $container, ); } } }, }); // The message will automatically get replaced via message_events.update_messages. } export function maybe_show_edit($row: JQuery, id: number): void { if (message_lists.current === undefined) { return; } if (currently_editing_messages.has(id)) { const $message_edit_content = currently_editing_messages.get(id); edit_message($row, $message_edit_content?.val() ?? ""); } } function warn_user_about_unread_msgs(last_sent_msg_id: number, num_unread: number): void { confirm_dialog.launch({ modal_title_html: $t({defaultMessage: "Edit your last message?"}), modal_content_html: render_confirm_edit_messages({ num_unread, }), is_compact: true, on_click() { // Select the message we want to edit to mark messages between it and the // current selected id as read. message_lists.current?.select_id(last_sent_msg_id, { then_scroll: true, }); edit_last_sent_message(); }, }); } export function edit_last_sent_message(): void { if (message_lists.current === undefined) { return; } const last_sent_msg = message_lists.current.get_last_message_sent_by_me(); if (!last_sent_msg) { return; } if (!last_sent_msg.id) { blueslip.error("Message has invalid id in edit_last_sent_message."); return; } if (!is_content_editable(last_sent_msg, 5)) { return; } const current_selected_msg = message_store.get(message_lists.current.selected_id()); if ( current_selected_msg && current_selected_msg.id < last_sent_msg.id && message_lists.current.can_mark_messages_read() ) { // If there are any unread messages between the selected message and the // message we want to edit, we don't edit the last sent message to avoid // marking messages as read unintentionally. let num_unread = 0; for (const msg of message_lists.current.all_messages()) { if (current_selected_msg.id < msg.id && msg.id < last_sent_msg.id && msg.unread) { num_unread += 1; } } if (num_unread > 0) { warn_user_about_unread_msgs(last_sent_msg.id, num_unread); return; } } message_lists.current.select_id(last_sent_msg.id, {then_scroll: true}); const $msg_row = message_lists.current.get_row(last_sent_msg.id); if (!$msg_row) { // This should never happen, since we got the message above // from message_lists.current. blueslip.error("Could not find row for id", {msg_id: last_sent_msg.id}); return; } // Finally do the real work! compose_actions.cancel(); start($msg_row, () => { $(".message_edit_content").trigger("focus"); }); } export function restore_edit_state_after_message_view_change(): void { assert(message_lists.current !== undefined); for (const [idx, $content] of currently_editing_messages) { if (message_lists.current.get(idx) !== undefined) { const $row = message_lists.current.get_row(idx); edit_message($row, $content.val() ?? ""); } } } function handle_message_move_failure_due_to_time_limit( xhr: JQuery.jqXHR, handle_confirm: (e: JQuery.ClickEvent) => void, on_hide_callback?: () => void, ): void { const {total_messages_allowed_to_move, total_messages_in_topic} = z .object({ total_messages_allowed_to_move: z.number(), total_messages_in_topic: z.number(), }) .parse(xhr.responseJSON); const messages_allowed_to_move_text = $t( { defaultMessage: "Do you still want to move the latest {total_messages_allowed_to_move, plural, one {message} other {# messages}}?", }, {total_messages_allowed_to_move}, ); const messages_not_allowed_to_move_text = $t( { defaultMessage: "{messages_not_allowed_to_move, plural, one {# message} other {# messages}} will remain in the current topic.", }, { messages_not_allowed_to_move: total_messages_in_topic - total_messages_allowed_to_move, }, ); const modal_content_html = render_confirm_moving_messages_modal({ messages_allowed_to_move_text, messages_not_allowed_to_move_text, }); confirm_dialog.launch({ modal_title_html: $t_html({defaultMessage: "Move some messages?"}), modal_content_html, on_click: handle_confirm, loading_spinner: true, ...(on_hide_callback !== undefined && {on_hide: on_hide_callback}), }); } type ToastParams = { new_stream_id: number; new_topic_name: string; }; function show_message_moved_toast(toast_params: ToastParams): void { const new_stream = sub_store.get(toast_params.new_stream_id); if (new_stream === undefined) { blueslip.error("Cannot find stream for message moved toast", { stream_id: toast_params.new_stream_id, }); return; } const new_topic_display_name = util.get_final_topic_display_name(toast_params.new_topic_name); const is_empty_string_topic = toast_params.new_topic_name === ""; const new_location_url = hash_util.by_stream_topic_url( toast_params.new_stream_id, toast_params.new_topic_name, ); feedback_widget.show({ populate($container) { const widget_body_html = render_message_moved_widget_body({ stream: new_stream, new_topic_display_name, new_location_url, is_empty_string_topic, }); $container.html(widget_body_html); }, title_text: $t({defaultMessage: "Message moved"}), }); } export function move_topic_containing_message_to_stream( message_id: number, new_stream_id: number | undefined, new_topic_name: string | undefined, send_notification_to_new_thread: boolean, send_notification_to_old_thread: boolean, propagate_mode: string, toast_params?: ToastParams, ): void { function reset_modal_ui(): void { currently_topic_editing_message_ids = currently_topic_editing_message_ids.filter( (id) => id !== message_id, ); dialog_widget.hide_dialog_spinner(); } if (currently_topic_editing_message_ids.includes(message_id)) { ui_report.client_error( $t_html({defaultMessage: "A topic move is already in progress."}), $("#move_topic_modal #dialog_error"), ); return; } currently_topic_editing_message_ids.push(message_id); const request = { stream_id: new_stream_id, propagate_mode, topic: new_topic_name, send_notification_to_old_thread, send_notification_to_new_thread, }; notify_old_thread_default = send_notification_to_old_thread; notify_new_thread_default = send_notification_to_new_thread; void channel.patch({ url: "/json/messages/" + message_id, data: request, success(): void { // The main UI will update via receiving the event // from server_events.js. reset_modal_ui(); dialog_widget.close(); if (toast_params) { show_message_moved_toast(toast_params); } }, error(xhr): void { reset_modal_ui(); if (xhr.responseJSON !== undefined) { const {code} = z.object({code: z.string()}).parse(xhr.responseJSON); if (code === "MOVE_MESSAGES_TIME_LIMIT_EXCEEDED") { const {first_message_id_allowed_to_move} = z .object({first_message_id_allowed_to_move: z.number()}) .parse(xhr.responseJSON); function handle_confirm(): void { move_topic_containing_message_to_stream( first_message_id_allowed_to_move, new_stream_id, new_topic_name, send_notification_to_new_thread, send_notification_to_old_thread, "change_later", ); } const partial_move_confirmation_modal_callback = (): void => { handle_message_move_failure_due_to_time_limit(xhr, handle_confirm); }; dialog_widget.close(partial_move_confirmation_modal_callback); return; } } ui_report.error($t_html({defaultMessage: "Failed"}), xhr, $("#dialog_error")); }, }); } export function with_first_message_id( stream_id: number, topic_name: string, success_cb: (message_id: number | undefined) => void, error_cb?: (xhr: JQuery.jqXHR) => void, ): void { // The API endpoint for editing messages to change their // content, topic, or stream requires a message ID. // // Because we don't have full data in the browser client, it's // possible that we might display a topic in the left sidebar // (and thus expose the UI for moving its topic to another // stream) without having a message ID that is definitely // within the topic. (The comments in stream_topic_history.ts // discuss the tricky issues around message deletion that are // involved here). // // To ensure this option works reliably at a small latency // cost for a rare operation, we just ask the server for the // latest message ID in the topic. const data = { anchor: "newest", num_before: 1, num_after: 0, narrow: JSON.stringify([ {operator: "channel", operand: stream_id}, {operator: "topic", operand: topic_name}, ]), allow_empty_topic_name: true, }; void channel.get({ url: "/json/messages", data, success(data) { const {messages} = z .object({messages: z.array(z.object({id: z.number()}))}) .parse(data); const message_id = messages[0]?.id; success_cb(message_id); }, error: error_cb ?? (() => { /* By default do nothing */ }), }); } export function is_message_oldest_or_newest( stream_id: number, topic_name: string, message_id: number, success_callback: (is_oldest: boolean, is_newest: boolean) => void, error_callback?: (xhr: JQuery.jqXHR) => void, ): void { const data = { anchor: message_id, num_before: 1, num_after: 1, narrow: JSON.stringify([ {operator: "channel", operand: stream_id}, {operator: "topic", operand: topic_name}, ]), allow_empty_topic_name: true, }; void channel.get({ url: "/json/messages", data, success(data) { const {messages} = z .object({messages: z.array(z.object({id: z.number()}))}) .parse(data); let is_oldest = true; let is_newest = true; for (const message of messages) { if (message.id < message_id) { is_oldest = false; } else if (message.id > message_id) { is_newest = false; } } success_callback(is_oldest, is_newest); }, error: error_callback ?? (() => { /* By default do nothing */ }), }); } export function show_preview_area($element: JQuery): void { const $row = rows.get_closest_row($element); compose_ui.enter_preview_mode($row); render_preview_area($row); } export function render_preview_area($row: JQuery): void { const $msg_edit_content = $row.find<HTMLTextAreaElement>("textarea.message_edit_content"); const content = $msg_edit_content.val(); assert(content !== undefined); const $preview_message_area = $row.find(".preview_message_area"); compose_ui.render_and_show_preview( $row, $row.find(".markdown_preview_spinner"), $row.find(".preview_content"), content, ); const edit_height = $msg_edit_content.height(); $preview_message_area.css({"min-height": edit_height + "px"}); $preview_message_area.show(); } export function clear_preview_area($element: JQuery): void { const $row = rows.get_closest_row($element); compose_ui.exit_preview_mode($row); }