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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user