diff --git a/tools/test-js-with-node b/tools/test-js-with-node index abad999e98..8dd8d38187 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -261,7 +261,7 @@ EXEMPT_FILES = make_set( "web/src/unread.ts", "web/src/unread_ops.ts", "web/src/unread_ui.ts", - "web/src/upload.js", + "web/src/upload.ts", "web/src/upload_widget.ts", "web/src/url-template.d.ts", "web/src/user_card_popover.js", diff --git a/web/src/state_data.ts b/web/src/state_data.ts index 53cc6cc2fd..34d9d59ea0 100644 --- a/web/src/state_data.ts +++ b/web/src/state_data.ts @@ -74,6 +74,7 @@ export const realm_schema = z.object({ PRONOUNS: z.object({id: z.number(), name: z.string()}), }), max_avatar_file_size_mib: z.number(), + max_file_upload_size_mib: z.number(), max_icon_file_size_mib: z.number(), max_logo_file_size_mib: z.number(), max_message_length: z.number(), diff --git a/web/src/upload.js b/web/src/upload.ts similarity index 78% rename from web/src/upload.js rename to web/src/upload.ts index 289f92523a..11dd402b1f 100644 --- a/web/src/upload.js +++ b/web/src/upload.ts @@ -1,7 +1,9 @@ +import type {UppyFile} from "@uppy/core"; import {Uppy} from "@uppy/core"; import XHRUpload from "@uppy/xhr-upload"; import $ from "jquery"; import assert from "minimalistic-assert"; +import {z} from "zod"; import render_upload_banner from "../templates/compose_banner/upload_banner.hbs"; @@ -18,27 +20,42 @@ import * as message_lists from "./message_lists"; import * as rows from "./rows"; import {realm} from "./state_data"; -let drag_drop_img = null; -let compose_upload_object; -const upload_objects_by_message_edit_row = new Map(); +let drag_drop_img: HTMLElement | null = null; +let compose_upload_object: Uppy; +const upload_objects_by_message_edit_row = new Map(); -export function compose_upload_cancel() { +export function compose_upload_cancel(): void { compose_upload_object.cancelAll(); } -export function feature_check() { +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(file) { +export function get_translated_status(file: File | UppyFile): string { const status = $t({defaultMessage: "Uploading {filename}…"}, {filename: file.name}); return "[" + status + "]()"; } -export const compose_config = { +type Config = ({mode: "compose"} | {mode: "edit"; row: number}) & { + textarea: () => JQuery; + 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: () => $("textarea#compose-textarea"), + textarea: () => $("textarea#compose-textarea"), send_button: () => $("#compose-send-button"), banner_container: () => $("#compose_banners"), upload_banner_identifier: (file_id) => @@ -58,55 +75,58 @@ export const compose_config = { ), upload_banner_message: (file_id) => $(`#compose_banners .upload_banner.file_${CSS.escape(file_id)} .upload_msg`), - file_input_identifier: () => "#compose .file_input", + 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) { +export function edit_config(row: number): Config { return { mode: "edit", row, - textarea: () => $(`#edit_form_${CSS.escape(row)} .message_edit_content`), - send_button: () => $(`#edit_form_${CSS.escape(row)}`).find(".message_edit_save"), - banner_container: () => $(`#edit_form_${CSS.escape(row)} .edit_form_banners`), + textarea: () => + $( + `#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)}`, + `#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)}`), + $(`#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( + `#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( + `#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( + `#edit_form_${CSS.escape(`${row}`)} .upload_banner.file_${CSS.escape( file_id, )} .upload_msg`, ), - file_input_identifier: () => `#edit_form_${CSS.escape(row)} .file_input`, + 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`, + `#message-row-${message_lists.current.id}-${CSS.escape(`${row}`)} .message_edit_form`, ); }, markdown_preview_hide_button: () => - $(`#edit_form_${CSS.escape(row)} .undo_markdown_preview`), + $(`#edit_form_${CSS.escape(`${row}`)} .undo_markdown_preview`), }; } -export function hide_upload_banner(uppy, config, file_id) { +export function hide_upload_banner(uppy: Uppy, config: Config, file_id: string): void { config.upload_banner(file_id).remove(); if (uppy.getFiles().length === 0) { if (config.mode === "compose") { @@ -118,12 +138,12 @@ export function hide_upload_banner(uppy, config, file_id) { } function add_upload_banner( - config, - banner_type, - banner_text, - file_id, + 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, @@ -137,10 +157,10 @@ function add_upload_banner( } export function show_error_message( - config, + config: Config, message = $t({defaultMessage: "An unknown error occurred."}), - file_id = null, -) { + 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"); @@ -153,7 +173,7 @@ export function show_error_message( } } -export async function upload_files(uppy, config, files) { +export function upload_files(uppy: Uppy, config: Config, files: File[] | FileList): void { if (files.length === 0) { return; } @@ -179,6 +199,7 @@ export async function upload_files(uppy, config, files) { } for (const file of files) { + let file_id; try { compose_ui.insert_syntax_and_focus( get_translated_status(file), @@ -187,7 +208,7 @@ export async function upload_files(uppy, config, files) { 1, ); compose_ui.autosize_textarea(config.textarea()); - file.id = uppy.addFile({ + file_id = uppy.addFile({ source: config.source(), name: file.name, type: file.type, @@ -207,24 +228,24 @@ export async function upload_files(uppy, config, files) { config, "info", $t({defaultMessage: "Uploading {filename}…"}, {filename: file.name}), - file.id, + file_id, true, ); - config.upload_banner_cancel_button(file.id).one("click", () => { + config.upload_banner_cancel_button(file_id).one("click", () => { compose_ui.replace_syntax(get_translated_status(file), "", config.textarea()); compose_ui.autosize_textarea(config.textarea()); config.textarea().trigger("focus"); - uppy.removeFile(file.id); - hide_upload_banner(uppy, config, file.id); + uppy.removeFile(file_id); + hide_upload_banner(uppy, config, file_id); }); - config.upload_banner_hide_button(file.id).one("click", () => { - hide_upload_banner(uppy, config, file.id); + config.upload_banner_hide_button(file_id).one("click", () => { + hide_upload_banner(uppy, config, file_id); }); } } -export function setup_upload(config) { +export function setup_upload(config: Config): Uppy { const uppy = new Uppy({ debug: false, autoProceed: true, @@ -267,14 +288,16 @@ export function setup_upload(config) { } uppy.on("upload-progress", (file, progress) => { + assert(file !== undefined); const percent_complete = (100 * progress.bytesUploaded) / progress.bytesTotal; $(`${config.upload_banner_identifier(file.id)} .moving_bar`).css({ width: `${percent_complete}%`, }); }); - $(config.file_input_identifier()).on("change", (event) => { + $(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 = ""; @@ -291,12 +314,18 @@ export function setup_upload(config) { ); 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("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); const files = event.originalEvent.dataTransfer.files; if (config.mode === "compose" && !compose_state.composing()) { compose_reply.respond_to_message({ @@ -308,17 +337,18 @@ export function setup_upload(config) { }); $drag_drop_container.on("paste", (event) => { - const clipboard_data = event.clipboardData || event.originalEvent.clipboardData; + 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) { - if (item.kind !== "file") { + const file = item.getAsFile(); + if (file === null) { continue; } - const file = item.getAsFile(); files.push(file); } if (files.length === 0) { @@ -338,7 +368,8 @@ export function setup_upload(config) { }); uppy.on("upload-success", (file, response) => { - const url = response.body.uri; + assert(file !== undefined); + const {uri: url} = z.object({uri: z.string().optional()}).parse(response.body); if (url === undefined) { return; } @@ -376,7 +407,9 @@ export function setup_upload(config) { // 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; @@ -397,11 +430,17 @@ export function setup_upload(config) { }); 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); - const message = response ? response.body.msg : undefined; + 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); @@ -410,6 +449,7 @@ export function setup_upload(config) { }); uppy.on("restriction-failed", (file) => { + assert(file !== undefined); compose_ui.replace_syntax(get_translated_status(file), "", config.textarea()); compose_ui.autosize_textarea(config.textarea()); }); @@ -417,7 +457,7 @@ export function setup_upload(config) { return uppy; } -export function deactivate_upload(config) { +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"); @@ -451,7 +491,7 @@ export function deactivate_upload(config) { } } -export function initialize() { +export function initialize(): void { compose_upload_object = setup_upload(compose_config); $(".app, #navbar-fixed-container").on("dragstart", (event) => { @@ -463,10 +503,14 @@ export function initialize() { }); // Allow the app panel to receive drag/drop events. - $(".app, #navbar-fixed-container").on("dragover", (event) => event.preventDefault()); + $(".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("dragenter", (event) => { + event.preventDefault(); + }); $(".app, #navbar-fixed-container").on("drop", (event) => { event.preventDefault(); @@ -477,6 +521,8 @@ export function initialize() { } const $drag_drop_edit_containers = $(".message_edit_form form"); + assert(event.originalEvent !== undefined); + assert(event.originalEvent.dataTransfer !== null); const files = event.originalEvent.dataTransfer.files; const $last_drag_drop_edit_container = $drag_drop_edit_containers.last(); @@ -496,6 +542,7 @@ export function initialize() { 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()) { diff --git a/web/tests/blueslip_stacktrace.test.js b/web/tests/blueslip_stacktrace.test.js index 81f22f521a..e0589c5aeb 100644 --- a/web/tests/blueslip_stacktrace.test.js +++ b/web/tests/blueslip_stacktrace.test.js @@ -10,8 +10,8 @@ const blueslip_stacktrace = zrequire("blueslip_stacktrace"); run_test("clean_path", () => { // Local file assert.strictEqual( - blueslip_stacktrace.clean_path("webpack:///web/src/upload.js"), - "/web/src/upload.js", + blueslip_stacktrace.clean_path("webpack:///web/src/upload.ts"), + "/web/src/upload.ts", ); // Third party library (jQuery) @@ -36,9 +36,9 @@ run_test("clean_function_name", () => { // Local file assert.deepEqual( - blueslip_stacktrace.clean_function_name("Object../web/src/upload.js.exports.options"), + blueslip_stacktrace.clean_function_name("Object../web/src/upload.ts.exports.options"), { - scope: "Object../web/src/upload.js.exports.", + scope: "Object../web/src/upload.ts.exports.", name: "options", }, ); diff --git a/web/tests/upload.test.js b/web/tests/upload.test.js index 6f3665e02b..5bab39ee5e 100644 --- a/web/tests/upload.test.js +++ b/web/tests/upload.test.js @@ -7,6 +7,13 @@ const {run_test, noop} = require("./lib/test"); const $ = require("./lib/zjquery"); const {realm} = require("./lib/zpage_params"); +class ClipboardEvent { + constructor({clipboardData}) { + this.clipboardData = clipboardData; + } +} +set_global("ClipboardEvent", ClipboardEvent); + set_global("navigator", { userAgent: "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)", }); @@ -60,7 +67,7 @@ test("config", () => { upload.compose_config.upload_banner_hide_button("id_2"), $("#compose_banners .upload_banner.file_id_2 .main-view-banner-close-button"), ); - assert.equal(upload.compose_config.file_input_identifier(), "#compose .file_input"); + assert.equal(upload.compose_config.file_input_identifier(), "#compose input.file_input"); assert.equal(upload.compose_config.source(), "compose-file-input"); assert.equal(upload.compose_config.drag_drop_container(), $("#compose")); assert.equal( @@ -70,7 +77,7 @@ test("config", () => { assert.equal( upload.edit_config(1).textarea(), - $(`#edit_form_${CSS.escape(1)} .message_edit_content`), + $(`#edit_form_${CSS.escape(1)} textarea.message_edit_content`), ); $(`#edit_form_${CSS.escape(2)}`).set_find_results( @@ -117,7 +124,7 @@ test("config", () => { assert.equal( upload.edit_config(123).file_input_identifier(), - `#edit_form_${CSS.escape(123)} .file_input`, + `#edit_form_${CSS.escape(123)} input.file_input`, ); assert.equal(upload.edit_config(123).source(), "message-edit-file-input"); assert.equal( @@ -343,7 +350,7 @@ test("uppy_config", () => { test("file_input", ({override_rewire}) => { upload.setup_upload(upload.compose_config); - const change_handler = $("#compose .file_input").get_on_handler("change"); + const change_handler = $("#compose input.file_input").get_on_handler("change"); const files = ["file1", "file2"]; const event = { target: { @@ -417,7 +424,7 @@ test("copy_paste", ({override, override_rewire}) => { const paste_handler = $("#compose").get_on_handler("paste"); let get_as_file_called = false; let event = { - originalEvent: { + originalEvent: new ClipboardEvent({ clipboardData: { items: [ { @@ -428,10 +435,11 @@ test("copy_paste", ({override, override_rewire}) => { }, { kind: "notfile", + getAsFile: () => null, }, ], }, - }, + }), preventDefault() {}, }; let upload_files_called = false; @@ -449,7 +457,7 @@ test("copy_paste", ({override, override_rewire}) => { assert.ok(compose_actions_start_called); upload_files_called = false; event = { - originalEvent: {}, + originalEvent: new ClipboardEvent({}), }; paste_handler(event); assert.equal(upload_files_called, false); @@ -585,7 +593,7 @@ test("uppy_events", ({override_rewire, mock_template}) => { assert.ok(compose_ui_replace_syntax_called); compose_ui_replace_syntax_called = false; - on_upload_error_callback(file, null, null); + on_upload_error_callback(file, null, undefined); assert.ok(compose_ui_replace_syntax_called); $("#compose_banners .upload_banner .upload_msg").text("");