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
@@ -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<IProps> = ({ parent, onFileDrop, room }) => {
const FileDropTarget: React.FC<IProps> = ({ parent }) => {
const [state, setState] = useState<IState>({
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<IProps> = ({ 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<IProps> = ({ 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 (
<div className="mx_FileDropTarget">
<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 { 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<HTMLElement | null>;
onFileDrop: (dataTransfer: DataTransfer) => Promise<void>;
mainSplitContentType: MainSplitContentType;
e2eStatus?: E2EStatus;
}
@@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
<div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary>
<RoomHeader room={room} />
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} />
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel">
{encryptionTile}
<NewRoomIntro />
</ScrollPanel>
</div>
{statusBar}
{composer}
</main>
<RoomUploadContextProvider>
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
<FileDropTarget parent={props.roomView.current} />
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel">
{encryptionTile}
<NewRoomIntro />
</ScrollPanel>
</div>
{statusBar}
{composer}
</main>
</RoomUploadContextProvider>
</ErrorBoundary>
</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 => {
this.setState({ narrow });
};
@@ -2169,7 +2158,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
resizeNotifier={this.context.resizeNotifier}
permalinkCreator={this.permalinkCreator}
roomView={this.roomView}
onFileDrop={this.onFileDrop}
mainSplitContentType={this.state.mainSplitContentType}
/>
</ScopedRoomContextProvider>
@@ -2673,16 +2661,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
case MainSplitContentType.Timeline:
mainSplitContentClassName = "mx_MainSplit_timeline";
mainSplitBody = (
<>
<RoomUploadContextProvider>
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses} data-testid="timeline">
<FileDropTarget
parent={this.roomView.current}
onFileDrop={this.onFileDrop}
room={this.state.room}
/>
<FileDropTarget parent={this.roomView.current} />
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
@@ -2691,7 +2675,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{statusBarArea}
{previewBar}
{messageComposer}
</>
</RoomUploadContextProvider>
);
break;
case MainSplitContentType.MaximisedWidget:
@@ -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<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 {
const relation: IEventRelation = {
rel_type: THREAD_RELATION_TYPE.name,
@@ -393,7 +377,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
timeline = (
<>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
<FileDropTarget parent={this.card.current} />
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
key={this.state.thread.id}
@@ -437,38 +421,40 @@ export default class ThreadView extends React.Component<IProps, IState> {
liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()}
narrow={this.state.narrow}
>
<BaseCard
className={classNames("mx_ThreadView mx_ThreadPanel", {
mx_ThreadView_narrow: this.state.narrow,
})}
onClose={this.props.onClose}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
ref={this.card}
onKeyDown={this.onKeyDown}
onBack={(ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
}}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
<RoomUploadContextProvider threadRelation={this.threadRelation}>
<BaseCard
className={classNames("mx_ThreadView mx_ThreadPanel", {
mx_ThreadView_narrow: this.state.narrow,
})}
onClose={this.props.onClose}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
ref={this.card}
onKeyDown={this.onKeyDown}
onBack={(ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
}}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />
)}
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />
)}
{this.state.thread?.timelineSet && (
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
</BaseCard>
{this.state.thread?.timelineSet && (
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
</BaseCard>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
);
}