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/
This commit is contained in:
Will Hunt
2026-05-12 12:30:30 +01:00
committed by GitHub
parent 39607799de
commit aeaeb55cda
26 changed files with 768 additions and 490 deletions
@@ -751,7 +751,7 @@ test.describe("Timeline", () => {
await expect(page.locator(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded")).toBeVisible(); 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 page.goto(`/#/room/${room.roomId}`);
await expect( await expect(
page page
@@ -760,12 +760,7 @@ test.describe("Timeline", () => {
).toBeVisible(); ).toBeVisible();
// Upload a file from the message composer // Upload a file from the message composer
await page await app.composerUploadFiles("room", getSampleFilePath("matrix-org-client-versions.json"));
.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();
// Wait until the file is sent // Wait until the file is sent
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible(); await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
+1 -2
View File
@@ -169,8 +169,7 @@ export class ElementAppPage {
): ReturnType<Locator["setInputFiles"]> { ): ReturnType<Locator["setInputFiles"]> {
const input = this.page const input = this.page
.locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel") .locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel")
.getByRole("region", { name: "Message composer" }) .getByTestId("room-upload-context-input");
.locator("input[type='file']");
return input.setInputFiles(...params); return input.setInputFiles(...params);
} }
+1 -1
View File
@@ -607,7 +607,7 @@ export default class ContentMessages {
throw e; throw e;
} }
// Otherwise we failed to thumbnail, fall back to uploading an m.file // 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; content.msgtype = MsgType.File;
} }
} else if (file.type.startsWith("audio/")) { } else if (file.type.startsWith("audio/")) {
@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. 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 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 { _t } from "../../languageHandler";
import UploadBigSvg from "../../../res/img/upload-big.svg"; import UploadBigSvg from "../../../res/img/upload-big.svg";
import { useRoomState } from "../../hooks/useRoomState.ts"; import { useRoomUploadViewModel } from "../../viewmodels/room/RoomUploadViewModel";
interface IProps { interface IProps {
room: Room;
parent: HTMLElement | null; parent: HTMLElement | null;
onFileDrop(this: void, dataTransfer: DataTransfer): void;
} }
interface IState { interface IState {
@@ -24,15 +23,16 @@ interface IState {
counter: number; counter: number;
} }
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => { const FileDropTarget: React.FC<IProps> = ({ parent }) => {
const [state, setState] = useState<IState>({ const [state, setState] = useState<IState>({
dragging: false, dragging: false,
counter: 0, counter: 0,
}); });
const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!)); const vm = useRoomUploadViewModel();
const { mayUpload } = useViewModel(vm);
useEffect(() => { useEffect(() => {
if (!hasPermission || !parent || parent.ondrop) return; if (!mayUpload || !parent || parent.ondrop) return;
const onDragEnter = (ev: DragEvent): void => { const onDragEnter = (ev: DragEvent): void => {
ev.stopPropagation(); ev.stopPropagation();
@@ -83,7 +83,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
if (!ev.dataTransfer) return; if (!ev.dataTransfer) return;
onFileDrop(ev.dataTransfer); void vm.initiateViaDataTransfer(ev.dataTransfer);
setState((state) => ({ setState((state) => ({
dragging: false, dragging: false,
@@ -106,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
parent?.removeEventListener("dragenter", onDragEnter); parent?.removeEventListener("dragenter", onDragEnter);
parent?.removeEventListener("dragleave", onDragLeave); parent?.removeEventListener("dragleave", onDragLeave);
}; };
}, [parent, onFileDrop, hasPermission]); }, [parent, mayUpload, vm]);
if (hasPermission && state.dragging) { if (mayUpload && state.dragging) {
return ( return (
<div className="mx_FileDropTarget"> <div className="mx_FileDropTarget">
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" /> <img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
+17 -33
View File
@@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts"; import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts"; import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
import { ModuleApi } from "../../modules/Api.ts"; import { ModuleApi } from "../../modules/Api.ts";
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider"; import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
const DEBUG = false; const DEBUG = false;
@@ -302,7 +303,6 @@ interface LocalRoomViewProps {
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
roomView: RefObject<HTMLElement | null>; roomView: RefObject<HTMLElement | null>;
onFileDrop: (dataTransfer: DataTransfer) => Promise<void>;
mainSplitContentType: MainSplitContentType; mainSplitContentType: MainSplitContentType;
e2eStatus?: E2EStatus; e2eStatus?: E2EStatus;
} }
@@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
<div className="mx_RoomView mx_RoomView--local"> <div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary> <ErrorBoundary>
<RoomHeader room={room} /> <RoomHeader room={room} />
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}> <RoomUploadContextProvider>
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} /> <main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
<div className="mx_RoomView_timeline"> <FileDropTarget parent={props.roomView.current} />
<ScrollPanel className="mx_RoomView_messagePanel"> <div className="mx_RoomView_timeline">
{encryptionTile} <ScrollPanel className="mx_RoomView_messagePanel">
<NewRoomIntro /> {encryptionTile}
</ScrollPanel> <NewRoomIntro />
</div> </ScrollPanel>
{statusBar} </div>
{composer} {statusBar}
</main> {composer}
</main>
</RoomUploadContextProvider>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
); );
@@ -2121,19 +2123,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}); });
} }
private onFileDrop = async (dataTransfer: DataTransfer): Promise<void> => {
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 => { private onMeasurement = (narrow: boolean): void => {
this.setState({ narrow }); this.setState({ narrow });
}; };
@@ -2169,7 +2158,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
resizeNotifier={this.context.resizeNotifier} resizeNotifier={this.context.resizeNotifier}
permalinkCreator={this.permalinkCreator} permalinkCreator={this.permalinkCreator}
roomView={this.roomView} roomView={this.roomView}
onFileDrop={this.onFileDrop}
mainSplitContentType={this.state.mainSplitContentType} mainSplitContentType={this.state.mainSplitContentType}
/> />
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
@@ -2673,16 +2661,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
case MainSplitContentType.Timeline: case MainSplitContentType.Timeline:
mainSplitContentClassName = "mx_MainSplit_timeline"; mainSplitContentClassName = "mx_MainSplit_timeline";
mainSplitBody = ( mainSplitBody = (
<> <RoomUploadContextProvider>
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} /> <Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
{auxPanel} {auxPanel}
{pinnedMessageBanner} {pinnedMessageBanner}
<main className={timelineClasses} data-testid="timeline"> <main className={timelineClasses} data-testid="timeline">
<FileDropTarget <FileDropTarget parent={this.roomView.current} />
parent={this.roomView.current}
onFileDrop={this.onFileDrop}
room={this.state.room}
/>
{topUnreadMessagesBar} {topUnreadMessagesBar}
{jumpToBottom} {jumpToBottom}
{messagePanel} {messagePanel}
@@ -2691,7 +2675,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{statusBarArea} {statusBarArea}
{previewBar} {previewBar}
{messageComposer} {messageComposer}
</> </RoomUploadContextProvider>
); );
break; break;
case MainSplitContentType.MaximisedWidget: case MainSplitContentType.MaximisedWidget:
@@ -29,7 +29,6 @@ import TimelinePanel from "./TimelinePanel";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { type ActionPayload } from "../../dispatcher/payloads"; import { type ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { type E2EStatus } from "../../utils/ShieldUtils"; import { type E2EStatus } from "../../utils/ShieldUtils";
import EditorStateTransfer from "../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../utils/EditorStateTransfer";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
@@ -51,6 +50,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo
import Heading from "../views/typography/Heading"; import Heading from "../views/typography/Heading";
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload"; import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider"; import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
interface IProps { interface IProps {
@@ -329,22 +329,6 @@ export default class ThreadView extends React.Component<IProps, IState> {
} }
}; };
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 { private get threadRelation(): IEventRelation {
const relation: IEventRelation = { const relation: IEventRelation = {
rel_type: THREAD_RELATION_TYPE.name, rel_type: THREAD_RELATION_TYPE.name,
@@ -393,7 +377,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
timeline = ( timeline = (
<> <>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} /> <FileDropTarget parent={this.card.current} />
<EventPresentationContextProvider layout={layout}> <EventPresentationContextProvider layout={layout}>
<TimelinePanel <TimelinePanel
key={this.state.thread.id} key={this.state.thread.id}
@@ -437,38 +421,40 @@ export default class ThreadView extends React.Component<IProps, IState> {
liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()} liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()}
narrow={this.state.narrow} narrow={this.state.narrow}
> >
<BaseCard <RoomUploadContextProvider threadRelation={this.threadRelation}>
className={classNames("mx_ThreadView mx_ThreadPanel", { <BaseCard
mx_ThreadView_narrow: this.state.narrow, className={classNames("mx_ThreadView mx_ThreadPanel", {
})} mx_ThreadView_narrow: this.state.narrow,
onClose={this.props.onClose} })}
withoutScrollContainer={true} onClose={this.props.onClose}
header={this.renderThreadViewHeader()} withoutScrollContainer={true}
ref={this.card} header={this.renderThreadViewHeader()}
onKeyDown={this.onKeyDown} ref={this.card}
onBack={(ev: ButtonEvent) => { onKeyDown={this.onKeyDown}
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev); onBack={(ev: ButtonEvent) => {
}} PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
> }}
<Measured sensor={this.card} onMeasurement={this.onMeasurement} /> >
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div> <Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( {ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} /> <UploadBar room={this.props.room} relation={threadRelation} />
)} )}
{this.state.thread?.timelineSet && ( {this.state.thread?.timelineSet && (
<MessageComposer <MessageComposer
room={this.props.room} room={this.props.room}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
relation={threadRelation} relation={threadRelation}
replyToEvent={this.state.replyToEvent} replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus} e2eStatus={this.props.e2eStatus}
compact={true} compact={true}
/> />
)} )}
</BaseCard> </BaseCard>
</RoomUploadContextProvider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
); );
} }
@@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import Measured from "../elements/Measured"; import Measured from "../elements/Measured";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
import { RoomUploadContextProvider } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider"; import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
interface IProps { interface IProps {
@@ -214,47 +215,49 @@ export default class TimelineCard extends React.Component<IProps, IState> {
header={_t("right_panel|video_room_chat|title")} header={_t("right_panel|video_room_chat|title")}
ref={this.card} ref={this.card}
> >
<Measured sensor={this.card} onMeasurement={this.onMeasurement} /> <RoomUploadContextProvider>
<div className="mx_TimelineCard_timeline"> <Measured sensor={this.card} onMeasurement={this.onMeasurement} />
{jumpToBottom} <div className="mx_TimelineCard_timeline">
<EventPresentationContextProvider layout={layout}> {jumpToBottom}
<TimelinePanel <EventPresentationContextProvider layout={layout}>
ref={this.timelinePanel} <TimelinePanel
showReadReceipts={this.state.showReadReceipts} ref={this.timelinePanel}
manageReadReceipts={true} showReadReceipts={this.state.showReadReceipts}
manageReadMarkers={false} // No RM support in the TimelineCard manageReadReceipts={true}
sendReadReceiptOnLoad={true} manageReadMarkers={false} // No RM support in the TimelineCard
timelineSet={this.props.timelineSet} sendReadReceiptOnLoad={true}
showUrlPreview={this.context.showUrlPreview} timelineSet={this.props.timelineSet}
// The right panel timeline (and therefore threads) don't support IRC layout at this time showUrlPreview={this.context.showUrlPreview}
layout={layout} // The right panel timeline (and therefore threads) don't support IRC layout at this time
hideThreadedMessages={false} layout={layout}
hidden={false} hideThreadedMessages={false}
showReactions={true} hidden={false}
className="mx_RoomView_messagePanel" showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
/>
</EventPresentationContextProvider>
</div>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
{showComposer && (
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
membersLoaded={true} e2eStatus={this.props.e2eStatus}
editState={this.state.editState} compact={true}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
/> />
</EventPresentationContextProvider> )}
</div> </RoomUploadContextProvider>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
{showComposer && (
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
</BaseCard> </BaseCard>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
); );
@@ -1,4 +1,5 @@
/* /*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
@@ -14,7 +15,7 @@ import {
THREAD_RELATION_TYPE, THREAD_RELATION_TYPE,
M_POLL_START, M_POLL_START,
} from "matrix-js-sdk/src/matrix"; } 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 { import {
AttachmentIcon, AttachmentIcon,
MicOnIcon, MicOnIcon,
@@ -27,22 +28,19 @@ import {
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { CollapsibleButton } from "./CollapsibleButton"; import { CollapsibleButton } from "./CollapsibleButton";
import { type MenuProps } from "../../structures/ContextMenu"; import { type MenuProps } from "../../structures/ContextMenu";
import dis from "../../../dispatcher/dispatcher";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import { LocationButton } from "../location"; import { LocationButton } from "../location";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog"; import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import ContentMessages from "../../../ContentMessages";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import { EmojiButton } from "./EmojiButton"; import { EmojiButton } from "./EmojiButton";
import { filterBoolean } from "../../../utils/arrays"; import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings"; import { useSettingValue } from "../../../hooks/useSettings";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
interface IProps { interface IProps {
addEmoji: (emoji: string) => boolean; addEmoji: (emoji: string) => boolean;
@@ -126,7 +124,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
}); });
return ( return (
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}> <>
{mainButtons} {mainButtons}
{moreButtons.length > 0 && ( {moreButtons.length > 0 && (
<AccessibleButton <AccessibleButton
@@ -149,7 +147,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
</OverflowMenuContext.Provider> </OverflowMenuContext.Provider>
</IconizedContextMenu> </IconizedContextMenu>
)} )}
</UploadButtonContextProvider> </>
); );
}; };
@@ -168,79 +166,13 @@ function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />; return <UploadButton key="controls_upload" />;
} }
type UploadButtonFn = () => void;
export const UploadButtonContext = createContext<UploadButtonFn | null>(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<IUploadButtonProps> = ({ roomId, relation, children }) => {
const cli = useContext(MatrixClientContext);
const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent");
const uploadInput = useRef<HTMLInputElement>(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<HTMLInputElement>): 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 (
<UploadButtonContext.Provider value={onUploadClick}>
{children}
<input
ref={uploadInput}
type="file"
style={uploadInputStyle}
multiple
onClick={chromeFileInputFix}
onChange={onUploadFileInputChange}
/>
</UploadButtonContext.Provider>
);
};
// Must be rendered within an UploadButtonContextProvider // Must be rendered within an UploadButtonContextProvider
const UploadButton: React.FC = () => { const UploadButton: React.FC = () => {
const overflowMenuCloser = useContext(OverflowMenuContext); const overflowMenuCloser = useContext(OverflowMenuContext);
const uploadButtonFn = useContext(UploadButtonContext); const vm = useRoomUploadViewModel();
const onClick = (): void => { const onClick = (): void => {
uploadButtonFn?.(); vm.openUploadDialog();
overflowMenuCloser?.(); // close overflow menu overflowMenuCloser?.(); // close overflow menu
}; };
@@ -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. 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 { import {
type MatrixEvent, type MatrixEvent,
type IEventRelation, type IEventRelation,
@@ -37,8 +43,7 @@ import { CommandPartCreator, type Part, type PartCreator, type SerializedPart }
import { findEditableEvent } from "../../../utils/EventUtils"; import { findEditableEvent } from "../../../utils/EventUtils";
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from "../../../slash-commands/SlashCommands"; import { CommandCategories } from "../../../slash-commands/SlashCommands";
import ContentMessages from "../../../ContentMessages"; import { useMatrixClientContext, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { containsEmoji } from "../../../effects/utils"; import { containsEmoji } from "../../../effects/utils";
import { CHAT_EFFECTS } from "../../../effects"; import { CHAT_EFFECTS } from "../../../effects";
@@ -60,6 +65,7 @@ import { type IDiff } from "../../../editor/diff";
import { getBlobSafeMimeType } from "../../../utils/blobs"; import { getBlobSafeMimeType } from "../../../utils/blobs";
import { EMOJI_REGEX } from "../../../HtmlUtils"; import { EMOJI_REGEX } from "../../../HtmlUtils";
import { attachMentions, attachRelation } from "../../../utils/messages"; import { attachMentions, attachRelation } from "../../../utils/messages";
import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel";
// The prefix used when persisting editor drafts to localstorage. // The prefix used when persisting editor drafts to localstorage.
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
@@ -124,6 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean {
interface ISendMessageComposerProps extends MatrixClientProps { interface ISendMessageComposerProps extends MatrixClientProps {
room: Room; room: Room;
uploadVm: RoomUploadViewModel;
placeholder?: string; placeholder?: string;
relation?: IEventRelation; relation?: IEventRelation;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
@@ -561,14 +568,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer // 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. // it puts the filename in as text/plain which we want to ignore.
if (data.files.length && !data.types.includes("text/rtf")) { if (data.files.length && !data.types.includes("text/rtf")) {
ContentMessages.sharedInstance().sendContentListToRoom( this.props.uploadVm.initiateViaDataTransfer(data);
Array.from(data.files),
this.props.room.roomId,
this.props.relation,
this.context.replyToEvent,
this.props.mxClient,
this.context.timelineRenderingType,
);
return true; // to skip internal onPaste handler return true; // to skip internal onPaste handler
} }
@@ -601,13 +601,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
const parts = response.url.split("/"); const parts = response.url.split("/");
const filename = parts[parts.length - 1]; const filename = parts[parts.length - 1];
const file = new File([imgBlob], filename + "." + ext, { type: safetype }); const file = new File([imgBlob], filename + "." + ext, { type: safetype });
ContentMessages.sharedInstance().sendContentToRoom( this.props.uploadVm.initiateViaInputFiles([file]);
file,
this.props.room.roomId,
this.props.relation,
this.props.mxClient,
this.context.replyToEvent,
);
}, },
(error) => { (error) => {
console.log(error); console.log(error);
@@ -660,5 +654,13 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
} }
} }
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer); function SendMessageComposerWrapped(
export default SendMessageComposerWithMatrixClient; props: Omit<ISendMessageComposerProps, "mxClient" | "uploadVm"> &
RefAttributes<InstanceType<typeof SendMessageComposer>>,
): ReactElement {
const client = useMatrixClientContext();
const uploadVm = useRoomUploadViewModel();
return <SendMessageComposer {...props} mxClient={client} uploadVm={uploadVm} />;
}
export default SendMessageComposerWrapped;
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import classNames from "classnames"; import classNames from "classnames";
import { type IEventRelation } from "matrix-js-sdk/src/matrix"; 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 { useComposerFunctions } from "../hooks/useComposerFunctions";
import { useIsFocused } from "../hooks/useIsFocused"; import { useIsFocused } from "../hooks/useIsFocused";
@@ -19,6 +19,7 @@ import { type ComposerFunctions } from "../types";
import { Editor } from "./Editor"; import { Editor } from "./Editor";
import { WysiwygAutocomplete } from "./WysiwygAutocomplete"; import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
import { useSettingValue } from "../../../../../hooks/useSettings"; import { useSettingValue } from "../../../../../hooks/useSettings";
import { RoomUploadContext } from "../../../../../viewmodels/room/RoomUploadViewModel";
interface PlainTextComposerProps { interface PlainTextComposerProps {
disabled?: boolean; disabled?: boolean;
@@ -46,6 +47,7 @@ export function PlainTextComposer({
eventRelation, eventRelation,
}: PlainTextComposerProps): JSX.Element { }: PlainTextComposerProps): JSX.Element {
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
const uploadContext = useContext(RoomUploadContext);
const { const {
ref: editorRef, ref: editorRef,
autocompleteRef, autocompleteRef,
@@ -61,7 +63,7 @@ export function PlainTextComposer({
handleMention, handleMention,
handleAtRoomMention, handleAtRoomMention,
handleEmoji, handleEmoji,
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled); } = usePlainTextListeners(initialContent, onChange, onSend, isAutoReplaceEmojiEnabled, uploadContext || undefined);
const composerFunctions = useComposerFunctions(editorRef, setContent); const composerFunctions = useComposerFunctions(editorRef, setContent);
usePlainTextInitialization(initialContent, editorRef); usePlainTextInitialization(initialContent, editorRef);
useSetCursorPosition(disabled, editorRef); useSetCursorPosition(disabled, editorRef);
@@ -60,7 +60,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
const { room } = useScopedRoomContext("room"); const { room } = useScopedRoomContext("room");
const autocompleteRef = useRef<Autocomplete | null>(null); const autocompleteRef = useRef<Autocomplete | null>(null);
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation); const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent);
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]); const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);
@@ -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 { type Wysiwyg, type WysiwygEvent } from "@vector-im/matrix-wysiwyg";
import { useCallback } from "react"; 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 { useSettingValue } from "../../../../../hooks/useSettings";
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
@@ -26,13 +26,14 @@ import { endEditing } from "../utils/editing";
import type Autocomplete from "../../Autocomplete"; import type Autocomplete from "../../Autocomplete";
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
export function useInputEventProcessor( export function useInputEventProcessor(
onSend: () => void, onSend: () => void,
autocompleteRef: React.RefObject<Autocomplete | null>, autocompleteRef: React.RefObject<Autocomplete | null>,
initialContent?: string, initialContent?: string,
eventRelation?: IEventRelation,
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { ): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
const roomUploadVm = useRoomUploadViewModel();
const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType"); const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType");
const composerContext = useComposerContext(); const composerContext = useComposerContext();
const mxClient = useMatrixClientContext(); const mxClient = useMatrixClientContext();
@@ -52,7 +53,7 @@ export function useInputEventProcessor(
if (isEventToHandleAsClipboardEvent(event)) { if (isEventToHandleAsClipboardEvent(event)) {
const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer; 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; return handled ? null : event;
} }
@@ -77,11 +78,11 @@ export function useInputEventProcessor(
isCtrlEnterToSend, isCtrlEnterToSend,
onSend, onSend,
initialContent, initialContent,
roomUploadVm,
roomContext, roomContext,
composerContext, composerContext,
mxClient, mxClient,
autocompleteRef, autocompleteRef,
eventRelation,
], ],
); );
} }
@@ -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 KeyboardEvent, type RefObject, type SyntheticEvent, useCallback, useRef, useState } from "react";
import { type AllowedMentionAttributes, type MappedSuggestion } from "@vector-im/matrix-wysiwyg"; 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 { useSettingValue } from "../../../../../hooks/useSettings";
import { IS_MAC, Key } from "../../../../../Keyboard"; import { IS_MAC, Key } from "../../../../../Keyboard";
@@ -16,8 +15,7 @@ import type Autocomplete from "../../Autocomplete";
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
import { useSuggestion } from "./useSuggestion"; import { useSuggestion } from "./useSuggestion";
import { isNotNull, isNotUndefined } from "../../../../../Typeguards"; import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
function isDivElement(target: EventTarget): target is HTMLDivElement { function isDivElement(target: EventTarget): target is HTMLDivElement {
return target instanceof HTMLDivElement; return target instanceof HTMLDivElement;
@@ -46,8 +44,8 @@ export function usePlainTextListeners(
initialContent?: string, initialContent?: string,
onChange?: (content: string) => void, onChange?: (content: string) => void,
onSend?: () => void, onSend?: () => void,
eventRelation?: IEventRelation,
isAutoReplaceEmojiEnabled?: boolean, isAutoReplaceEmojiEnabled?: boolean,
roomUploadVM?: RoomUploadViewModel,
): { ): {
ref: RefObject<HTMLDivElement | null>; ref: RefObject<HTMLDivElement | null>;
autocompleteRef: RefObject<Autocomplete | null>; autocompleteRef: RefObject<Autocomplete | null>;
@@ -64,9 +62,6 @@ export function usePlainTextListeners(
onSelect: (this: void, event: SyntheticEvent<HTMLDivElement>) => void; onSelect: (this: void, event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null; suggestion: MappedSuggestion | null;
} { } {
const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent");
const mxClient = useMatrixClientContext();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<Autocomplete>(null); const autocompleteRef = useRef<Autocomplete>(null);
const [content, setContent] = useState<string | undefined>(initialContent); const [content, setContent] = useState<string | undefined>(initialContent);
@@ -120,10 +115,10 @@ export function usePlainTextListeners(
const { nativeEvent } = event; const { nativeEvent } = event;
let imagePasteWasHandled = false; let imagePasteWasHandled = false;
if (isEventToHandleAsClipboardEvent(nativeEvent)) { if (roomUploadVM && isEventToHandleAsClipboardEvent(nativeEvent)) {
const data = const data =
nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer; 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 // prevent default behaviour and skip call to onInput if the image paste event was handled
@@ -133,7 +128,7 @@ export function usePlainTextListeners(
onInput(event); onInput(event);
} }
}, },
[eventRelation, mxClient, onInput, roomContext], [onInput, roomUploadVM],
); );
const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend"); const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend");
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { type RefObject } from "react"; 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 WysiwygEvent } from "@vector-im/matrix-wysiwyg";
import { type TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { type TimelineRenderingType } from "../../../../../contexts/RoomContext";
@@ -16,8 +15,8 @@ import type Autocomplete from "../../Autocomplete";
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
import { getBlobSafeMimeType } from "../../../../../utils/blobs"; import { getBlobSafeMimeType } from "../../../../../utils/blobs";
import ContentMessages from "../../../../../ContentMessages";
import { isNotNull } from "../../../../../Typeguards"; import { isNotNull } from "../../../../../Typeguards";
import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel";
export function focusComposer( export function focusComposer(
composerElement: RefObject<HTMLElement | null>, composerElement: RefObject<HTMLElement | null>,
@@ -123,13 +122,8 @@ export function handleEventWithAutocomplete(
export function handleClipboardEvent( export function handleClipboardEvent(
event: ClipboardEvent | InputEvent, event: ClipboardEvent | InputEvent,
data: DataTransfer | null, data: DataTransfer | null,
roomContext: Pick<IRoomState, "room" | "timelineRenderingType" | "replyToEvent">, vm: RoomUploadViewModel,
mxClient: MatrixClient,
eventRelation?: IEventRelation,
): boolean { ): boolean {
// Logic in this function follows that of `SendMessageComposer.onPaste`
const { room, timelineRenderingType, replyToEvent } = roomContext;
function handleError(error: unknown): void { function handleError(error: unknown): void {
if (error instanceof Error) { if (error instanceof Error) {
console.log(error.message); 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; 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 // 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. // it puts the filename in as text/plain which we want to ignore.
if (data.files.length && !data.types.includes("text/rtf")) { if (data.files.length && !data.types.includes("text/rtf")) {
ContentMessages.sharedInstance() vm.initiateViaDataTransfer(data).catch(handleError);
.sendContentListToRoom(
Array.from(data.files),
room.roomId,
eventRelation,
roomContext.replyToEvent,
mxClient,
timelineRenderingType,
)
.catch(handleError);
return true; return true;
} }
@@ -188,9 +173,7 @@ export function handleClipboardEvent(
const parts = response.url.split("/"); const parts = response.url.split("/");
const filename = parts[parts.length - 1]; const filename = parts[parts.length - 1];
const file = new File([imgBlob], filename + "." + ext, { type: safetype }); const file = new File([imgBlob], filename + "." + ext, { type: safetype });
ContentMessages.sharedInstance() return vm.initiateViaInputFiles([file]);
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
.catch(handleError);
}) })
.catch(handleError); .catch(handleError);
}) })
@@ -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<void>;
initiateViaDataTransfer(dataTransfer: DataTransfer): Promise<void>;
openUploadDialog(): void;
}
export class RoomUploadViewModel
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
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<void> => {
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<void> => {
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<RoomUploadViewModel | null>(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<HTMLInputElement>(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<HTMLInputElement> = 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 (
<RoomUploadContext.Provider value={vm}>
<>
{children}
<input
ref={uploadInput}
type="file"
data-testid="room-upload-context-input"
style={{ display: "none" }}
multiple
onClick={chromeFileInputFix}
onChange={onInputChange}
/>
</>
</RoomUploadContext.Provider>
);
}
+2 -2
View File
@@ -659,8 +659,8 @@ export function mkMessage({
export function mkStubRoom( export function mkStubRoom(
roomId: string | null | undefined = null, roomId: string | null | undefined = null,
name: string | undefined, name?: string | undefined,
client: MatrixClient | undefined, client?: MatrixClient | undefined,
state?: RoomState | undefined, state?: RoomState | undefined,
): Room { ): Room {
const stubTimeline = { const stubTimeline = {
@@ -6,35 +6,62 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React from "react";
import { mocked } from "jest-mock";
import { render, fireEvent } from "jest-matrix-react"; 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 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<RoomUploadViewActions>;
}) {
const mockVm = useMockedViewModel<RoomUploadViewSnapshot, RoomUploadViewActions>(
snapshot,
actions as RoomUploadViewActions,
);
return (
<RoomUploadContext.Provider value={mockVm as RoomUploadViewModel}>
<FileDropTarget parent={element} />
</RoomUploadContext.Provider>
);
}
describe("FileDropTarget", () => { 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", () => { it("should render nothing when idle", () => {
const element = document.createElement("div"); const element = document.createElement("div");
const onFileDrop = jest.fn(); const onFileDrop = jest.fn();
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />); const { asFragment } = render(
<FileDropTargetWrapped
element={element}
snapshot={{ mayUpload: true }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
);
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
it("should render drop file prompt on mouse over with file if permissions allow", () => { it("should render drop file prompt on mouse over with file if permissions allow", () => {
const element = document.createElement("div"); const element = document.createElement("div");
const onFileDrop = jest.fn(); const onFileDrop = jest.fn();
mocked(room.currentState.maySendMessage).mockReturnValue(true); const { asFragment } = render(
<FileDropTargetWrapped
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />); element={element}
snapshot={{ mayUpload: true }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
);
fireEvent.dragEnter(element, { fireEvent.dragEnter(element, {
dataTransfer: { dataTransfer: {
types: ["Files"], 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", () => { it("should not render drop file prompt on mouse over with file if permissions do not allow", () => {
const element = document.createElement("div"); const element = document.createElement("div");
const onFileDrop = jest.fn(); const onFileDrop = jest.fn();
mocked(room.currentState.maySendMessage).mockReturnValue(false); const { asFragment } = render(
<FileDropTargetWrapped
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />); element={element}
snapshot={{ mayUpload: false }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
);
fireEvent.dragEnter(element, { fireEvent.dragEnter(element, {
dataTransfer: { dataTransfer: {
types: ["Files"], types: ["Files"],
@@ -270,6 +270,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div> </div>
</div> </div>
</main> </main>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
`; `;
@@ -638,16 +644,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
/> />
</svg> </svg>
</div> </div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
`; `;
@@ -1010,16 +1017,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
/> />
</svg> </svg>
</div> </div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
`; `;
@@ -1320,6 +1328,12 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
/> />
</div> </div>
</div> </div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -1621,15 +1635,16 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
/> />
</svg> </svg>
</div> </div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -2096,15 +2111,16 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
/> />
</svg> </svg>
</div> </div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -2571,15 +2587,16 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
/> />
</svg> </svg>
</div> </div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -2794,6 +2811,12 @@ exports[`RoomView should not display the timeline when the room encryption is lo
/> />
</div> </div>
</div> </div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -3288,15 +3311,16 @@ exports[`RoomView should not display the timeline when the room encryption is lo
/> />
</svg> </svg>
</div> </div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -3844,15 +3868,16 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
/> />
</svg> </svg>
</div> </div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div> </div>
</aside> </aside>
<div> <div>
@@ -36,6 +36,10 @@ import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
import {
RoomUploadContext,
type RoomUploadViewModel,
} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
const openStickerPicker = async (): Promise<void> => { const openStickerPicker = async (): Promise<void> => {
await userEvent.click(screen.getByLabelText("More options")); await userEvent.click(screen.getByLabelText("More options"));
@@ -469,7 +473,9 @@ function wrapAndRender(
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => ( const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}> <MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...context}> <ScopedRoomContextProvider {...context}>
<MessageComposer {...defaultProps} {...props} /> <RoomUploadContext.Provider value={{} as RoomUploadViewModel}>
<MessageComposer {...defaultProps} {...props} />
</RoomUploadContext.Provider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );
@@ -14,7 +14,8 @@ import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-u
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons"; import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; 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", () => { describe("MessageComposerButtons", () => {
// @ts-ignore - we're deliberately not implementing the whole interface here, but // @ts-ignore - we're deliberately not implementing the whole interface here, but
@@ -54,7 +55,9 @@ describe("MessageComposerButtons", () => {
return render( return render(
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>{component}</ScopedRoomContextProvider> <ScopedRoomContextProvider {...defaultRoomContext}>
<RoomUploadContextProvider>{component}</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
} }
@@ -31,6 +31,7 @@ import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
import { addTextToComposer } from "../../../../test-utils/composer"; import { addTextToComposer } from "../../../../test-utils/composer";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts";
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
jest.mock("../../../../../src/utils/local-room", () => ({ jest.mock("../../../../../src/utils/local-room", () => ({
doMaybeLocalRoomAction: jest.fn(), doMaybeLocalRoomAction: jest.fn(),
@@ -187,8 +188,10 @@ describe("<SendMessageComposer/>", () => {
}; };
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => ( const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}> <MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}> <ScopedRoomContextProvider room={mockRoom} {...roomContext}>
<SendMessageComposer {...defaultProps} {...props} /> <RoomUploadContextProvider>
<SendMessageComposer {...defaultProps} {...props} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );
@@ -435,7 +438,11 @@ describe("<SendMessageComposer/>", () => {
const { container } = render( const { container } = render(
<MatrixClientContext.Provider value={cli}> <MatrixClientContext.Provider value={cli}>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} /> <ScopedRoomContextProvider {...({ room } as unknown as RoomContextType)}>
<RoomUploadContextProvider>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
@@ -27,6 +27,7 @@ import { type ActionPayload } from "../../../../../../src/dispatcher/payloads";
import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton"; import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton";
import { createMocks } from "./utils"; import { createMocks } from "./utils";
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
beforeAll(initOnce, 10000); beforeAll(initOnce, 10000);
@@ -46,7 +47,9 @@ describe("EditWysiwygComposer", () => {
return render( return render(
<MatrixClientContext.Provider value={client}> <MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}> <ScopedRoomContextProvider {...roomContext}>
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} /> <RoomUploadContextProvider>
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
@@ -62,7 +65,9 @@ describe("EditWysiwygComposer", () => {
rerender( rerender(
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext} room={undefined}> <ScopedRoomContextProvider {...defaultRoomContext} room={undefined}>
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} /> <RoomUploadContextProvider>
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
@@ -273,7 +278,9 @@ describe("EditWysiwygComposer", () => {
render( render(
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}> <ScopedRoomContextProvider {...defaultRoomContext}>
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} /> <RoomUploadContextProvider>
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
</RoomUploadContextProvider>
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} /> <Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
@@ -25,6 +25,7 @@ import { setSelection } from "../../../../../../src/components/views/rooms/wysiw
import { createMocks } from "./utils"; import { createMocks } from "./utils";
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts"; import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts";
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({ jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
@@ -77,15 +78,17 @@ describe("SendWysiwygComposer", () => {
return render( return render(
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}> <ScopedRoomContextProvider {...defaultRoomContext}>
<SendWysiwygComposer <RoomUploadContextProvider>
onChange={onChange} <SendWysiwygComposer
onSend={onSend} onChange={onChange}
disabled={disabled} onSend={onSend}
isRichTextEnabled={isRichTextEnabled} disabled={disabled}
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })} isRichTextEnabled={isRichTextEnabled}
placeholder={placeholder} menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
e2eStatus={e2eStatus} placeholder={placeholder}
/> e2eStatus={e2eStatus}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
@@ -33,6 +33,7 @@ import type AutocompleteProvider from "../../../../../../../src/autocomplete/Aut
import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks"; import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks";
import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor"; import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor";
import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx";
import { RoomUploadContextProvider } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
beforeAll(initOnce, 10000); beforeAll(initOnce, 10000);
@@ -42,12 +43,14 @@ describe("WysiwygComposer", () => {
return render( return render(
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}> <ScopedRoomContextProvider {...defaultRoomContext}>
<WysiwygComposer <RoomUploadContextProvider>
onChange={onChange} <WysiwygComposer
onSend={onSend} onChange={onChange}
disabled={disabled} onSend={onSend}
initialContent={initialContent} disabled={disabled}
/> initialContent={initialContent}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
@@ -561,19 +564,21 @@ describe("WysiwygComposer", () => {
return render( return render(
<MatrixClientContext.Provider value={client}> <MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}> <ScopedRoomContextProvider {...roomContext}>
<ComposerContext.Provider <RoomUploadContextProvider>
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })} <ComposerContext.Provider
> value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
<WysiwygComposer >
onChange={jest.fn()} <WysiwygComposer
onSend={jest.fn()} onChange={jest.fn()}
initialContent={ onSend={jest.fn()}
roomContext.room && _editorStateTransfer initialContent={
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client) roomContext.room && _editorStateTransfer
: undefined ? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
} : undefined
/> }
</ComposerContext.Provider> />
</ComposerContext.Provider>
</RoomUploadContextProvider>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
@@ -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 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. 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 { waitFor } from "jest-matrix-react";
import fetchMock from "@fetch-mock/jest"; 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 { import {
handleClipboardEvent, handleClipboardEvent,
isEventToHandleAsClipboardEvent, isEventToHandleAsClipboardEvent,
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils"; } 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 mockUploadVM = {
const mockRoom = mkStubRoom("mock room", "mock room", mockClient); initiateViaDataTransfer: jest.fn().mockResolvedValue(undefined),
const mockRoomState = { initiateViaInputFiles: jest.fn().mockResolvedValue(undefined),
room: mockRoom, } as Partial<RoomUploadViewModel> as MockedObject<RoomUploadViewModel>;
timelineRenderingType: TimelineRenderingType.Room,
replyToEvent: {} as unknown as MatrixEvent,
} as unknown as IRoomState;
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom");
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
describe("handleClipboardEvent", () => { describe("handleClipboardEvent", () => {
@@ -45,29 +37,16 @@ describe("handleClipboardEvent", () => {
it("returns false if it is not a paste event", () => { it("returns false if it is not a paste event", () => {
const originalEvent = createMockClipboardEvent({ type: "copy" }); 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(output).toBe(false);
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
}); });
it("returns false if clipboard data is null", () => { it("returns false if clipboard data is null", () => {
const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null }); const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null });
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
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,
);
expect(output).toBe(false); expect(output).toBe(false);
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
}); });
it("returns false if room clipboardData files and types are empty", () => { it("returns false if room clipboardData files and types are empty", () => {
@@ -75,8 +54,9 @@ describe("handleClipboardEvent", () => {
type: "paste", type: "paste",
clipboardData: { files: [], types: [] }, clipboardData: { files: [], types: [] },
}); });
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(output).toBe(false); expect(output).toBe(false);
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
}); });
it("handles event and calls sendContentListToRoom when data files are present", () => { it("handles event and calls sendContentListToRoom when data files are present", () => {
@@ -84,65 +64,23 @@ describe("handleClipboardEvent", () => {
type: "paste", type: "paste",
clipboardData: { files: ["something here"], types: [] }, clipboardData: { files: ["something here"], types: [] },
}); });
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
const mockReplyToEvent = {} as unknown as MatrixEvent; expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledWith(originalEvent.clipboardData);
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,
);
expect(output).toBe(true); expect(output).toBe(true);
}); });
it("calls the error handler when sentContentListToRoom errors", async () => { it("calls the error handler when sentContentListToRoom errors", async () => {
const mockErrorMessage = "something went wrong"; const mockErrorMessage = "something went wrong";
sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage)); mockUploadVM.initiateViaDataTransfer.mockRejectedValueOnce(new Error(mockErrorMessage));
const originalEvent = createMockClipboardEvent({ const originalEvent = createMockClipboardEvent({
type: "paste", type: "paste",
clipboardData: { files: ["something here"], types: [] }, clipboardData: { files: ["something here"], types: [] },
}); });
const mockEventRelation = {} as unknown as IEventRelation; const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
await waitFor(() => { await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
}); });
@@ -158,15 +96,7 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue("<div>invalid html"), getData: jest.fn().mockReturnValue("<div>invalid html"),
}, },
}); });
const mockEventRelation = {} as unknown as IEventRelation; const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content"); expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content");
expect(output).toBe(false); expect(output).toBe(false);
}); });
@@ -180,10 +110,10 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`), getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
}, },
}); });
const mockEventRelation = {} as unknown as IEventRelation; const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation);
expect(fetchMock).toHaveFetchedTimes(1, "blob:"); expect(fetchMock).toHaveFetchedTimes(1, "blob:");
expect(output).toBe(true);
}); });
it("calls error handler when fetch fails", async () => { it("calls error handler when fetch fails", async () => {
@@ -197,14 +127,7 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`), getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
}, },
}); });
const mockEventRelation = {} as unknown as IEventRelation; const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
await waitFor(() => { await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
@@ -212,7 +135,7 @@ describe("handleClipboardEvent", () => {
expect(output).toBe(true); expect(output).toBe(true);
}); });
it("calls sendContentToRoom when parsing is successful", async () => { it("calls initiateViaInputFiles when parsing is successful", async () => {
fetchMock.get("test/file", { fetchMock.get("test/file", {
blob: () => { blob: () => {
return Promise.resolve({ type: "image/jpeg" } as Blob); return Promise.resolve({ type: "image/jpeg" } as Blob);
@@ -227,23 +150,11 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`), getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
}, },
}); });
const mockEventRelation = {} as unknown as IEventRelation; const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
await waitFor(() => { await waitFor(() => {
expect(sendContentToRoomSpy).toHaveBeenCalledWith( expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledTimes(1);
expect.any(File), expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledWith([expect.any(File)]);
mockRoom.roomId,
mockEventRelation,
mockClient,
mockRoomState.replyToEvent,
);
}); });
expect(output).toBe(true); expect(output).toBe(true);
}); });
@@ -254,8 +165,8 @@ describe("handleClipboardEvent", () => {
return Promise.resolve({ type: "image/jpeg" } as Blob); return Promise.resolve({ type: "image/jpeg" } as Blob);
}, },
}); });
const mockErrorMessage = "sendContentToRoom failed"; const mockErrorMessage = "initiateViaInputFiles failed";
sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage); mockUploadVM.initiateViaInputFiles.mockRejectedValueOnce(mockErrorMessage);
const originalEvent = createMockClipboardEvent({ const originalEvent = createMockClipboardEvent({
type: "paste", type: "paste",
@@ -265,14 +176,7 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`), getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
}, },
}); });
const mockEventRelation = {} as unknown as IEventRelation; const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
await waitFor(() => { await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
@@ -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<MatrixClient>;
let room: MockedObject<Room>;
let dis: MockedObject<MatrixDispatcher>;
beforeEach(() => {
jest.clearAllMocks();
client = stubClient() as MockedObject<MatrixClient>;
room = mkStubRoom("!room", undefined, undefined) as MockedObject<Room>;
dis = {
dispatch: jest.fn(),
} as Partial<MatrixDispatcher> as MockedObject<MatrixDispatcher>;
});
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,
);
});
});
});