From aeaeb55cdaec8b684db72fca5b612631747a8d92 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Tue, 12 May 2026 12:30:30 +0100 Subject: [PATCH] Rework Upload internals to use MVVM (#33307) * Initial reword of upload to MVVM. * Update tests * More incremental improvements * Refactor tests to use helper method for composer uploads. * Add drag and drop tests * lint * Add commentary * fixup test * More precise selector * Retarget uploads * lint * fixup * one more type * update snap * Fixup composerUploadFiles * fix import * lint * Copy and paste fixes too * Add tests for pasting * Add tests for pasting files. * Remove redundant fn * rm comment * tidy up * Test cleanup * More clean up * another fix * Use condensed version * Cleanup tests * more cleaning * last bity * s/throw Error/throw new Error/ --- .../playwright/e2e/timeline/timeline.spec.ts | 9 +- apps/web/playwright/pages/ElementAppPage.ts | 3 +- apps/web/src/ContentMessages.ts | 2 +- .../components/structures/FileDropTarget.tsx | 20 +- .../src/components/structures/RoomView.tsx | 50 ++-- .../src/components/structures/ThreadView.tsx | 82 +++---- .../views/right_panel/TimelineCard.tsx | 81 +++--- .../views/rooms/MessageComposerButtons.tsx | 82 +------ .../views/rooms/SendMessageComposer.tsx | 42 ++-- .../components/PlainTextComposer.tsx | 6 +- .../components/WysiwygComposer.tsx | 2 +- .../hooks/useInputEventProcessor.ts | 9 +- .../hooks/usePlainTextListeners.ts | 15 +- .../rooms/wysiwyg_composer/hooks/utils.ts | 27 +- .../viewmodels/room/RoomUploadViewModel.tsx | 231 ++++++++++++++++++ apps/web/test/test-utils/test-utils.ts | 4 +- .../structures/FileDropTarget-test.tsx | 65 +++-- .../__snapshots__/RoomView-test.tsx.snap | 95 ++++--- .../views/rooms/MessageComposer-test.tsx | 8 +- .../rooms/MessageComposerButtons-test.tsx | 7 +- .../views/rooms/SendMessageComposer-test.tsx | 13 +- .../EditWysiwygComposer-test.tsx | 13 +- .../SendWysiwygComposer-test.tsx | 21 +- .../components/WysiwygComposer-test.tsx | 43 ++-- .../wysiwyg_composer/hooks/utils-test.tsx | 154 +++--------- .../room/RoomUploadViewModel-test.ts | 174 +++++++++++++ 26 files changed, 768 insertions(+), 490 deletions(-) create mode 100644 apps/web/src/viewmodels/room/RoomUploadViewModel.tsx create mode 100644 apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts diff --git a/apps/web/playwright/e2e/timeline/timeline.spec.ts b/apps/web/playwright/e2e/timeline/timeline.spec.ts index 8c4888bff5..a9a25bcbb4 100644 --- a/apps/web/playwright/e2e/timeline/timeline.spec.ts +++ b/apps/web/playwright/e2e/timeline/timeline.spec.ts @@ -751,7 +751,7 @@ test.describe("Timeline", () => { await expect(page.locator(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded")).toBeVisible(); }); - test("should render file size in kibibytes on a file tile", async ({ page, room }) => { + test("should render file size in kibibytes on a file tile", async ({ page, app, room }) => { await page.goto(`/#/room/${room.roomId}`); await expect( page @@ -760,12 +760,7 @@ test.describe("Timeline", () => { ).toBeVisible(); // Upload a file from the message composer - await page - .locator(".mx_MessageComposer_actions input[type='file']") - .setInputFiles(getSampleFilePath("matrix-org-client-versions.json")); - - // Click "Upload" button - await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click(); + await app.composerUploadFiles("room", getSampleFilePath("matrix-org-client-versions.json")); // Wait until the file is sent await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible(); diff --git a/apps/web/playwright/pages/ElementAppPage.ts b/apps/web/playwright/pages/ElementAppPage.ts index 41abc8f980..f933484ff0 100644 --- a/apps/web/playwright/pages/ElementAppPage.ts +++ b/apps/web/playwright/pages/ElementAppPage.ts @@ -169,8 +169,7 @@ export class ElementAppPage { ): ReturnType { const input = this.page .locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel") - .getByRole("region", { name: "Message composer" }) - .locator("input[type='file']"); + .getByTestId("room-upload-context-input"); return input.setInputFiles(...params); } diff --git a/apps/web/src/ContentMessages.ts b/apps/web/src/ContentMessages.ts index ec68f854fe..15a43d79b6 100644 --- a/apps/web/src/ContentMessages.ts +++ b/apps/web/src/ContentMessages.ts @@ -607,7 +607,7 @@ export default class ContentMessages { throw e; } // Otherwise we failed to thumbnail, fall back to uploading an m.file - logger.error(e); + logger.error(`Expected file of type "${file.type}" to be an image, but got`, e); content.msgtype = MsgType.File; } } else if (file.type.startsWith("audio/")) { diff --git a/apps/web/src/components/structures/FileDropTarget.tsx b/apps/web/src/components/structures/FileDropTarget.tsx index 564383d9f4..a81c05fbad 100644 --- a/apps/web/src/components/structures/FileDropTarget.tsx +++ b/apps/web/src/components/structures/FileDropTarget.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -7,16 +8,14 @@ Please see LICENSE files in the repository root for full details. */ import React, { useEffect, useState } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; +import { useViewModel } from "@element-hq/web-shared-components"; import { _t } from "../../languageHandler"; import UploadBigSvg from "../../../res/img/upload-big.svg"; -import { useRoomState } from "../../hooks/useRoomState.ts"; +import { useRoomUploadViewModel } from "../../viewmodels/room/RoomUploadViewModel"; interface IProps { - room: Room; parent: HTMLElement | null; - onFileDrop(this: void, dataTransfer: DataTransfer): void; } interface IState { @@ -24,15 +23,16 @@ interface IState { counter: number; } -const FileDropTarget: React.FC = ({ parent, onFileDrop, room }) => { +const FileDropTarget: React.FC = ({ parent }) => { const [state, setState] = useState({ dragging: false, counter: 0, }); - const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!)); + const vm = useRoomUploadViewModel(); + const { mayUpload } = useViewModel(vm); useEffect(() => { - if (!hasPermission || !parent || parent.ondrop) return; + if (!mayUpload || !parent || parent.ondrop) return; const onDragEnter = (ev: DragEvent): void => { ev.stopPropagation(); @@ -83,7 +83,7 @@ const FileDropTarget: React.FC = ({ parent, onFileDrop, room }) => { ev.stopPropagation(); ev.preventDefault(); if (!ev.dataTransfer) return; - onFileDrop(ev.dataTransfer); + void vm.initiateViaDataTransfer(ev.dataTransfer); setState((state) => ({ dragging: false, @@ -106,9 +106,9 @@ const FileDropTarget: React.FC = ({ parent, onFileDrop, room }) => { parent?.removeEventListener("dragenter", onDragEnter); parent?.removeEventListener("dragleave", onDragLeave); }; - }, [parent, onFileDrop, hasPermission]); + }, [parent, mayUpload, vm]); - if (hasPermission && state.dragging) { + if (mayUpload && state.dragging) { return (
diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index b5c03938bc..9483b05a9c 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts"; import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts"; import { ModuleApi } from "../../modules/Api.ts"; +import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx"; import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider"; const DEBUG = false; @@ -302,7 +303,6 @@ interface LocalRoomViewProps { resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; roomView: RefObject; - onFileDrop: (dataTransfer: DataTransfer) => Promise; mainSplitContentType: MainSplitContentType; e2eStatus?: E2EStatus; } @@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
-
- -
- - {encryptionTile} - - -
- {statusBar} - {composer} -
+ +
+ +
+ + {encryptionTile} + + +
+ {statusBar} + {composer} +
+
); @@ -2121,19 +2123,6 @@ export class RoomView extends React.Component { }); } - private onFileDrop = async (dataTransfer: DataTransfer): Promise => { - const roomId = this.getRoomId(); - if (!roomId || !this.context.client) return; - await ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(dataTransfer.files), - roomId, - undefined, - this.state.replyToEvent, - this.context.client, - TimelineRenderingType.Room, - ); - }; - private onMeasurement = (narrow: boolean): void => { this.setState({ narrow }); }; @@ -2169,7 +2158,6 @@ export class RoomView extends React.Component { resizeNotifier={this.context.resizeNotifier} permalinkCreator={this.permalinkCreator} roomView={this.roomView} - onFileDrop={this.onFileDrop} mainSplitContentType={this.state.mainSplitContentType} /> @@ -2673,16 +2661,12 @@ export class RoomView extends React.Component { case MainSplitContentType.Timeline: mainSplitContentClassName = "mx_MainSplit_timeline"; mainSplitBody = ( - <> + {auxPanel} {pinnedMessageBanner}
- + {topUnreadMessagesBar} {jumpToBottom} {messagePanel} @@ -2691,7 +2675,7 @@ export class RoomView extends React.Component { {statusBarArea} {previewBar} {messageComposer} - + ); break; case MainSplitContentType.MaximisedWidget: diff --git a/apps/web/src/components/structures/ThreadView.tsx b/apps/web/src/components/structures/ThreadView.tsx index 54727b95e2..87303d3f8c 100644 --- a/apps/web/src/components/structures/ThreadView.tsx +++ b/apps/web/src/components/structures/ThreadView.tsx @@ -29,7 +29,6 @@ import TimelinePanel from "./TimelinePanel"; import dis from "../../dispatcher/dispatcher"; import { type ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import { type E2EStatus } from "../../utils/ShieldUtils"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; @@ -51,6 +50,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo import Heading from "../views/typography/Heading"; import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload"; import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx"; import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider"; interface IProps { @@ -329,22 +329,6 @@ export default class ThreadView extends React.Component { } }; - private onFileDrop = (dataTransfer: DataTransfer): void => { - const roomId = this.props.mxEvent.getRoomId(); - if (roomId) { - ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(dataTransfer.files), - roomId, - this.threadRelation, - this.context.replyToEvent, - MatrixClientPeg.safeGet(), - TimelineRenderingType.Thread, - ); - } else { - console.warn("Unknwon roomId for event", this.props.mxEvent); - } - }; - private get threadRelation(): IEventRelation { const relation: IEventRelation = { rel_type: THREAD_RELATION_TYPE.name, @@ -393,7 +377,7 @@ export default class ThreadView extends React.Component { timeline = ( <> - + { liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()} narrow={this.state.narrow} > - { - PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev); - }} - > - -
{timeline}
+ + { + PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev); + }} + > + +
{timeline}
- {ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( - - )} + {ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( + + )} - {this.state.thread?.timelineSet && ( - - )} -
+ {this.state.thread?.timelineSet && ( + + )} +
+ ); } diff --git a/apps/web/src/components/views/right_panel/TimelineCard.tsx b/apps/web/src/components/views/right_panel/TimelineCard.tsx index 0ec7b8bedd..3d5cabd1f9 100644 --- a/apps/web/src/components/views/right_panel/TimelineCard.tsx +++ b/apps/web/src/components/views/right_panel/TimelineCard.tsx @@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo import Measured from "../elements/Measured"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../../viewmodels/room/RoomUploadViewModel.tsx"; import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider"; interface IProps { @@ -214,47 +215,49 @@ export default class TimelineCard extends React.Component { header={_t("right_panel|video_room_chat|title")} ref={this.card} > - -
- {jumpToBottom} - - -
- - {isUploading && } - - {showComposer && ( - - )} + )} + ); diff --git a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx index 2221bf7592..94919e7788 100644 --- a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -14,7 +15,7 @@ import { THREAD_RELATION_TYPE, M_POLL_START, } from "matrix-js-sdk/src/matrix"; -import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext, useRef } from "react"; +import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react"; import { AttachmentIcon, MicOnIcon, @@ -27,22 +28,19 @@ import { import { _t } from "../../../languageHandler"; import { CollapsibleButton } from "./CollapsibleButton"; import { type MenuProps } from "../../structures/ContextMenu"; -import dis from "../../../dispatcher/dispatcher"; import ErrorDialog from "../dialogs/ErrorDialog"; import { LocationButton } from "../location"; import Modal from "../../../Modal"; import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import ContentMessages from "../../../ContentMessages"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { useDispatcher } from "../../../hooks/useDispatcher"; -import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; import { EmojiButton } from "./EmojiButton"; import { filterBoolean } from "../../../utils/arrays"; import { useSettingValue } from "../../../hooks/useSettings"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; +import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx"; interface IProps { addEmoji: (emoji: string) => boolean; @@ -126,7 +124,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { }); return ( - + <> {mainButtons} {moreButtons.length > 0 && ( = (props: IProps) => { )} - + ); }; @@ -168,79 +166,13 @@ function uploadButton(): ReactElement { return ; } -type UploadButtonFn = () => void; -export const UploadButtonContext = createContext(null); - -interface IUploadButtonProps { - roomId: string; - relation?: IEventRelation; - children: ReactNode; -} - -// We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. -const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { - const cli = useContext(MatrixClientContext); - const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent"); - const uploadInput = useRef(null); - - const onUploadClick = (): void => { - if (cli?.isGuest()) { - dis.dispatch({ action: "require_registration" }); - return; - } - uploadInput.current?.click(); - }; - - useDispatcher(dis, (payload) => { - if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") { - onUploadClick(); - } - }); - - const onUploadFileInputChange = (ev: React.ChangeEvent): void => { - if (ev.target.files?.length === 0) return; - - // Take a copy, so we can safely reset the value of the form control - ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(ev.target.files!), - roomId, - relation, - roomContext.replyToEvent, - cli, - roomContext.timelineRenderingType, - ); - - // This is the onChange handler for a file form control, but we're - // not keeping any state, so reset the value of the form control - // to empty. - // NB. we need to set 'value': the 'files' property is immutable. - ev.target.value = ""; - }; - - const uploadInputStyle = { display: "none" }; - return ( - - {children} - - - - ); -}; - // Must be rendered within an UploadButtonContextProvider const UploadButton: React.FC = () => { const overflowMenuCloser = useContext(OverflowMenuContext); - const uploadButtonFn = useContext(UploadButtonContext); + const vm = useRoomUploadViewModel(); const onClick = (): void => { - uploadButtonFn?.(); + vm.openUploadDialog(); overflowMenuCloser?.(); // close overflow menu }; diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 0d9a68c45d..66288fbbaf 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, type KeyboardEvent, type SyntheticEvent } from "react"; +import React, { + createRef, + type KeyboardEvent, + type SyntheticEvent, + type RefAttributes, + type ReactElement, +} from "react"; import { type MatrixEvent, type IEventRelation, @@ -37,8 +43,7 @@ import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } import { findEditableEvent } from "../../../utils/EventUtils"; import SendHistoryManager from "../../../SendHistoryManager"; import { CommandCategories } from "../../../slash-commands/SlashCommands"; -import ContentMessages from "../../../ContentMessages"; -import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext"; +import { useMatrixClientContext, type MatrixClientProps } from "../../../contexts/MatrixClientContext"; import { Action } from "../../../dispatcher/actions"; import { containsEmoji } from "../../../effects/utils"; import { CHAT_EFFECTS } from "../../../effects"; @@ -60,6 +65,7 @@ import { type IDiff } from "../../../editor/diff"; import { getBlobSafeMimeType } from "../../../utils/blobs"; import { EMOJI_REGEX } from "../../../HtmlUtils"; import { attachMentions, attachRelation } from "../../../utils/messages"; +import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel"; // The prefix used when persisting editor drafts to localstorage. export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; @@ -124,6 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean { interface ISendMessageComposerProps extends MatrixClientProps { room: Room; + uploadVm: RoomUploadViewModel; placeholder?: string; relation?: IEventRelation; replyToEvent?: MatrixEvent; @@ -561,14 +568,7 @@ export class SendMessageComposer extends React.Component { console.log(error); @@ -660,5 +654,13 @@ export class SendMessageComposer extends React.Component & + RefAttributes>, +): ReactElement { + const client = useMatrixClientContext(); + const uploadVm = useRoomUploadViewModel(); + return ; +} + +export default SendMessageComposerWrapped; diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index f45cc5d74f..8caf4c5eab 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import { type IEventRelation } from "matrix-js-sdk/src/matrix"; -import React, { type JSX, type RefObject, type ReactNode } from "react"; +import React, { type JSX, type RefObject, type ReactNode, useContext } from "react"; import { useComposerFunctions } from "../hooks/useComposerFunctions"; import { useIsFocused } from "../hooks/useIsFocused"; @@ -19,6 +19,7 @@ import { type ComposerFunctions } from "../types"; import { Editor } from "./Editor"; import { WysiwygAutocomplete } from "./WysiwygAutocomplete"; import { useSettingValue } from "../../../../../hooks/useSettings"; +import { RoomUploadContext } from "../../../../../viewmodels/room/RoomUploadViewModel"; interface PlainTextComposerProps { disabled?: boolean; @@ -46,6 +47,7 @@ export function PlainTextComposer({ eventRelation, }: PlainTextComposerProps): JSX.Element { const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); + const uploadContext = useContext(RoomUploadContext); const { ref: editorRef, autocompleteRef, @@ -61,7 +63,7 @@ export function PlainTextComposer({ handleMention, handleAtRoomMention, handleEmoji, - } = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled); + } = usePlainTextListeners(initialContent, onChange, onSend, isAutoReplaceEmojiEnabled, uploadContext || undefined); const composerFunctions = useComposerFunctions(editorRef, setContent); usePlainTextInitialization(initialContent, editorRef); useSetCursorPosition(disabled, editorRef); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index b0696143aa..ab720e0283 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -60,7 +60,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ const { room } = useScopedRoomContext("room"); const autocompleteRef = useRef(null); - const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation); + const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent); const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 30e5649013..d72f777218 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { type Wysiwyg, type WysiwygEvent } from "@vector-im/matrix-wysiwyg"; import { useCallback } from "react"; -import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { useSettingValue } from "../../../../../hooks/useSettings"; import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; @@ -26,13 +26,14 @@ import { endEditing } from "../utils/editing"; import type Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; +import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx"; export function useInputEventProcessor( onSend: () => void, autocompleteRef: React.RefObject, initialContent?: string, - eventRelation?: IEventRelation, ): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { + const roomUploadVm = useRoomUploadViewModel(); const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType"); const composerContext = useComposerContext(); const mxClient = useMatrixClientContext(); @@ -52,7 +53,7 @@ export function useInputEventProcessor( if (isEventToHandleAsClipboardEvent(event)) { const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer; - const handled = handleClipboardEvent(event, data, roomContext, mxClient, eventRelation); + const handled = handleClipboardEvent(event, data, roomUploadVm); return handled ? null : event; } @@ -77,11 +78,11 @@ export function useInputEventProcessor( isCtrlEnterToSend, onSend, initialContent, + roomUploadVm, roomContext, composerContext, mxClient, autocompleteRef, - eventRelation, ], ); } diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 81272f8b4b..1a4713503e 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. import { type KeyboardEvent, type RefObject, type SyntheticEvent, useCallback, useRef, useState } from "react"; import { type AllowedMentionAttributes, type MappedSuggestion } from "@vector-im/matrix-wysiwyg"; -import { type IEventRelation } from "matrix-js-sdk/src/matrix"; import { useSettingValue } from "../../../../../hooks/useSettings"; import { IS_MAC, Key } from "../../../../../Keyboard"; @@ -16,8 +15,7 @@ import type Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { useSuggestion } from "./useSuggestion"; import { isNotNull, isNotUndefined } from "../../../../../Typeguards"; -import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; -import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; +import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx"; function isDivElement(target: EventTarget): target is HTMLDivElement { return target instanceof HTMLDivElement; @@ -46,8 +44,8 @@ export function usePlainTextListeners( initialContent?: string, onChange?: (content: string) => void, onSend?: () => void, - eventRelation?: IEventRelation, isAutoReplaceEmojiEnabled?: boolean, + roomUploadVM?: RoomUploadViewModel, ): { ref: RefObject; autocompleteRef: RefObject; @@ -64,9 +62,6 @@ export function usePlainTextListeners( onSelect: (this: void, event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { - const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent"); - const mxClient = useMatrixClientContext(); - const ref = useRef(null); const autocompleteRef = useRef(null); const [content, setContent] = useState(initialContent); @@ -120,10 +115,10 @@ export function usePlainTextListeners( const { nativeEvent } = event; let imagePasteWasHandled = false; - if (isEventToHandleAsClipboardEvent(nativeEvent)) { + if (roomUploadVM && isEventToHandleAsClipboardEvent(nativeEvent)) { const data = nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer; - imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomContext, mxClient, eventRelation); + imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomUploadVM); } // prevent default behaviour and skip call to onInput if the image paste event was handled @@ -133,7 +128,7 @@ export function usePlainTextListeners( onInput(event); } }, - [eventRelation, mxClient, onInput, roomContext], + [onInput, roomUploadVM], ); const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend"); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 0a34cf0940..6475109ab5 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. */ import { type RefObject } from "react"; -import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type WysiwygEvent } from "@vector-im/matrix-wysiwyg"; import { type TimelineRenderingType } from "../../../../../contexts/RoomContext"; @@ -16,8 +15,8 @@ import type Autocomplete from "../../Autocomplete"; import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts"; import { getBlobSafeMimeType } from "../../../../../utils/blobs"; -import ContentMessages from "../../../../../ContentMessages"; import { isNotNull } from "../../../../../Typeguards"; +import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel"; export function focusComposer( composerElement: RefObject, @@ -123,13 +122,8 @@ export function handleEventWithAutocomplete( export function handleClipboardEvent( event: ClipboardEvent | InputEvent, data: DataTransfer | null, - roomContext: Pick, - mxClient: MatrixClient, - eventRelation?: IEventRelation, + vm: RoomUploadViewModel, ): boolean { - // Logic in this function follows that of `SendMessageComposer.onPaste` - const { room, timelineRenderingType, replyToEvent } = roomContext; - function handleError(error: unknown): void { if (error instanceof Error) { console.log(error.message); @@ -138,7 +132,7 @@ export function handleClipboardEvent( } } - if (event.type !== "paste" || data === null || room === undefined) { + if (event.type !== "paste" || data === null) { return false; } @@ -147,16 +141,7 @@ export function handleClipboardEvent( // We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer // it puts the filename in as text/plain which we want to ignore. if (data.files.length && !data.types.includes("text/rtf")) { - ContentMessages.sharedInstance() - .sendContentListToRoom( - Array.from(data.files), - room.roomId, - eventRelation, - roomContext.replyToEvent, - mxClient, - timelineRenderingType, - ) - .catch(handleError); + vm.initiateViaDataTransfer(data).catch(handleError); return true; } @@ -188,9 +173,7 @@ export function handleClipboardEvent( const parts = response.url.split("/"); const filename = parts[parts.length - 1]; const file = new File([imgBlob], filename + "." + ext, { type: safetype }); - ContentMessages.sharedInstance() - .sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent) - .catch(handleError); + return vm.initiateViaInputFiles([file]); }) .catch(handleError); }) diff --git a/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx new file mode 100644 index 0000000000..8b409e777b --- /dev/null +++ b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx @@ -0,0 +1,231 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { BaseViewModel, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; +import React, { + type ChangeEventHandler, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; +import { + type MatrixClient, + type Room, + type IEventRelation, + type MatrixEvent, + RoomEvent, +} from "matrix-js-sdk/src/matrix"; + +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext"; +import { useMatrixClientContext } from "../../contexts/MatrixClientContext"; +import ContentMessages from "../../ContentMessages"; +import type { TimelineRenderingType } from "../../contexts/RoomContext"; +import { chromeFileInputFix } from "../../utils/BrowserWorkarounds"; +import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; +import defaultDispatcher from "../../dispatcher/dispatcher"; + +const logger = rootLogger.getChild("RoomUploadViewModel"); + +export interface RoomUploadViewSnapshot { + mayUpload: boolean; +} + +export interface RoomUploadViewActions { + initiateViaInputFiles(files: FileList | null): Promise; + initiateViaDataTransfer(dataTransfer: DataTransfer): Promise; + openUploadDialog(): void; +} + +export class RoomUploadViewModel + extends BaseViewModel> + implements RoomUploadViewActions +{ + public constructor( + private readonly room: Room, + private readonly client: MatrixClient, + private readonly timelineRenderingType: TimelineRenderingType, + private readonly dispatcher: MatrixDispatcher, + private replyToEvent: MatrixEvent | undefined, + private threadRelation: IEventRelation | undefined, + public readonly openUploadDialog: () => void, + ) { + super( + {}, + { + mayUpload: room.maySendMessage(), + }, + ); + room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated); + this.disposables.track(() => { + room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated); + }); + } + + private onRoomCurrentStateUpdated = (): void => { + this.snapshot.merge({ + mayUpload: this.room.maySendMessage(), + }); + }; + + public setReplyToEvent = (replyToEvent?: MatrixEvent): void => { + this.replyToEvent = replyToEvent; + }; + + public setThreadRelation = (threadRelation?: IEventRelation): void => { + this.threadRelation = threadRelation; + }; + + public initiateViaInputFiles = async (files: FileList | File[] | null): Promise => { + if (!this.checkCanUpload()) { + return; + } + const { roomId } = this.room; + logger.info("initiateViaInputFiles for", roomId); + if (!files?.length) return; + + try { + await ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(files), + roomId, + this.threadRelation, + this.replyToEvent, + this.client, + this.timelineRenderingType, + ); + } catch (ex) { + logger.warn("Failed to handle file upload transfer", ex); + } + }; + + public initiateViaDataTransfer = async (dataTransfer: DataTransfer): Promise => { + if (!this.checkCanUpload()) { + return; + } + const { roomId } = this.room; + logger.info("initiateViaDataTransfer for", roomId); + if (!dataTransfer.files?.length) return; + + try { + await ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(dataTransfer.files), + roomId, + this.threadRelation, + this.replyToEvent, + this.client, + this.timelineRenderingType, + ); + } catch (ex) { + logger.warn("Failed to handle drag and drop data transfer", ex); + } + }; + + private checkCanUpload(): boolean { + if (this.client.isGuest()) { + this.dispatcher.dispatch({ action: "require_registration" }); + return false; + } + return true; + } +} + +export const RoomUploadContext = createContext(null); + +export function useRoomUploadViewModel(): RoomUploadViewModel { + const ctx = useContext(RoomUploadContext); + if (!ctx) { + throw new Error("RoomFileUploadProvider is not present"); + } + return ctx; +} + +export function RoomUploadContextProvider({ + children, + threadRelation, +}: { + children: ReactNode; + threadRelation?: IEventRelation; +}): ReactNode { + const { room, timelineRenderingType, replyToEvent } = useScopedRoomContext( + "room", + "timelineRenderingType", + "replyToEvent", + ); + const client = useMatrixClientContext(); + const uploadInput = useRef(null); + + const openFilePicker = useCallback((): void => { + if (!uploadInput.current) { + throw new Error("Input not ready"); + } + uploadInput.current.click(); + }, [uploadInput]); + + const vm = useCreateAutoDisposedViewModel(() => { + if (!room) { + throw new Error("RoomUploadContextProvider must have a room"); + } + return new RoomUploadViewModel( + room, + client, + timelineRenderingType, + defaultDispatcher, + replyToEvent, + threadRelation, + openFilePicker, + ); + }); + + useEffect(() => { + vm.setReplyToEvent(replyToEvent); + }, [vm, replyToEvent]); + + useEffect(() => { + vm.setThreadRelation(threadRelation); + }, [vm, threadRelation]); + + const onInputChange: ChangeEventHandler = useCallback( + (ev) => { + void (async () => { + try { + await vm.initiateViaInputFiles(ev.target.files); + } finally { + // This is the onChange handler for a file form control, but we're + // not keeping any state, so reset the value of the form control + // to empty. + // NB. we need to set 'value': the 'files' property is immutable. + ev.target.value = ""; + } + })(); + }, + [vm], + ); + + // Note, while this logic could be largely replaced with https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker + // it does not enjoy support across all our target platforms. + // Therefore, we use the invisible input element trick. + + return ( + + <> + {children} + + + + ); +} diff --git a/apps/web/test/test-utils/test-utils.ts b/apps/web/test/test-utils/test-utils.ts index 08fc1af63a..456a1e79b5 100644 --- a/apps/web/test/test-utils/test-utils.ts +++ b/apps/web/test/test-utils/test-utils.ts @@ -659,8 +659,8 @@ export function mkMessage({ export function mkStubRoom( roomId: string | null | undefined = null, - name: string | undefined, - client: MatrixClient | undefined, + name?: string | undefined, + client?: MatrixClient | undefined, state?: RoomState | undefined, ): Room { const stubTimeline = { diff --git a/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx b/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx index 20b02f9fa5..2950f66239 100644 --- a/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx +++ b/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx @@ -6,35 +6,62 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { mocked } from "jest-mock"; import { render, fireEvent } from "jest-matrix-react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { useMockedViewModel } from "@element-hq/web-shared-components"; import FileDropTarget from "../../../../src/components/structures/FileDropTarget.tsx"; -import { stubClient } from "../../../test-utils"; +import { + RoomUploadContext, + type RoomUploadViewActions, + type RoomUploadViewModel, + type RoomUploadViewSnapshot, +} from "../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; + +function FileDropTargetWrapped({ + element, + snapshot, + actions, +}: { + element: HTMLDivElement; + snapshot: RoomUploadViewSnapshot; + actions: Partial; +}) { + const mockVm = useMockedViewModel( + snapshot, + actions as RoomUploadViewActions, + ); + return ( + + + + ); +} describe("FileDropTarget", () => { - let room: Room; - beforeEach(() => { - const client = stubClient(); - room = new Room("!roomId:example.com", client, client.getUserId()!); - room.currentState.maySendMessage = jest.fn().mockReturnValue(true); - }); - it("should render nothing when idle", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); - const { asFragment } = render(); + const { asFragment } = render( + , + ); expect(asFragment()).toMatchSnapshot(); }); it("should render drop file prompt on mouse over with file if permissions allow", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); - mocked(room.currentState.maySendMessage).mockReturnValue(true); - - const { asFragment } = render(); + const { asFragment } = render( + , + ); fireEvent.dragEnter(element, { dataTransfer: { types: ["Files"], @@ -46,9 +73,13 @@ describe("FileDropTarget", () => { it("should not render drop file prompt on mouse over with file if permissions do not allow", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); - mocked(room.currentState.maySendMessage).mockReturnValue(false); - - const { asFragment } = render(); + const { asFragment } = render( + , + ); fireEvent.dragEnter(element, { dataTransfer: { types: ["Files"], diff --git a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 0a6b8d1acc..d093f4da0e 100644 --- a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -270,6 +270,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
+ `; @@ -638,16 +644,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = /> - + `; @@ -1010,16 +1017,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t /> - + `; @@ -1320,6 +1328,12 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = ` /> + @@ -1621,15 +1635,16 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = ` /> - + @@ -2096,15 +2111,16 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa /> - + @@ -2571,15 +2587,16 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = ` /> - + @@ -2794,6 +2811,12 @@ exports[`RoomView should not display the timeline when the room encryption is lo /> + @@ -3288,15 +3311,16 @@ exports[`RoomView should not display the timeline when the room encryption is lo /> - + @@ -3844,15 +3868,16 @@ exports[`RoomView video rooms should render joined video room view 1`] = ` /> - +
diff --git a/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx index bfd9c88448..078b8c97c2 100644 --- a/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx @@ -36,6 +36,10 @@ import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore"; import { Action } from "../../../../../src/dispatcher/actions"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; +import { + RoomUploadContext, + type RoomUploadViewModel, +} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; const openStickerPicker = async (): Promise => { await userEvent.click(screen.getByLabelText("More options")); @@ -469,7 +473,9 @@ function wrapAndRender( const getRawComponent = (props = {}, context = roomContext, client = mockClient) => ( - + + + ); diff --git a/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx b/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx index 58a58185d0..0f29c546ec 100644 --- a/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx @@ -14,7 +14,8 @@ import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-u import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; -import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; +import { type RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; +import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; describe("MessageComposerButtons", () => { // @ts-ignore - we're deliberately not implementing the whole interface here, but @@ -54,7 +55,9 @@ describe("MessageComposerButtons", () => { return render( - {component} + + {component} + , ); } diff --git a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index c6e5109406..2d616027ff 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -31,6 +31,7 @@ import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room"; import { addTextToComposer } from "../../../../test-utils/composer"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts"; +import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; jest.mock("../../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -187,8 +188,10 @@ describe("", () => { }; const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => ( - - + + + + ); @@ -435,7 +438,11 @@ describe("", () => { const { container } = render( - + + + + + , ); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index 3293829686..fd47666269 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -27,6 +27,7 @@ import { type ActionPayload } from "../../../../../../src/dispatcher/payloads"; import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton"; import { createMocks } from "./utils"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; beforeAll(initOnce, 10000); @@ -46,7 +47,9 @@ describe("EditWysiwygComposer", () => { return render( - + + + , ); @@ -62,7 +65,9 @@ describe("EditWysiwygComposer", () => { rerender( - + + + , ); @@ -273,7 +278,9 @@ describe("EditWysiwygComposer", () => { render( - + + + , diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 528615939a..0116c6e8e3 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -25,6 +25,7 @@ import { setSelection } from "../../../../../../src/components/views/rooms/wysiw import { createMocks } from "./utils"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts"; +import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({ EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { @@ -77,15 +78,17 @@ describe("SendWysiwygComposer", () => { return render( - + + + , ); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index ce4eb8634f..0a98d04969 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -33,6 +33,7 @@ import type AutocompleteProvider from "../../../../../../../src/autocomplete/Aut import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks"; import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor"; import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; beforeAll(initOnce, 10000); @@ -42,12 +43,14 @@ describe("WysiwygComposer", () => { return render( - + + + , ); @@ -561,19 +564,21 @@ describe("WysiwygComposer", () => { return render( - - - + + + + + , ); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx index 3627513c20..ff8c622426 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx @@ -5,29 +5,21 @@ Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { type IEventRelation, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { waitFor } from "jest-matrix-react"; import fetchMock from "@fetch-mock/jest"; -import { TimelineRenderingType } from "../../../../../../../src/contexts/RoomContext"; -import { mkStubRoom, stubClient } from "../../../../../../test-utils"; -import ContentMessages from "../../../../../../../src/ContentMessages"; -import { type IRoomState } from "../../../../../../../src/components/structures/RoomView"; import { handleClipboardEvent, isEventToHandleAsClipboardEvent, } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils"; +import type { RoomUploadViewModel } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel"; +import type { MockedObject } from "jest-mock"; -const mockClient = stubClient(); -const mockRoom = mkStubRoom("mock room", "mock room", mockClient); -const mockRoomState = { - room: mockRoom, - timelineRenderingType: TimelineRenderingType.Room, - replyToEvent: {} as unknown as MatrixEvent, -} as unknown as IRoomState; +const mockUploadVM = { + initiateViaDataTransfer: jest.fn().mockResolvedValue(undefined), + initiateViaInputFiles: jest.fn().mockResolvedValue(undefined), +} as Partial as MockedObject; -const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom"); -const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom"); const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); describe("handleClipboardEvent", () => { @@ -45,29 +37,16 @@ describe("handleClipboardEvent", () => { it("returns false if it is not a paste event", () => { const originalEvent = createMockClipboardEvent({ type: "copy" }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); - + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(output).toBe(false); + expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled(); }); it("returns false if clipboard data is null", () => { const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); - - expect(output).toBe(false); - }); - - it("returns false if room is undefined", () => { - const originalEvent = createMockClipboardEvent({ type: "paste" }); - const { room, ...roomStateWithoutRoom } = mockRoomState; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - roomStateWithoutRoom, - mockClient, - ); - + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(output).toBe(false); + expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled(); }); it("returns false if room clipboardData files and types are empty", () => { @@ -75,8 +54,9 @@ describe("handleClipboardEvent", () => { type: "paste", clipboardData: { files: [], types: [] }, }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(output).toBe(false); + expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled(); }); it("handles event and calls sendContentListToRoom when data files are present", () => { @@ -84,65 +64,23 @@ describe("handleClipboardEvent", () => { type: "paste", clipboardData: { files: ["something here"], types: [] }, }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); - const mockReplyToEvent = {} as unknown as MatrixEvent; - expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); - expect(sendContentListToRoomSpy).toHaveBeenCalledWith( - originalEvent.clipboardData?.files, - mockRoom.roomId, - undefined, // this is the event relation, an optional arg - mockReplyToEvent, - mockClient, - mockRoomState.timelineRenderingType, - ); - expect(output).toBe(true); - }); - - it("calls sendContentListToRoom with eventRelation when present", () => { - const originalEvent = createMockClipboardEvent({ - type: "paste", - clipboardData: { files: ["something here"], types: [] }, - }); - const mockEventRelation = {} as unknown as IEventRelation; - const mockReplyToEvent = {} as unknown as MatrixEvent; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); - - expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); - expect(sendContentListToRoomSpy).toHaveBeenCalledWith( - originalEvent.clipboardData?.files, - mockRoom.roomId, - mockEventRelation, // this is the event relation, an optional arg - mockReplyToEvent, - mockClient, - mockRoomState.timelineRenderingType, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); + expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1); + expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledWith(originalEvent.clipboardData); expect(output).toBe(true); }); it("calls the error handler when sentContentListToRoom errors", async () => { const mockErrorMessage = "something went wrong"; - sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage)); + mockUploadVM.initiateViaDataTransfer.mockRejectedValueOnce(new Error(mockErrorMessage)); const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: { files: ["something here"], types: [] }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); - expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); + expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1); await waitFor(() => { expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); }); @@ -158,15 +96,7 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue("
invalid html"), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); - + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content"); expect(output).toBe(false); }); @@ -180,10 +110,10 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(fetchMock).toHaveFetchedTimes(1, "blob:"); + expect(output).toBe(true); }); it("calls error handler when fetch fails", async () => { @@ -197,14 +127,7 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); await waitFor(() => { expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); @@ -212,7 +135,7 @@ describe("handleClipboardEvent", () => { expect(output).toBe(true); }); - it("calls sendContentToRoom when parsing is successful", async () => { + it("calls initiateViaInputFiles when parsing is successful", async () => { fetchMock.get("test/file", { blob: () => { return Promise.resolve({ type: "image/jpeg" } as Blob); @@ -227,23 +150,11 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); await waitFor(() => { - expect(sendContentToRoomSpy).toHaveBeenCalledWith( - expect.any(File), - mockRoom.roomId, - mockEventRelation, - mockClient, - mockRoomState.replyToEvent, - ); + expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledTimes(1); + expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledWith([expect.any(File)]); }); expect(output).toBe(true); }); @@ -254,8 +165,8 @@ describe("handleClipboardEvent", () => { return Promise.resolve({ type: "image/jpeg" } as Blob); }, }); - const mockErrorMessage = "sendContentToRoom failed"; - sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage); + const mockErrorMessage = "initiateViaInputFiles failed"; + mockUploadVM.initiateViaInputFiles.mockRejectedValueOnce(mockErrorMessage); const originalEvent = createMockClipboardEvent({ type: "paste", @@ -265,14 +176,7 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); await waitFor(() => { expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); diff --git a/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts b/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts new file mode 100644 index 0000000000..837eaacc62 --- /dev/null +++ b/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type IEventRelation, type MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; + +import type { MockedObject } from "jest-mock"; +import { RoomUploadViewModel } from "../../../src/viewmodels/room/RoomUploadViewModel"; +import { mkEvent, mkStubRoom, stubClient } from "../../test-utils"; +import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; +import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import ContentMessages from "../../../src/ContentMessages"; +const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom"); + +describe("RoomUploadViewModel", () => { + let client: MockedObject; + let room: MockedObject; + let dis: MockedObject; + beforeEach(() => { + jest.clearAllMocks(); + client = stubClient() as MockedObject; + room = mkStubRoom("!room", undefined, undefined) as MockedObject; + dis = { + dispatch: jest.fn(), + } as Partial as MockedObject; + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it.each([true, false])("handles state of mayUpload when room.maySendMessage = %s", (maySendMessage) => { + room.maySendMessage.mockReturnValue(maySendMessage); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + expect(vm.getSnapshot().mayUpload).toEqual(maySendMessage); + room.maySendMessage.mockReturnValue(!maySendMessage); + room.emit(RoomEvent.CurrentStateUpdated, room, null as any, null as any); + expect(vm.getSnapshot().mayUpload).toEqual(!maySendMessage); + }); + + describe("uploads via input", () => { + it("redirected if guest", async () => { + client.isGuest.mockReturnValue(true); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaInputFiles([] as unknown as FileList); + expect(dis.dispatch).toHaveBeenCalledWith({ action: "require_registration" }); + }); + it("skips empty files", async () => { + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaInputFiles([] as unknown as FileList); + expect(dis.dispatch).not.toHaveBeenCalled(); + }); + it("uploads with correct context", async () => { + sendContentListToRoomSpy.mockResolvedValue(undefined); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Thread, + dis, + undefined, + undefined, + () => {}, + ); + const replyEvent = mkEvent({ event: true, type: "anything", user: "anyone", content: {} }); + vm.setReplyToEvent(replyEvent); + const threadRelation: IEventRelation = { key: "foo" }; + vm.setThreadRelation(threadRelation); + const fileList = [ + { + name: "fake.png", + size: 1024, + type: "image/png", + }, + ] as unknown as FileList; + await vm.initiateViaInputFiles(fileList); + expect(sendContentListToRoomSpy).toHaveBeenCalledWith( + fileList, + room.roomId, + threadRelation, + replyEvent, + client, + TimelineRenderingType.Thread, + ); + }); + }); + + describe("uploads via data transfer", () => { + it("redirected if guest", async () => { + client.isGuest.mockReturnValue(true); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaDataTransfer({} as DataTransfer); + expect(dis.dispatch).toHaveBeenCalledWith({ action: "require_registration" }); + }); + it("skips empty files", async () => { + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaDataTransfer({ files: [] as unknown as FileList } as DataTransfer); + expect(dis.dispatch).not.toHaveBeenCalled(); + }); + it("uploads with correct context", async () => { + sendContentListToRoomSpy.mockResolvedValue(undefined); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Thread, + dis, + undefined, + undefined, + () => {}, + ); + const replyEvent = mkEvent({ event: true, type: "anything", user: "anyone", content: {} }); + vm.setReplyToEvent(replyEvent); + const threadRelation: IEventRelation = { key: "foo" }; + vm.setThreadRelation(threadRelation); + const files = [ + { + name: "fake.png", + size: 1024, + type: "image/png", + }, + ] as unknown as FileList; + await vm.initiateViaDataTransfer({ files } as DataTransfer); + expect(sendContentListToRoomSpy).toHaveBeenCalledWith( + files, + room.roomId, + threadRelation, + replyEvent, + client, + TimelineRenderingType.Thread, + ); + }); + }); +});