/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/upload.ts
899 строк
33 KB
Anders Kaseorg
eslint: Fix unicorn/no-useless-promise-resolve-reject.
29 июл 2026, 02:35
29 июл 2026, 02:35
dd1e371
Код
Авторство
О чём код?
import type {Meta} from "@uppy/core"; import {Uppy} from "@uppy/core"; import Tus, {type TusBody} from "@uppy/tus"; import {$} from "jquery"; import assert from "minimalistic-assert"; import * as z from "zod/mini"; import render_upload_banner from "../templates/compose_banner/upload_banner.hbs"; import * as blueslip from "./blueslip.ts"; import * as compose_actions from "./compose_actions.ts"; import * as compose_banner from "./compose_banner.ts"; import * as compose_recipient from "./compose_recipient.ts"; import * as compose_reply from "./compose_reply.ts"; import * as compose_state from "./compose_state.ts"; import * as compose_ui from "./compose_ui.ts"; import * as compose_validate from "./compose_validate.ts"; import {$t} from "./i18n.ts"; import * as message_lists from "./message_lists.ts"; import * as rows from "./rows.ts"; import {realm} from "./state_data.ts"; // The server's response for a successful upload. type UploadResult = { filename: string; url: string; }; let drag_drop_img: HTMLElement | null = null; let compose_upload_object: Uppy<Meta, TusBody>; const upload_objects_by_message_edit_row = new Map<number, Uppy<Meta, TusBody>>(); // This list should be kept identical to the one defined as // THUMBNAIL_ACCEPT_IMAGE_TYPES in zerver/lib/thumbnail.py // "Supported" in this context means _by the server_ -- uploaded // images are transcoded into more widely supported formats by the server. export const SUPPORTED_IMAGE_TYPES = new Set([ "image/avif", "image/gif", "image/heic", "image/jpeg", "image/png", "image/tiff", "image/webp", ]); export function is_supported_image_type(file_type: string): boolean { return SUPPORTED_IMAGE_TYPES.has(file_type); } // This list should be kept identical to the one defined as // AUDIO_INLINE_MIME_TYPES defined in zerver/lib/mime_types.py const SUPPORTED_AUDIO_TYPES = new Set([ "audio/aac", "audio/flac", "audio/mp4", "audio/mpeg", "audio/vnd.wave", "audio/wav", "audio/webm", "audio/x-wav", ]); function is_supported_audio_type(file_type: string): boolean { return SUPPORTED_AUDIO_TYPES.has(file_type); } export function compose_upload_cancel(): void { compose_upload_object.cancelAll(); } export function feature_check(): XMLHttpRequestUpload { // Show the upload button only if the browser supports it. return window.XMLHttpRequest && new window.XMLHttpRequest().upload; } export function get_translated_status(filename: string): string { const status = $t({defaultMessage: "Uploading {filename}…"}, {filename}); return "[" + status + "]()"; } function contains_folder(data_transfer: DataTransfer): boolean { if (!data_transfer.items) { return false; } for (const item of data_transfer.items) { if (item.kind !== "file") { continue; } // https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry // Note: This function is implemented as webkitGetAsEntry() in non-WebKit // browsers including Firefox at this time; it may be renamed to getAsEntry() // in the future, so you should code defensively, looking for both. // @ts-expect-error -- getAsEntry/webkitGetAsEntry not in lib.dom.d.ts yet // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const entry = item.getAsEntry?.() ?? item.webkitGetAsEntry?.(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (entry?.isDirectory) { return true; } } return false; } function show_folder_upload_error(config: Config): void { show_error_message( config, $t({ defaultMessage: "Folders can't be uploaded. Instead, please upload the files you need.", }), ); } type Config = ({mode: "compose"} | {mode: "edit"; row: number}) & { textarea: () => JQuery<HTMLTextAreaElement>; send_button: () => JQuery; banner_container: () => JQuery; upload_banner_identifier: (file_id: string) => string; upload_banner: (file_id: string) => JQuery; upload_banner_cancel_button: (file_id: string) => JQuery; upload_banner_hide_button: (file_id: string) => JQuery; upload_banner_message: (file_id: string) => JQuery; file_input_identifier: () => string; source: () => string; drag_drop_container: () => JQuery; markdown_preview_hide_button: () => JQuery; }; export const compose_config: Config = { mode: "compose", textarea: () => $<HTMLTextAreaElement>("textarea#compose-textarea"), send_button: () => $("#compose-send-button"), banner_container: () => $("#compose_banners"), upload_banner_identifier: (file_id) => `#compose_banners .upload_banner.file_${CSS.escape(file_id)}`, upload_banner: (file_id) => $(`#compose_banners .upload_banner.file_${CSS.escape(file_id)}`), upload_banner_cancel_button: (file_id) => $( `#compose_banners .upload_banner.file_${CSS.escape( file_id, )} .upload_banner_cancel_button`, ), upload_banner_hide_button: (file_id) => $( `#compose_banners .upload_banner.file_${CSS.escape( file_id, )} .main-view-banner-close-button`, ), upload_banner_message: (file_id) => $(`#compose_banners .upload_banner.file_${CSS.escape(file_id)} .upload_msg`), file_input_identifier: () => "#compose input.file_input", source: () => "compose-file-input", drag_drop_container: () => $("#compose"), markdown_preview_hide_button: () => $("#compose .undo_markdown_preview"), }; export function edit_config(row: number): Config { return { mode: "edit", row, textarea: () => $<HTMLTextAreaElement>( `#edit_form_${CSS.escape(`${row}`)} textarea.message_edit_content`, ), send_button: () => $(`#edit_form_${CSS.escape(`${row}`)}`).find(".message_edit_save"), banner_container: () => $(`#edit_form_${CSS.escape(`${row}`)} .edit_form_banners`), upload_banner_identifier: (file_id) => `#edit_form_${CSS.escape(`${row}`)} .upload_banner.file_${CSS.escape(file_id)}`, upload_banner: (file_id) => $(`#edit_form_${CSS.escape(`${row}`)} .upload_banner.file_${CSS.escape(file_id)}`), upload_banner_cancel_button: (file_id) => $( `#edit_form_${CSS.escape(`${row}`)} .upload_banner.file_${CSS.escape( file_id, )} .upload_banner_cancel_button`, ), upload_banner_hide_button: (file_id) => $( `#edit_form_${CSS.escape(`${row}`)} .upload_banner.file_${CSS.escape( file_id, )} .main-view-banner-close-button`, ), upload_banner_message: (file_id) => $( `#edit_form_${CSS.escape(`${row}`)} .upload_banner.file_${CSS.escape( file_id, )} .upload_msg`, ), file_input_identifier: () => `#edit_form_${CSS.escape(`${row}`)} input.file_input`, source: () => "message-edit-file-input", drag_drop_container() { assert(message_lists.current !== undefined); return $( `#message-row-${message_lists.current.id}-${CSS.escape(`${row}`)} .message_edit_form`, ); }, markdown_preview_hide_button: () => $(`#edit_form_${CSS.escape(`${row}`)} .undo_markdown_preview`), }; } export let hide_upload_banner = ( uppy: Uppy<Meta, TusBody>, config: Config, file_id: string, delay = 0, ): void => { if (delay > 0) { setTimeout(() => { config.upload_banner(file_id).remove(); }, delay); } else { config.upload_banner(file_id).remove(); } // Allow sending the message if all uploads are complete or cancelled. if ( uppy.getFiles().every((e) => e.progress.uploadComplete) || Object.keys(uppy.getState().currentUploads).length === 0 ) { if (config.mode === "compose") { compose_validate.set_upload_in_progress(false); } else { config.send_button().prop("disabled", false); } } }; export function rewire_hide_upload_banner(value: typeof hide_upload_banner): void { hide_upload_banner = value; } function add_upload_banner( config: Config, banner_type: string, banner_text: string, file_id: string, is_upload_process_tracker = false, ): void { const new_banner_html = render_upload_banner({ banner_type, is_upload_process_tracker, banner_text, file_id, }); compose_banner.append_compose_banner_to_banner_list( $(new_banner_html), config.banner_container(), ); } export function show_error_message( config: Config, message = $t({defaultMessage: "An unknown error occurred."}), file_id: string | null = null, ): void { if (file_id) { $(`${config.upload_banner_identifier(file_id)} .moving_bar`).hide(); config.upload_banner(file_id).removeClass("info").addClass("error"); config.upload_banner_message(file_id).text(message); } else { // We still use a "file_id" (that's not actually related to a file) // to differentiate this banner from banners that *are* associated // with files. This is notably relevant for the close click handler. add_upload_banner(config, "error", message, "generic_error"); } } export let upload_files = ( uppy: Uppy<Meta, TusBody>, config: Config, files: File[] | FileList, ): void => { if (files.length === 0) { return; } // A new upload attempt supersedes any error banner (e.g. "file too // large") left over from a previous attempt, so we clear those here. config.banner_container().find(".upload_banner.error").remove(); if (realm.max_file_upload_size_mib === 0) { show_error_message( config, $t({ defaultMessage: "File and image uploads have been disabled for this organization.", }), ); return; } // If we're looking at a markdown preview, switch back to the edit // UI. This is important for all the later logic around focus // (etc.) to work correctly. // // We implement this transition through triggering a click on the // toggle button to take advantage of the existing plumbing for // handling the compose and edit UIs. if (config.markdown_preview_hide_button().css("display") !== "none") { config.markdown_preview_hide_button().trigger("click"); } for (const file of files) { let file_id; try { compose_ui.insert_syntax_and_focus( get_translated_status(file.name), config.textarea(), "block", 1, ); compose_ui.autosize_textarea(config.textarea()); file_id = uppy.addFile({ source: config.source(), name: file.name, type: file.type, data: file, }); } catch { // Errors are handled by info-visible and upload-error event callbacks. continue; } if (config.mode === "compose") { compose_validate.set_upload_in_progress(true); } else { config.send_button().prop("disabled", true); } add_upload_banner( config, "info", $t({defaultMessage: "Uploading {filename}…"}, {filename: file.name}), file_id, true, ); // eslint-disable-next-line @typescript-eslint/no-loop-func config.upload_banner_cancel_button(file_id).on("click", () => { compose_ui.set_prevent_next_spinner(true); compose_ui.replace_syntax(get_translated_status(file.name), "", config.textarea()); compose_ui.set_prevent_next_spinner(false); compose_ui.autosize_textarea(config.textarea()); config.textarea().trigger("focus"); uppy.removeFile(file_id); hide_upload_banner(uppy, config, file_id); }); // eslint-disable-next-line @typescript-eslint/no-loop-func config.upload_banner_hide_button(file_id).on("click", () => { hide_upload_banner(uppy, config, file_id); }); } }; export function rewire_upload_files(value: typeof upload_files): void { upload_files = value; } export function upload_pasted_file(textarea: HTMLTextAreaElement, pasted_file: File): void { if (textarea.id === "compose-textarea") { upload_files(compose_upload_object, compose_config, [pasted_file]); return; } const row = rows.get_message_id(textarea); const edit_uploader = upload_objects_by_message_edit_row.get(row); assert(edit_uploader !== undefined); upload_files(edit_uploader, edit_config(row), [pasted_file]); } // Borrowed from tus-js-client code at // https://github.com/tus/tus-js-client/blob/ca63ba254ea8766438b9d422f6f94284911f1fa5/lib/index.d.ts#L79 // The library does not export this type, hence requiring a copy here. type PreviousUpload = { size: number | null; metadata: Record<string, string>; creationTime: string; urlStorageKey: string; uploadUrl: string | null; parallelUploadUrls: string[] | null; }; // We attach our upload result to the entry that tus-js-client stores. // // We need to store it because a re-upload gives us nothing to insert. // tus recognizes the file from an earlier upload and answers with an // empty body, so no URL or filename comes back. Example: pasting the // same image twice. We save the result on the first upload and read it // back on the re-upload. // // It lives on the same entry whose fingerprint match is what // short-circuits the re-upload. tus ignores this extra field. type StoredUpload = PreviousUpload & { zulip_result?: UploadResult; }; // Parts of it are inspired from WebStorageUrlStorage at // https://github.com/tus/tus-js-client/blob/ca63ba254ea8766438b9d422f6f94284911f1fa5/lib/browser/urlStorage.js#L27 // While there are no async actions happening in any of the methods in // this class, UrlStorage interface for tus-js-client requires a Promise // to be returned for each of these methods. // // The camelCase methods are the tus UrlStorage interface. tus calls // these. The snake_case methods are a Zulip extension. tus never calls // them. We use them to stash each upload's result on the entries this // class already owns, so the result survives cancelAll() and we can // recover it on a re-upload. class InMemoryUrlStorage { urlStorage: Map<string, StoredUpload>; constructor() { this.urlStorage = new Map(); } set_result_for_upload_url(upload_url: string, result: UploadResult): void { for (const upload of this.urlStorage.values()) { if (upload.uploadUrl === upload_url) { upload.zulip_result = result; return; } } } get_result_for_upload_url(upload_url: string): UploadResult | undefined { for (const upload of this.urlStorage.values()) { if (upload.uploadUrl === upload_url) { return upload.zulip_result; } } return undefined; } // eslint-disable-next-line @typescript-eslint/require-await async findAllUploads(): Promise<PreviousUpload[]> { return this.urlStorage.values().toArray(); } // eslint-disable-next-line @typescript-eslint/require-await async findUploadsByFingerprint(fingerprint: string): Promise<PreviousUpload[]> { const results = []; for (const [key, value] of this.urlStorage) { if (!key.startsWith(`${fingerprint}::`)) { continue; } results.push(value); } return results; } async removeUpload(urlStorageKey: string): Promise<void> { this.urlStorage.delete(urlStorageKey); await Promise.resolve(); } // eslint-disable-next-line @typescript-eslint/require-await async addUpload(fingerprint: string, upload: PreviousUpload): Promise<string> { const id = Math.round(Math.random() * 1e12); const key = `${fingerprint}::${id}`; upload.urlStorageKey = key; this.urlStorage.set(key, upload); return key; } } const zulip_upload_response_schema = z.object({ url: z.string(), filename: z.string(), }); export function setup_upload(config: Config): Uppy<Meta, TusBody> { // tus-js-client keeps this fingerprint -> upload cache for the life // of the uppy instance, and a match is what short-circuits a // re-upload to an empty-body HEAD. We store each upload's result on // its entry here, so it is available to recover from exactly when // tus short-circuits. We can't use uppy's `files` state instead: it // is wiped when the compose box is closed (cancelAll), which is what // caused re-uploads to hang. const url_storage = new InMemoryUrlStorage(); const uppy = new Uppy<Meta, TusBody>({ debug: false, autoProceed: true, restrictions: { maxFileSize: realm.max_file_upload_size_mib * 1024 * 1024, }, locale: { strings: { exceedsSize: $t( { defaultMessage: "%'{file}' exceeds the maximum file size for attachments ({variable} MB).", }, {variable: `${realm.max_file_upload_size_mib}`}, ), failedToUpload: $t({defaultMessage: "Failed to upload %'{file}'"}), }, pluralize: (_n) => 0, }, onBeforeFileAdded: () => true, // Allow duplicate file uploads }); uppy.use(Tus, { // https://uppy.io/docs/tus/#options endpoint: "/api/v1/tus/", // The tus-js-client fingerprinting feature stores metadata on // previously uploaded files by default in browser local storage. // Since these local storage entries are never garbage-collected, // they can be accessed via the browser console even after // logging out, and contain some metadata about previously // uploaded files, which seems like a security risk for // using Zulip on a public computer. // We use our own implementation of url storage that saves urls // in memory instead. We won't be able to retain this history // across reloads unlike local storage, which is a tradeoff we // are willing to make. urlStorage: url_storage, // Number of concurrent uploads limit: 5, }); if (config.mode === "edit") { upload_objects_by_message_edit_row.set(config.row, uppy); } uppy.on("upload-progress", (file, progress) => { assert(file !== undefined); assert(progress.bytesTotal !== null); const percent_complete = (100 * progress.bytesUploaded) / progress.bytesTotal; $(`${config.upload_banner_identifier(file.id)} .moving_bar`).css({ width: `${percent_complete}%`, }); }); $<HTMLInputElement>(config.file_input_identifier()).on("change", (event) => { const files = event.target.files; assert(files !== null); upload_files(uppy, config, files); config.textarea().trigger("focus"); event.target.value = ""; }); const $banner_container = config.banner_container(); $banner_container.on( "click", ".upload_banner.file_generic_error .main-view-banner-close-button", (event) => { event.preventDefault(); $(event.target).parents(".upload_banner").remove(); }, ); const $drag_drop_container = config.drag_drop_container(); $drag_drop_container.on("dragover", (event) => { event.preventDefault(); }); $drag_drop_container.on("dragenter", (event) => { event.preventDefault(); }); $drag_drop_container.on("drop", (event) => { event.preventDefault(); event.stopPropagation(); assert(event.originalEvent !== undefined); assert(event.originalEvent.dataTransfer !== null); if (contains_folder(event.originalEvent.dataTransfer)) { setTimeout(() => { if ($("#compose_select_recipient_widget").hasClass("widget-open")) { compose_recipient.toggle_compose_recipient_dropdown(); } }, 0); show_folder_upload_error(config); return; } const files = event.originalEvent.dataTransfer.files; if (config.mode === "compose" && !compose_state.composing()) { compose_reply.respond_to_message({ trigger: "file drop or paste", keep_composebox_empty: true, }); } upload_files(uppy, config, files); }); $drag_drop_container.on("paste", (event) => { assert(event.originalEvent instanceof ClipboardEvent); const clipboard_data = event.originalEvent.clipboardData; if (!clipboard_data) { return; } const items = clipboard_data.items; const files = []; for (const item of items) { const file = item.getAsFile(); if (file === null) { continue; } files.push(file); } if (files.length === 0) { // Exit when there are no files from the clipboard return; } // Halt the normal browser paste event, which would otherwise // present a plain-text version of the file name. event.preventDefault(); if (config.mode === "compose" && !compose_state.composing()) { compose_reply.respond_to_message({ trigger: "file drop or paste", keep_composebox_empty: true, }); } upload_files(uppy, config, files); }); uppy.on("upload-success", (file, response) => { assert(file !== undefined); if (response.status !== 200) { blueslip.warn("Tus server returned an error, expected a 200 OK response code.", { response, }); return; } // The tus `/api/v1/tus/...` URL (not the `/user_uploads/...` URL // we insert), reported for both a fresh upload and a re-upload. // We use it to find this upload's InMemoryUrlStorage entry. const upload_url = response.uploadURL; // We do not receive response text if the file has already // been uploaded. For an existing upload, TUS js client sends // a HEAD request to the TUS server to check `Upload-Offset` // and if some part of the upload is left to be done -- and // when the upload offset is the same as the file length, it // will not send any further requests, meaning we will not // have a response body. In that case we recover the result we // stored when this file was first uploaded. let upload_result: UploadResult; if (response.body!.xhr.responseText === "") { const previous_result = upload_url === undefined ? undefined : url_storage.get_result_for_upload_url(upload_url); if (previous_result === undefined) { blueslip.warn("No upload result stored for re-uploaded file", {file}); return; } upload_result = previous_result; } else { try { upload_result = zulip_upload_response_schema.parse( JSON.parse(response.body!.xhr.responseText), ); } catch { blueslip.warn("Invalid JSON response from the tus server", { body: response.body!.xhr.responseText, }); return; } if (upload_url !== undefined) { url_storage.set_result_for_upload_url(upload_url, upload_result); } } const filtered_filename = upload_result.filename.replaceAll(/[[\]]/g, ""); let syntax_to_insert = "[" + filtered_filename + "](" + upload_result.url + ")"; if (is_supported_image_type(file.type) || is_supported_audio_type(file.type)) { syntax_to_insert = "!" + syntax_to_insert; } const $text_area = config.textarea(); const replacement_successful = compose_ui.replace_syntax( // We need to replace the original file name, and not the // possibly modified filename returned in the response by // the server. file.meta.name remains unchanged by us // unlike file.name get_translated_status(file.meta.name), syntax_to_insert, $text_area, ); if (!replacement_successful) { compose_ui.insert_syntax_and_focus(syntax_to_insert, $text_area); } compose_ui.autosize_textarea($text_area); // Hide upload status after waiting 100ms after the 1s transition to 100% // so that the user can see the progress bar at 100%. hide_upload_banner(uppy, config, file.id, 1100); }); uppy.on("info-visible", () => { // Uppy's `info-visible` event is issued after appending the // notice details into the list of event events accessed via // uppy.getState().info. Extract the notice details so that we // can potentially act on the error. // // TODO: Ideally, we'd be using the `.error()` hook or // something, not parsing error message strings. const infoList = uppy.getState().info; assert(infoList !== undefined); const info = infoList.at(-1); assert(info !== undefined); if (info.type === "error" && info.message === "No Internet connection") { // server_events already handles the case of no internet. return; } if (info.type === "error" && info.details === "Upload Error") { // The server errors come under 'Upload Error'. But we can't handle them // here because info object don't contain response.body.msg received from // the server. Server errors are hence handled by on('upload-error'). return; } if (info.type === "error") { // The remaining errors are mostly frontend errors like file being too large // for upload. show_error_message(config, info.message); } }); uppy.on("upload-error", (file, _error, response) => { assert(file !== undefined); // The files with failed upload should be removed since uppy doesn't allow files in the store // to be re-uploaded again. uppy.removeFile(file.id); let parsed; const message = response !== undefined && (parsed = z.object({msg: z.string()}).safeParse(response.body)).success ? parsed.data.msg : undefined; // Hide the upload status banner on error so only the error banner shows hide_upload_banner(uppy, config, file.id); show_error_message(config, message, file.id); compose_ui.replace_syntax(get_translated_status(file.name), "", config.textarea()); compose_ui.autosize_textarea(config.textarea()); }); uppy.on("restriction-failed", (file) => { assert(file !== undefined); compose_ui.replace_syntax(get_translated_status(file.name), "", config.textarea()); compose_ui.autosize_textarea(config.textarea()); }); uppy.on("cancel-all", () => { if (config.mode === "compose") { compose_validate.set_upload_in_progress(false); } }); return uppy; } export function deactivate_upload(config: Config): void { // Remove event listeners added for handling uploads. $(config.file_input_identifier()).off("change"); config.banner_container().off("click"); config.drag_drop_container().off("dragover dragenter drop paste"); let uppy; if (config.mode === "edit") { uppy = upload_objects_by_message_edit_row.get(config.row); } else if (config.mode === "compose") { uppy = compose_upload_object; } if (!uppy) { return; } try { // Uninstall all plugins and close down the Uppy instance. // Also runs uppy.cancelAll() before uninstalling - which // cancels all uploads, resets progress and removes all files. uppy.destroy(); } catch (error) { blueslip.error("Failed to close upload object.", {config}, error); } if (config.mode === "edit") { // Since we removed all the uploads from the row, we should // now remove the corresponding upload object from the store. upload_objects_by_message_edit_row.delete(config.row); } } export function initialize(): void { compose_upload_object = setup_upload(compose_config); $(".app, #navbar-fixed-container").on("dragstart", (event) => { if (event.target.nodeName === "IMG") { drag_drop_img = event.target; } else { drag_drop_img = null; } }); // Allow the app panel to receive drag/drop events. $(".app, #navbar-fixed-container").on("dragover", (event) => { event.preventDefault(); }); // TODO: Do something visual to hint that drag/drop will work. $(".app, #navbar-fixed-container").on("dragenter", (event) => { event.preventDefault(); }); $(".app, #navbar-fixed-container").on("drop", (event) => { event.preventDefault(); if (event.target.nodeName === "IMG" && event.target === drag_drop_img) { drag_drop_img = null; return; } const $drag_drop_edit_containers = $(".message_edit_form form"); assert(event.originalEvent !== undefined); assert(event.originalEvent.dataTransfer !== null); if (contains_folder(event.originalEvent.dataTransfer)) { const was_dropdown_open = $("#compose_select_recipient_widget").hasClass("widget-open"); if (!compose_state.composing()) { if (message_lists.current?.selected_message()) { compose_reply.respond_to_message({ trigger: "drag_drop_file", keep_composebox_empty: true, }); } else { compose_actions.start({ message_type: "stream", trigger: "drag_drop_file", keep_composebox_empty: true, }); } } if (was_dropdown_open) { setTimeout(() => { if ($("#compose_select_recipient_widget").hasClass("widget-open")) { compose_recipient.toggle_compose_recipient_dropdown(); } }, 0); } show_folder_upload_error(compose_config); return; } const files = event.originalEvent.dataTransfer.files; const $last_drag_drop_edit_container = $drag_drop_edit_containers.last(); // Handlers registered on individual inputs will ensure that // drag/dropping directly onto a compose/edit input will put // the upload there. Here, we handle drag/drop events that // land somewhere else in the center pane. if (compose_state.composing()) { // Compose box is open; drop there. upload_files(compose_upload_object, compose_config, files); } else if ($last_drag_drop_edit_container[0] !== undefined) { // A message edit box is open; drop there. const row_id = rows.get_message_id($last_drag_drop_edit_container[0]); const $drag_drop_container = edit_config(row_id).drag_drop_container(); if ($drag_drop_container.closest("html").length === 0) { return; } const edit_upload_object = upload_objects_by_message_edit_row.get(row_id); assert(edit_upload_object !== undefined); upload_files(edit_upload_object, edit_config(row_id), files); } else if (message_lists.current?.selected_message()) { // Start a reply to selected message, if viewing a message feed. compose_reply.respond_to_message({ trigger: "drag_drop_file", keep_composebox_empty: true, }); upload_files(compose_upload_object, compose_config, files); } else { // Start a new message in other views. compose_actions.start({ message_type: "stream", trigger: "drag_drop_file", keep_composebox_empty: true, }); upload_files(compose_upload_object, compose_config, files); } }); }