Module API for adding new file upload mechanisms (#33355)

* 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

* Begin fleshing out

* Park changes

* More stuff

* Use condensed version

* Cleanup tests

* more cleaning

* last bity

* Add a test for the composer

* Park up changes

* Rewrite Measured to be a functional component

* Add tests to cover narrow viewports

* lint

* breakpoint is optional

* Cleanup

* Support narrow mode

* fixup

* begone

* Provide default value

* add label

* fixup test

* update copyright

* cleanup

* Be a bit more lazy with FileDropTarget

* remove a debug statement

* Fixup

* fix two snaps

* Update screenshot

* and the other one

* Update snaps

* unfake CIDER

* update screens again

* remove extra test

* Undo accidental snapshots

* Bit of tidyup

* fixup

* even more tidyup

* may drag and drop file

* tidy up again

* snap snap snap

* Use load to make sonarQube happy

* Bunch of refactors

* More cleanup

* cleanup debug code

* tweaks

* remove a test we no longer need

* make it happy

* fix import

* fixup

* Update snaps

* typo

* one off

* Add tests

* lint

* remove only

* Reduce screenshot scope

* fix snapshot usage

* cleanup
This commit is contained in:
Will Hunt
2026-05-18 21:41:38 +00:00
committed by GitHub
parent 66b739fea9
commit 02b6520f09
30 changed files with 1627 additions and 831 deletions
@@ -29,10 +29,10 @@ const FileDropTarget: React.FC<IProps> = ({ parent }) => {
counter: 0,
});
const vm = useRoomUploadViewModel();
const { mayUpload } = useViewModel(vm);
const { mayDragAndDropFile } = useViewModel(vm);
useEffect(() => {
if (!mayUpload || !parent || parent.ondrop) return;
if (!mayDragAndDropFile || !parent || parent.ondrop) return;
const onDragEnter = (ev: DragEvent): void => {
ev.stopPropagation();
@@ -106,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent }) => {
parent?.removeEventListener("dragenter", onDragEnter);
parent?.removeEventListener("dragleave", onDragLeave);
};
}, [parent, mayUpload, vm]);
}, [parent, mayDragAndDropFile, vm]);
if (mayUpload && state.dragging) {
if (mayDragAndDropFile && state.dragging) {
return (
<div className="mx_FileDropTarget">
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
@@ -1299,7 +1299,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const composerInsertPayload = payload as ComposerInsertPayload;
if (composerInsertPayload.composerType) break;
let timelineRenderingType: TimelineRenderingType | undefined;
let timelineRenderingType = composerInsertPayload.timelineRenderingType;
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break;
if (
@@ -1311,12 +1311,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
timelineRenderingType = TimelineRenderingType.Room;
}
// If the dispatchee didn't request a timeline rendering type, use the current one.
timelineRenderingType =
timelineRenderingType ??
composerInsertPayload.timelineRenderingType ??
this.state.timelineRenderingType;
// re-dispatch to the correct composer
defaultDispatcher.dispatch<ComposerInsertPayload>({
...composerInsertPayload,
@@ -17,13 +17,13 @@ import {
} from "matrix-js-sdk/src/matrix";
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react";
import {
AttachmentIcon,
MicOnIcon,
OverflowHorizontalIcon,
PollsIcon,
StickerIcon,
TextFormattingIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { UploadButton, useViewModel } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import { CollapsibleButton } from "./CollapsibleButton";
@@ -34,7 +34,10 @@ import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import { EmojiButton } from "./EmojiButton";
import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
@@ -64,6 +67,8 @@ export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
const matrixClient = useContext(MatrixClientContext);
const roomUploadVM = useRoomUploadViewModel();
const roomUploadSnapshot = useViewModel(roomUploadVM);
const { room, narrow } = useScopedRoomContext("room", "narrow");
const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer");
@@ -87,7 +92,15 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
),
];
moreButtons = [
uploadButton(), // props passed via UploadButtonContext
// This a textual list of buttons, so we can't use the UploadButton here.
roomUploadSnapshot.options.map(({ type, icon: Icon, label }) => (
<IconizedContextMenuOption
onClick={() => roomUploadVM.onUploadOptionSelected(type)}
icon={Icon && <Icon />}
label={label}
key={type}
/>
)),
showStickersButton(props),
voiceRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
@@ -104,7 +117,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
) : (
emojiButton(props)
),
uploadButton(), // props passed via UploadButtonContext
<UploadButton key="upload" vm={roomUploadVM} />,
];
moreButtons = [
showStickersButton(props),
@@ -162,27 +175,6 @@ function emojiButton(props: IProps): ReactElement {
);
}
function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
}
// Must be rendered within an UploadButtonContextProvider
const UploadButton: React.FC = () => {
const overflowMenuCloser = useContext(OverflowMenuContext);
const vm = useRoomUploadViewModel();
const onClick = (): void => {
vm.openUploadDialog();
overflowMenuCloser?.(); // close overflow menu
};
return (
<CollapsibleButton className="mx_MessageComposer_button" onClick={onClick} title={_t("common|attachment")}>
<AttachmentIcon />
</CollapsibleButton>
);
};
function showStickersButton(props: IProps): ReactElement | null {
return props.showStickersButton ? (
<CollapsibleButton
@@ -296,5 +288,4 @@ function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonP
</CollapsibleButton>
);
}
export default MessageComposerButtons;
+5
View File
@@ -195,6 +195,11 @@ export enum Action {
*/
ComposerInsert = "composer_insert",
/**
* Inserts a file into a target composer.
*/
ComposerFileInsert = "composer_insert_file",
/**
* Switches space. Should be used with SwitchSpacePayload.
*/
@@ -0,0 +1,16 @@
/*
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 { type ActionPayload } from "../payloads";
import { type Action } from "../actions";
import { type TimelineRenderingType } from "../../contexts/RoomContext";
export interface ComposerInsertFilesPayload extends ActionPayload {
action: Action.ComposerFileInsert;
files: File[];
timelineRenderingType: TimelineRenderingType;
}
@@ -17,7 +17,7 @@ export enum ComposerType {
interface IBaseComposerInsertPayload extends ActionPayload {
action: Action.ComposerInsert;
timelineRenderingType?: TimelineRenderingType; // undefined if this should just use the current in-focus type.
timelineRenderingType: TimelineRenderingType;
composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
}
+62 -5
View File
@@ -5,19 +5,76 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ComposerApi as ModuleComposerApi } from "@element-hq/element-web-module-api";
import {
type ComposerApi as ModuleComposerApi,
type ComposerApiFileUploadOption,
type ComposerApiTarget,
} from "@element-hq/element-web-module-api";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
import { ComposerType, type ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
import { TimelineRenderingType } from "../contexts/RoomContext";
import type { ComposerInsertFilesPayload } from "../dispatcher/payloads/ComposerInsertFilePayload";
export class ComposerApi implements ModuleComposerApi {
public constructor(private readonly dispatcher: MatrixDispatcher) {}
export enum ModuleComposerApiEvents {
UploaderOptionsChanged = "uploaderOptionsChanged",
}
public insertPlaintextIntoComposer(plaintext: string): void {
interface ModuleComposerApiEventsMap {
[ModuleComposerApiEvents.UploaderOptionsChanged]: (option: ComposerApiFileUploadOption) => void;
}
export class ComposerApi
extends TypedEventEmitter<ModuleComposerApiEvents, ModuleComposerApiEventsMap>
implements ModuleComposerApi
{
private readonly configuredFileUploadOptions = new Map<string, ComposerApiFileUploadOption>();
public constructor(private readonly dispatcher: MatrixDispatcher) {
super();
}
/**
* List of possible file upload options.
*/
public get fileUploadOptions(): ComposerApiFileUploadOption[] {
return [...this.configuredFileUploadOptions.values()];
}
public addFileUploadOption(option: ComposerApiFileUploadOption): void {
if (this.configuredFileUploadOptions.has(option.type)) {
throw new Error(`Option "${option.type}" already exists`);
}
if (option.type === "local") {
throw new Error(`Option "local" is reserved`);
}
this.configuredFileUploadOptions.set(option.type, option);
this.emit(ModuleComposerApiEvents.UploaderOptionsChanged, option);
}
public openFileUploadConfirmation(files: File[], view: ComposerApiTarget = { view: "room" }): void {
if (!["room", "thread"].includes(view.view)) {
throw new Error(`Invalid view '${view.view}'`);
}
this.dispatcher.dispatch({
action: Action.ComposerFileInsert,
files,
timelineRenderingType: view.view === "room" ? TimelineRenderingType.Room : TimelineRenderingType.Thread,
} satisfies ComposerInsertFilesPayload);
}
public insertPlaintextIntoComposer(plaintext: string, view: ComposerApiTarget = { view: "room" }): void {
if (!["room", "thread"].includes(view.view)) {
throw new Error(`Invalid view '${view.view}'`);
}
this.dispatcher.dispatch({
action: Action.ComposerInsert,
text: plaintext,
timelineRenderingType: view.view === "room" ? TimelineRenderingType.Room : TimelineRenderingType.Thread,
// We only support send.
composerType: ComposerType.Send,
} satisfies ComposerInsertPayload);
}
}
@@ -5,8 +5,15 @@
* Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import {
_t,
BaseViewModel,
type UploadButtonViewActions,
type UploadButtonViewSnapshot,
useCreateAutoDisposedViewModel,
} from "@element-hq/web-shared-components";
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { AttachmentIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import React, {
type ChangeEventHandler,
createContext,
@@ -24,30 +31,32 @@ import {
RoomEvent,
} from "matrix-js-sdk/src/matrix";
import type { ComposerApiFileUploadOption } from "@element-hq/element-web-module-api";
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";
import ContentMessages from "../../ContentMessages";
import type { TimelineRenderingType } from "../../contexts/RoomContext";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import { chromeFileInputFix } from "../../utils/BrowserWorkarounds";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ModuleApi } from "../../modules/Api";
import { ModuleComposerApiEvents } from "../../modules/ComposerApi";
import { Action } from "../../dispatcher/actions";
import type { ComposerInsertFilesPayload } from "../../dispatcher/payloads/ComposerInsertFilePayload";
import { useDispatcher } from "../../hooks/useDispatcher";
import type { ActionPayload } from "../../dispatcher/payloads";
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;
interface RoomUploadViewSnapshot extends UploadButtonViewSnapshot {
mayDragAndDropFile: boolean;
}
export class RoomUploadViewModel
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
implements RoomUploadViewActions
implements UploadButtonViewActions
{
private readonly uploadSelectFns = new Map<string, ComposerApiFileUploadOption["onSelected"]>();
public constructor(
private readonly room: Room,
private readonly client: MatrixClient,
@@ -56,22 +65,66 @@ export class RoomUploadViewModel
private replyToEvent: MatrixEvent | undefined,
private threadRelation: IEventRelation | undefined,
public readonly openUploadDialog: () => void,
private readonly moduleComposerApi = ModuleApi.instance.composer,
) {
super(
{},
{
mayUpload: room.maySendMessage(),
options: [],
mayDragAndDropFile: false,
},
);
// Initial check.
this.onRoomCurrentStateUpdated();
// Configure upload functions
for (const option of moduleComposerApi.fileUploadOptions) {
this.uploadSelectFns.set(option.type, option.onSelected);
}
this.uploadSelectFns.set("local", this.openUploadDialog);
room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
this.disposables.track(() => {
room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
});
this.disposables.trackListener(room, RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
moduleComposerApi.on(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged);
this.disposables.trackListener(
moduleComposerApi,
ModuleComposerApiEvents.UploaderOptionsChanged,
// Types issue.
this.onUploaderOptionsChanged as any,
);
}
private onRoomCurrentStateUpdated = (): void => {
const maySendMessage = this.room.maySendMessage();
this.snapshot.merge({
mayUpload: this.room.maySendMessage(),
mayDragAndDropFile: maySendMessage,
options: maySendMessage
? [
{
type: "local",
label: _t("common|attachment"),
icon: AttachmentIcon,
},
...this.moduleComposerApi.fileUploadOptions.map((option) => ({
type: option.type,
label: option.label,
icon: option.icon,
})),
]
: [],
});
};
private readonly onUploaderOptionsChanged = (option: ComposerApiFileUploadOption): void => {
this.uploadSelectFns.set(option.type, option.onSelected);
this.snapshot.merge({
options: [
...this.snapshot.current.options,
{
type: option.type,
label: option.label,
icon: option.icon,
},
],
});
};
@@ -127,6 +180,28 @@ export class RoomUploadViewModel
}
};
public onUploadOptionSelected = (type: ComposerApiFileUploadOption["type"]): void => {
const fn = this.uploadSelectFns.get(type);
if (!fn) {
throw new Error("Unexpectedly called onUploadOptionSelected with an unknown type");
}
// At the point of this function being called, we should be in a state that is either rendering a room
// or timeline.
if (![TimelineRenderingType.Room, TimelineRenderingType.Thread].includes(this.timelineRenderingType)) {
throw new Error("TimelineRenderingType must be Room or Thread");
}
fn(
this.room.roomId,
{
view: this.timelineRenderingType === TimelineRenderingType.Room ? "room" : "thread",
},
{
inReplyToEventId: this.replyToEvent?.getId(),
relType: this.threadRelation?.rel_type,
},
);
};
private checkCanUpload(): boolean {
if (this.client.isGuest()) {
this.dispatcher.dispatch({ action: "require_registration" });
@@ -175,6 +250,7 @@ export function RoomUploadContextProvider({
return new RoomUploadViewModel(
room,
client,
// Checked earlier
timelineRenderingType,
defaultDispatcher,
replyToEvent,
@@ -208,6 +284,21 @@ export function RoomUploadContextProvider({
[vm],
);
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
if (payload.action !== Action.ComposerFileInsert) {
return;
}
const fileInsert = payload as ComposerInsertFilesPayload;
if (fileInsert.timelineRenderingType === timelineRenderingType) {
logger.info(
`Got ComposerFileInsert with ${fileInsert.files.length} files`,
timelineRenderingType,
threadRelation,
);
vm.initiateViaInputFiles(fileInsert.files);
}
});
// 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.