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