From 9e9dbe623907ea78467e6515f0fa7a44329a2c05 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 1 May 2026 15:30:51 +0100 Subject: [PATCH] Park changes --- .../views/rooms/EditMessageComposer.tsx | 2 +- .../views/rooms/MessageComposerButtons.tsx | 127 ++---------------- .../views/rooms/SendMessageComposer.tsx | 24 ++-- .../hooks/useWysiwygSendActionHandler.ts | 22 +-- .../payloads/ComposerInsertPayload.ts | 9 +- apps/web/src/modules/ComposerApi.ts | 49 ++++++- .../viewmodels/room/RoomUploadViewModel.tsx | 49 +++++-- .../module-api/element-web-module-api.api.md | 22 +-- packages/module-api/src/api/composer.ts | 29 ++-- .../UploadButton.stories.tsx/default-auto.png | Bin 0 -> 16986 bytes .../with-one-option-auto.png | Bin 0 -> 16986 bytes .../src/i18n/strings/en_EN.json | 3 + packages/shared-components/src/index.ts | 17 ++- .../UploadButton/UploadButton.stories.tsx | 73 ++++++++++ .../UploadButton/UploadButton.test.tsx | 23 ++++ .../composer/UploadButton/UploadButton.tsx | 105 +++++++++++++++ .../src/room/composer/UploadButton/index.ts | 8 ++ 17 files changed, 369 insertions(+), 193 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/with-one-option-auto.png create mode 100644 packages/shared-components/src/room/composer/UploadButton/UploadButton.stories.tsx create mode 100644 packages/shared-components/src/room/composer/UploadButton/UploadButton.test.tsx create mode 100644 packages/shared-components/src/room/composer/UploadButton/UploadButton.tsx create mode 100644 packages/shared-components/src/room/composer/UploadButton/index.ts diff --git a/apps/web/src/components/views/rooms/EditMessageComposer.tsx b/apps/web/src/components/views/rooms/EditMessageComposer.tsx index 970d8cffcd..598765881d 100644 --- a/apps/web/src/components/views/rooms/EditMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/EditMessageComposer.tsx @@ -439,7 +439,7 @@ class EditMessageComposer extends React.Component boolean; @@ -69,6 +64,8 @@ export const OverflowMenuContext = createContext(null const MessageComposerButtons: React.FC = (props: IProps) => { const matrixClient = useContext(MatrixClientContext); + const roomUploadVM = useRoomUploadViewModel(); + const { extraUploadOptions } = useViewModel(roomUploadVM); const { room, narrow } = useScopedRoomContext("room", "narrow"); const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer"); @@ -92,7 +89,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { ), ]; moreButtons = [ - , // props passed via UploadButtonContext + , showStickersButton(props), voiceRecordingButton(props, narrow), props.showPollsButton ? pollButton(room, props.relation) : null, @@ -109,7 +106,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { ) : ( emojiButton(props) ), - , // props passed via UploadButtonContext + , ]; moreButtons = [ showStickersButton(props), @@ -129,7 +126,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { }); return ( - + <> {mainButtons} {moreButtons.length > 0 && ( = (props: IProps) => { )} - + ); }; @@ -167,110 +164,6 @@ function emojiButton(props: IProps): ReactElement { ); } -const UploadButton: React.FC = () => { - const overflowMenuCloser = useContext(OverflowMenuContext); - const onLocalUploadClick = useContext(UploadButtonContext); - const { room } = useScopedRoomContext("room"); - - const onLocalClick = (): void => { - onLocalUploadClick?.(); - overflowMenuCloser?.(); // close overflow menu - }; - - const options = [...ModuleApi.instance.composer.fileUploadOptions.values()].map((uploadOption) => { - if (uploadOption.type === ComposerApiFileUploadLocal) { - return { - icon: AttachmentIcon, - label: _t("common|attachment"), - onSelect: onLocalClick, - }; - } else { - return { - icon: uploadOption.icon, - label: uploadOption.label, - onSelect: () => { - uploadOption.onSelected(room!.roomId, (res) => { - console.log("Do something with the result", res); - }); - }, - }; - } - }); - - return ( - - ); -}; - -type UploadButtonFn = () => void; -export const UploadButtonContext = createContext(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 = ({ roomId, relation, children }) => { - const cli = useContext(MatrixClientContext); - const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent"); - const uploadInput = useRef(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): 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 ( - - {children} - - - - ); -}; - function showStickersButton(props: IProps): ReactElement | null { return props.showStickersButton ? ( (null); const handler = useCallback( @@ -50,21 +52,25 @@ export function useWysiwygSendActionHandler( composerFunctions.clear(); focusComposer(composerElement, context, roomContext, timeoutId); break; - case Action.ComposerInsert: - if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break; - if (payload.composerType !== ComposerType.Send) break; + case Action.ComposerInsert: { + const insertPayload = payload as ComposerInsertPayload; + if (insertPayload.timelineRenderingType !== roomContext.timelineRenderingType) break; + if (insertPayload.composerType !== ComposerType.Send) break; - if (payload.userId) { + if (insertPayload.userId) { // TODO insert mention - see SendMessageComposer - } else if (payload.event) { + } else if (insertPayload.event) { // TODO insert quote message - see SendMessageComposer - } else if (payload.text) { + } else if (insertPayload.text) { setSelection(composerContext.selection).then(() => composerFunctions.insertText(payload.text)); + } else if (insertPayload.files) { + uploadVm.initiateViaInputFiles(insertPayload.files); } break; + } } }, - [disabled, composerElement, roomContext, composerFunctions, composerContext], + [disabled, composerElement, roomContext, composerFunctions, composerContext, uploadVm], ); useDispatcher(defaultDispatcher, handler); diff --git a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts index 597db9712e..7981d1e87b 100644 --- a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts +++ b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts @@ -29,4 +29,11 @@ interface IComposerInsertPlaintextPayload extends IBaseComposerInsertPayload { text: string; } -export type ComposerInsertPayload = IComposerInsertMentionPayload | IComposerInsertPlaintextPayload; +interface IComposerInsertFilesPayload extends IBaseComposerInsertPayload { + files: File[]; +} + +export type ComposerInsertPayload = + | IComposerInsertMentionPayload + | IComposerInsertPlaintextPayload + | IComposerInsertFilesPayload; diff --git a/apps/web/src/modules/ComposerApi.ts b/apps/web/src/modules/ComposerApi.ts index ebe193ef46..f95ce8f1ab 100644 --- a/apps/web/src/modules/ComposerApi.ts +++ b/apps/web/src/modules/ComposerApi.ts @@ -5,27 +5,68 @@ 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, +} 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"; -export class ComposerApi implements ModuleComposerApi { +export enum ModuleComposerApiEvents { + UploaderOptionsChanged = "uploaderOptionsChanged", +} + +interface ModuleComposerApiEventsMap { + [ModuleComposerApiEvents.UploaderOptionsChanged]: (option: ComposerApiFileUploadOption) => void; +} + +export class ComposerApi + extends TypedEventEmitter + implements ModuleComposerApi +{ private allowLocalFileUploads = true; + private readonly configuredFileUploadOptions = new Map(); - public constructor(private readonly dispatcher: MatrixDispatcher) {} + public constructor(private readonly dispatcher: MatrixDispatcher) { + super(); + } + /** + * Is the user permitted to upload local files in any way. + */ public get localFileUploadsAllowed(): boolean { return this.allowLocalFileUploads; } - public addFileUploadOption(option): void {} + /** + * List of possible file upload options. + */ + public get fileUploadOptions(): ComposerApiFileUploadOption[] { + return [...this.configuredFileUploadOptions.values()]; + } public disableLocalFileUploads(): void { this.allowLocalFileUploads = false; } + public addFileUploadOption(option: ComposerApiFileUploadOption): void { + if (this.configuredFileUploadOptions.has(option.type)) { + throw new Error(`Option "${option.type}" already exists `); + } + this.configuredFileUploadOptions.set(option.type, option); + this.emit(ModuleComposerApiEvents.UploaderOptionsChanged, option); + } + + public openFileUploadConfirmation(files: File[]): void { + this.dispatcher.dispatch({ + action: Action.ComposerInsert, + files, + } satisfies ComposerInsertPayload); + } + public insertPlaintextIntoComposer(plaintext: string): void { this.dispatcher.dispatch({ action: Action.ComposerInsert, diff --git a/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx index 38b2721815..54d901f0a8 100644 --- a/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx +++ b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx @@ -6,6 +6,7 @@ */ import { BaseViewModel, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import type { ComposerApiFileUploadOption } from "@element-hq/element-web-module-api"; import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import React, { type ChangeEventHandler, @@ -32,23 +33,15 @@ 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"; const logger = rootLogger.getChild("RoomUploadViewModel"); -export interface RoomUploadViewSnapshot { - mayUpload: boolean; -} - -export interface RoomUploadViewActions { - initiateViaInputFiles(files: FileList | null): Promise; - initiateViaDataTransfer(dataTransfer: DataTransfer): Promise; - openUploadDialog(): void; -} - export class RoomUploadViewModel extends BaseViewModel> implements RoomUploadViewActions { + private readonly extraUploadSelectFns = new Map(); public constructor( private readonly room: Room, private readonly client: MatrixClient, @@ -57,16 +50,27 @@ export class RoomUploadViewModel private replyToEvent: MatrixEvent | undefined, private threadRelation: IEventRelation | undefined, public readonly openUploadDialog: () => void, + private readonly moduleComposerApi = ModuleApi.instance.composer, ) { super( {}, { mayUpload: ModuleApi.instance.composer.localFileUploadsAllowed ?? room.maySendMessage(), + extraUploadOptions: moduleComposerApi.fileUploadOptions.map((option) => ({ + type: option.type, + label: option.label, + icon: option.icon, + })), }, ); + for (const option of moduleComposerApi.fileUploadOptions) { + this.extraUploadSelectFns.set(option.type, option.onSelected); + } room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated); + moduleComposerApi.on(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged); this.disposables.track(() => { room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated); + moduleComposerApi.off(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged); }); } @@ -76,6 +80,20 @@ export class RoomUploadViewModel }); }; + private onUploaderOptionsChanged = (option: ComposerApiFileUploadOption): void => { + this.extraUploadSelectFns.set(option.type, option.onSelected); + this.snapshot.merge({ + extraUploadOptions: [ + ...this.snapshot.current.extraUploadOptions, + { + type: option.type, + label: option.label, + icon: option.icon, + }, + ], + }); + }; + public setReplyToEvent = (replyToEvent?: MatrixEvent): void => { this.replyToEvent = replyToEvent; }; @@ -128,6 +146,17 @@ export class RoomUploadViewModel } }; + public onUploadOptionSelected = (type: ComposerApiFileUploadOption["type"]): void => { + const fn = this.extraUploadSelectFns.get(type); + if (!fn) { + throw Error("Unexpectedly called onUploadOptionSelected with an unknown type"); + } + fn(this.room.roomId, { + inReplyToEventId: this.replyToEvent?.getId(), + relType: this.threadRelation?.rel_type, + }); + }; + private checkCanUpload(): boolean { if (this.client.isGuest()) { this.dispatcher.dispatch({ action: "require_registration" }); diff --git a/packages/module-api/element-web-module-api.api.md b/packages/module-api/element-web-module-api.api.md index b712958f87..8452872c20 100644 --- a/packages/module-api/element-web-module-api.api.md +++ b/packages/module-api/element-web-module-api.api.md @@ -105,7 +105,7 @@ export interface ComposerApi { addFileUploadOption(option: ComposerApiFileUploadOption): void; disableLocalFileUploads(): void; insertPlaintextIntoComposer(plaintext: string): void; - openFileUploadConfirmation(file: File | DataTransfer): void; + openFileUploadConfirmation(file: File[]): void; } // @alpha @@ -113,13 +113,10 @@ export type ComposerApiFileUploadOption = { type: string; label: string; icon?: ComponentType>; - onSelected: (roomId: string, relation?: ComposerApiFileUploadRelation) => Promise | void; -}; - -// @public (undocumented) -export type ComposerApiFileUploadRelation = { - inReplyToEventId?: string; - relType?: "m.thread" | "m"; + onSelected: (roomId: string, relation?: { + inReplyToEventId?: string; + relType?: string; + }) => Promise | void; }; // @public @@ -241,15 +238,6 @@ export interface ExtrasApi { setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; } -// @alpha -export type FileUploadResult = { - mxc: string; -} | { - file: File; -} | { - blob: Blob; -} | null; - // @public export interface I18nApi { humanizeTime(this: void, timeMillis: number): string; diff --git a/packages/module-api/src/api/composer.ts b/packages/module-api/src/api/composer.ts index 9a8f11c027..4525ab03b2 100644 --- a/packages/module-api/src/api/composer.ts +++ b/packages/module-api/src/api/composer.ts @@ -7,11 +7,6 @@ Please see LICENSE files in the repository root for full details. import { ComponentType, SVGAttributes } from "react"; -export type ComposerApiFileUploadRelation = { - inReplyToEventId?: string; - relType?: "m.thread" | "m"; -}; - /** * An option presented to the user for uploading a file. * @alpha Unlikely to change @@ -31,21 +26,20 @@ export type ComposerApiFileUploadOption = { */ icon?: ComponentType>; /** - * Function called when the option is selected. The room ID - * @param roomId - * @param relation - * @param onFileSelected + * Function called when the option is selected. + * @param roomId - The room ID of the room in focus. + * @param relation - Whether or not a thread and/or reply is in focus. * @returns */ - onSelected: (roomId: string, relation?: ComposerApiFileUploadRelation) => Promise | void; + onSelected: ( + roomId: string, + relation?: { + inReplyToEventId?: string; + relType?: string; + }, + ) => Promise | void; }; -/** - * Result from a file upload. - * @alpha Unlikely to change - */ -export type FileUploadResult = { mxc: string } | { file: File } | { blob: Blob } | null; - /** * API to interact with the message composer. * @alpha Likely to change @@ -53,7 +47,6 @@ export type FileUploadResult = { mxc: string } | { file: File } | { blob: Blob } export interface ComposerApi { /** * Add a new file upload option for the user. - * Use {@link ComposerApiFileUploadLocal} to alter the local file upload logic. * @throws If another option is already using the same `type`. * @alpha Likely to change */ @@ -66,7 +59,7 @@ export interface ComposerApi { * Open the file upload confirmation dialog. This may be used in conjunction * with `addFileUploadOption` to support an alternative file upload kind. */ - openFileUploadConfirmation(file: File | DataTransfer): void; + openFileUploadConfirmation(file: File[]): void; /** * Insert plaintext into the current composer. * @param plaintext - The plain text to insert diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..0dd6842eba014bd07d3ea6728af4f26fff692e90 GIT binary patch literal 16986 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 { + const vm = useMockedViewModel(rest, { + onUploadOptionSelected, + }); + return ; +}; + +const UploadButtonWrapper = withViewDocs(UploadButtonWrapperImpl, UploadButton); + +export default { + title: "Room/UploadButton", + component: UploadButtonWrapper, + tags: ["autodocs"], + args: { + onUploadOptionSelected: fn(), + options: [ + { + type: "local", + label: "Attachment", + icon: AttachmentIcon, + }, + ], + }, +} satisfies Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + options: [ + { + type: "local", + label: "Attachment", + icon: AttachmentIcon, + }, + { + label: "Fun Button", + icon: ReactionIcon, + type: "fun", + }, + ], +}; + +export const WithOneOption = Template.bind({}); + +WithOneOption.args = { + options: [ + { + type: "local", + label: "Attachment", + icon: AttachmentIcon, + }, + ], +}; diff --git a/packages/shared-components/src/room/composer/UploadButton/UploadButton.test.tsx b/packages/shared-components/src/room/composer/UploadButton/UploadButton.test.tsx new file mode 100644 index 0000000000..d2737a2b33 --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/UploadButton.test.tsx @@ -0,0 +1,23 @@ +/* + * 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 React from "react"; +import { render } from "@test-utils"; +import { describe, it, expect } from "vitest"; +import { composeStories } from "@storybook/react-vite"; +import { userEvent } from "vitest/browser"; +import { fn } from "storybook/test"; + +import * as stories from "./UploadButton.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("UploadButton", () => { + it("renders a default button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/room/composer/UploadButton/UploadButton.tsx b/packages/shared-components/src/room/composer/UploadButton/UploadButton.tsx new file mode 100644 index 0000000000..2d66070a62 --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/UploadButton.tsx @@ -0,0 +1,105 @@ +/* + * 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 React, { + type ReactElement, + type PropsWithChildren, + useState, + type ComponentProps, + type SVGAttributes, + type ComponentType, + useCallback, + type MouseEventHandler, +} from "react"; +import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; +import { AttachmentIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { useI18n } from "../../../core/i18n/i18nContext"; +import { useViewModel, type ViewModel } from "../../../core/viewmodel"; + +export interface UploadButtonViewSnapshot { + mayUpload: boolean; + options: { type: string; label: string; icon: ComponentType> }[]; +} + +export interface UploadButtonViewActions { + onUploadOptionSelected(type: string): void; +} + +/** + * A button that may have one or more options that the user can select. + * + * @example + * ```tsx + * + * ``` + */ +export function UploadButton({ + vm, + ...rootButtonProps +}: PropsWithChildren< + { vm: ViewModel } & ComponentProps +>): ReactElement { + const i18n = useI18n(); + const [open, setOpen] = useState(false); + const { options } = useViewModel(vm); + // Shift click is a shortcut to selecting the first item. + const onMenuClick: MouseEventHandler = useCallback( + (ev) => { + if (!ev.shiftKey) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + vm.onUploadOptionSelected(options[0].type); + }, + [options, vm], + ); + if (options.length === 1) { + const { label, icon: Icon } = options[0]; + return ( + vm.onUploadOptionSelected(options[0].type)} + > + + + ); + } + + const trigger = ( + + + + ); + + return ( + setOpen(o)} + > + {options.map((o) => ( + vm.onUploadOptionSelected(o.type)} + /> + ))} + + ); +} diff --git a/packages/shared-components/src/room/composer/UploadButton/index.ts b/packages/shared-components/src/room/composer/UploadButton/index.ts new file mode 100644 index 0000000000..2bbcd04eaa --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from "./UploadButton";