diff --git a/apps/web/playwright/e2e/modules/composer.spec.ts b/apps/web/playwright/e2e/modules/composer.spec.ts new file mode 100644 index 0000000000..7da67730aa --- /dev/null +++ b/apps/web/playwright/e2e/modules/composer.spec.ts @@ -0,0 +1,37 @@ +/* +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 { test, expect } from "../../element-web-test"; +import { getSampleFilePath } from "../../sample-files"; + +test.describe("Composer API", () => { + test.use({ + displayName: "Manny", + config: { + modules: ["/modules/upload-module.js"], + }, + page: async ({ page }, use) => { + await page.route("/modules/upload-module.js", async (route) => { + await route.fulfill({ path: getSampleFilePath("upload-module.js") }); + }); + await use(page); + }, + room: async ({ page, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name: "TestRoom" }); + await use({ roomId }); + }, + }); + test("should be able to select custom uploader", async ({ page, room, app }) => { + page.on("dialog", (dialog) => console.log("Dialog discovered", dialog)); + await app.viewRoomById(room.roomId); + await app.getComposer().getByRole("button", { name: "Attachment" }).click(); + await page.getByRole("menuitem", { name: "Example uploader" }).click({ noWaitAfter: true }); + await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click(); + const fileTile = page.locator(".mx_MFileBody").first(); + await expect(fileTile).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/sample-files/upload-module.js b/apps/web/playwright/sample-files/upload-module.js new file mode 100644 index 0000000000..11edfbca95 --- /dev/null +++ b/apps/web/playwright/sample-files/upload-module.js @@ -0,0 +1,35 @@ +/* +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. +*/ + +// Note: eslint-plugin-jsdoc doesn't like import types as parameters, so we +// get around it with @typedef +/** + * @typedef {import("@element-hq/element-web-module-api").Api} Api + */ + +export default class CustomComponentModule { + static moduleApiVersion = "^*"; + /** + * Basic module for testing. + * @param {Api} api API object + */ + constructor(api) { + this.api = api; + } + async load() { + this.api.composer.addFileUploadOption({ + type: "org.example.uploader", + label: "Example uploader", + onSelected: (_roomId, view) => { + this.api.composer.openFileUploadConfirmation( + [new File(["test"], "testfile.txt", { type: "text/plain" })], + view, + ); + }, + }); + } +} diff --git a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png index 7e795251a8..64260f6086 100644 Binary files a/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png and b/apps/web/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 6e13920c3e..8333dc76e7 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png index f5c34f1c8a..c1607c9a8f 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-after-switch-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png index 5d36c31906..2412bd9963 100644 Binary files a/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png and b/apps/web/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-before-switch-linux.png differ diff --git a/apps/web/src/components/structures/FileDropTarget.tsx b/apps/web/src/components/structures/FileDropTarget.tsx index a81c05fbad..8dd748cecf 100644 --- a/apps/web/src/components/structures/FileDropTarget.tsx +++ b/apps/web/src/components/structures/FileDropTarget.tsx @@ -29,10 +29,10 @@ const FileDropTarget: React.FC = ({ 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 = ({ parent }) => { parent?.removeEventListener("dragenter", onDragEnter); parent?.removeEventListener("dragleave", onDragLeave); }; - }, [parent, mayUpload, vm]); + }, [parent, mayDragAndDropFile, vm]); - if (mayUpload && state.dragging) { + if (mayDragAndDropFile && state.dragging) { return (
diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index 9483b05a9c..0ea1168944 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -1299,7 +1299,7 @@ export class RoomView extends React.Component { 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 { 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, diff --git a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx index 94919e7788..6f4f5d1c44 100644 --- a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx @@ -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(null const MessageComposerButtons: React.FC = (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 = (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 }) => ( + roomUploadVM.onUploadOptionSelected(type)} + 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 = (props: IProps) => { ) : ( emojiButton(props) ), - uploadButton(), // props passed via UploadButtonContext + , ]; moreButtons = [ showStickersButton(props), @@ -162,27 +175,6 @@ function emojiButton(props: IProps): ReactElement { ); } -function uploadButton(): ReactElement { - return ; -} - -// 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 ( - - - - ); -}; - function showStickersButton(props: IProps): ReactElement | null { return props.showStickersButton ? ( ); } - export default MessageComposerButtons; diff --git a/apps/web/src/dispatcher/actions.ts b/apps/web/src/dispatcher/actions.ts index ad19d1c7d8..b8a4ebdf88 100644 --- a/apps/web/src/dispatcher/actions.ts +++ b/apps/web/src/dispatcher/actions.ts @@ -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. */ diff --git a/apps/web/src/dispatcher/payloads/ComposerInsertFilePayload.ts b/apps/web/src/dispatcher/payloads/ComposerInsertFilePayload.ts new file mode 100644 index 0000000000..8445b1610f --- /dev/null +++ b/apps/web/src/dispatcher/payloads/ComposerInsertFilePayload.ts @@ -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; +} diff --git a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts index 597db9712e..9712a8303a 100644 --- a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts +++ b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts @@ -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 } diff --git a/apps/web/src/modules/ComposerApi.ts b/apps/web/src/modules/ComposerApi.ts index c3cce62485..a4b4deed13 100644 --- a/apps/web/src/modules/ComposerApi.ts +++ b/apps/web/src/modules/ComposerApi.ts @@ -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 + implements ModuleComposerApi +{ + private readonly configuredFileUploadOptions = new Map(); + + 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); } } diff --git a/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx index 8b409e777b..dd0ca4a0a5 100644 --- a/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx +++ b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx @@ -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; - initiateViaDataTransfer(dataTransfer: DataTransfer): Promise; - openUploadDialog(): void; +interface RoomUploadViewSnapshot extends UploadButtonViewSnapshot { + mayDragAndDropFile: boolean; } export class RoomUploadViewModel extends BaseViewModel> - implements RoomUploadViewActions + implements UploadButtonViewActions { + private readonly uploadSelectFns = new Map(); 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. diff --git a/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx b/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx index 2950f66239..30c780de4c 100644 --- a/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx +++ b/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx @@ -10,26 +10,21 @@ import { render, fireEvent } from "jest-matrix-react"; import { useMockedViewModel } from "@element-hq/web-shared-components"; import FileDropTarget from "../../../../src/components/structures/FileDropTarget.tsx"; -import { - RoomUploadContext, - type RoomUploadViewActions, - type RoomUploadViewModel, - type RoomUploadViewSnapshot, -} from "../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; +import { RoomUploadContext, type RoomUploadViewModel } from "../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; function FileDropTargetWrapped({ element, - snapshot, - actions, + mayDragAndDropFile = false, + onFileDrop, }: { element: HTMLDivElement; - snapshot: RoomUploadViewSnapshot; - actions: Partial; + mayDragAndDropFile?: boolean; + onFileDrop: RoomUploadViewModel["initiateViaDataTransfer"]; }) { - const mockVm = useMockedViewModel( - snapshot, - actions as RoomUploadViewActions, - ); + const mockVm = useMockedViewModel< + { mayDragAndDropFile: boolean }, + Pick + >({ mayDragAndDropFile }, { initiateViaDataTransfer: onFileDrop }); return ( @@ -43,11 +38,7 @@ describe("FileDropTarget", () => { const onFileDrop = jest.fn(); const { asFragment } = render( - , + , ); expect(asFragment()).toMatchSnapshot(); }); @@ -56,11 +47,7 @@ describe("FileDropTarget", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); const { asFragment } = render( - , + , ); fireEvent.dragEnter(element, { dataTransfer: { @@ -73,13 +60,7 @@ describe("FileDropTarget", () => { it("should not render drop file prompt on mouse over with file if permissions do not allow", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); - const { asFragment } = render( - , - ); + const { asFragment } = render(); fireEvent.dragEnter(element, { dataTransfer: { types: ["Files"], diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx index 155ad5d508..90cba684e7 100644 --- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx @@ -1079,27 +1079,6 @@ describe("RoomView", () => { }); describe("handles Action.ComposerInsert", () => { - it("redispatches an empty composerType, timelineRenderingType with the current state", async () => { - await mountRoomView(); - const promise = untilDispatch((payload) => { - try { - expect(payload).toEqual({ - action: Action.ComposerInsert, - text: "Hello world", - timelineRenderingType: TimelineRenderingType.Room, - composerType: ComposerType.Send, - }); - } catch { - return false; - } - return true; - }, defaultDispatcher); - defaultDispatcher.dispatch({ - action: Action.ComposerInsert, - text: "Hello world", - } satisfies ComposerInsertPayload); - await promise; - }); it("redispatches an empty composerType with the current state", async () => { await mountRoomView(); const promise = untilDispatch((payload) => { diff --git a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index d093f4da0e..e5dc083aae 100644 --- a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -212,7 +212,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]

Could not start a chat with this user

@@ -423,7 +423,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = >
-
- - - -
+ + + + +
-
- - - -
+ + + + +
-
- - - -
+ + + + +
-
- - - -
+ + + +
+
-
- - - -
+ + + +
+
-
- - - -
+ + + +
+
-
- - - -
+ + + +
+
=> { await userEvent.click(screen.getByLabelText("More options")); @@ -462,7 +459,8 @@ function wrapAndRender( canSendMessages, tombstone, narrow, - } as unknown as RoomContextType; + timelineRenderingType: TimelineRenderingType.Room, + } satisfies Partial as RoomContextType; const defaultProps = { room, @@ -473,9 +471,9 @@ function wrapAndRender( const getRawComponent = (props = {}, context = roomContext, client = mockClient) => ( - + - + ); diff --git a/apps/web/test/unit-tests/modules/ComposerApi-test.ts b/apps/web/test/unit-tests/modules/ComposerApi-test.ts index af70ce0125..7d78343d3f 100644 --- a/apps/web/test/unit-tests/modules/ComposerApi-test.ts +++ b/apps/web/test/unit-tests/modules/ComposerApi-test.ts @@ -5,20 +5,79 @@ 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 { TimelineRenderingType } from "../../../src/contexts/RoomContext"; import { Action } from "../../../src/dispatcher/actions"; -import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; -import { ComposerApi } from "../../../src/modules/ComposerApi"; +import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import { type ComposerInsertPayload, ComposerType } from "../../../src/dispatcher/payloads/ComposerInsertPayload"; +import { ComposerApi, ModuleComposerApiEvents } from "../../../src/modules/ComposerApi"; +import type { ComposerInsertFilesPayload } from "../../../src/dispatcher/payloads/ComposerInsertFilePayload"; describe("ComposerApi", () => { - it("should be able to insert text via insertTextIntoComposer()", () => { - const dispatcher = { - dispatch: jest.fn(), - } as unknown as MatrixDispatcher; - const api = new ComposerApi(dispatcher); - api.insertPlaintextIntoComposer("Hello world"); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ComposerInsert, - text: "Hello world", + describe("insertPlaintextIntoComposer()", () => { + it("should be able to insert text", () => { + const dispatcher = { + dispatch: jest.fn(), + } as unknown as MatrixDispatcher; + const api = new ComposerApi(dispatcher); + api.insertPlaintextIntoComposer("Hello world", { view: "room" }); + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Room, + composerType: ComposerType.Send, + } satisfies ComposerInsertPayload); + }); + it("throws if called with an invalid view", () => { + const api = new ComposerApi(new MatrixDispatcher()); + expect(() => api.insertPlaintextIntoComposer("text", { view: "bleh" as "room" })).toThrow( + "Invalid view 'bleh'", + ); + }); + }); + describe("openFileUploadConfirmation()", () => { + it("should be able to initiate a file upload", () => { + const dispatcher = { + dispatch: jest.fn(), + } as unknown as MatrixDispatcher; + const api = new ComposerApi(dispatcher); + const files = [new File(["test"], "test.txt")]; + api.openFileUploadConfirmation(files, { view: "room" }); + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ComposerFileInsert, + files: files, + timelineRenderingType: TimelineRenderingType.Room, + } satisfies ComposerInsertFilesPayload); + }); + it("throws if called with an invalid view", () => { + const api = new ComposerApi(new MatrixDispatcher()); + const files = [new File(["test"], "test.txt")]; + expect(() => api.openFileUploadConfirmation(files, { view: "bleh" as "room" })).toThrow( + "Invalid view 'bleh'", + ); + }); + }); + describe("addFileUploadOption()", () => { + it("should be able to add a file upload option", () => { + const api = new ComposerApi(new MatrixDispatcher()); + const eventCb = jest.fn(); + api.on(ModuleComposerApiEvents.UploaderOptionsChanged, eventCb); + const option = { type: "an_option", label: "New option", onSelected: () => {} }; + api.addFileUploadOption(option); + expect(api.fileUploadOptions).toHaveLength(1); + expect(api.fileUploadOptions).toContain(option); + expect(eventCb).toHaveBeenCalledWith(option); + }); + it("throws if called with the type 'local'", () => { + const api = new ComposerApi(new MatrixDispatcher()); + expect(() => api.addFileUploadOption({ type: "local", label: "New option", onSelected: () => {} })).toThrow( + 'Option "local" is reserved', + ); + }); + it("throws if called with a duplicate type", () => { + const api = new ComposerApi(new MatrixDispatcher()); + const option = { type: "an_option", label: "New option", onSelected: () => {} }; + api.addFileUploadOption(option); + expect(() => api.addFileUploadOption({ ...option })).toThrow('Option "an_option" already exists'); }); }); }); diff --git a/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts b/apps/web/test/viewmodels/room/RoomUploadViewModel-test.tsx similarity index 63% rename from apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts rename to apps/web/test/viewmodels/room/RoomUploadViewModel-test.tsx index 837eaacc62..ce17c09c52 100644 --- a/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts +++ b/apps/web/test/viewmodels/room/RoomUploadViewModel-test.tsx @@ -4,15 +4,21 @@ * 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 { type IEventRelation, type MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { render } from "jest-matrix-react"; import type { MockedObject } from "jest-mock"; -import { RoomUploadViewModel } from "../../../src/viewmodels/room/RoomUploadViewModel"; -import { mkEvent, mkStubRoom, stubClient } from "../../test-utils"; +import { RoomUploadContextProvider, RoomUploadViewModel } from "../../../src/viewmodels/room/RoomUploadViewModel"; +import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../test-utils"; import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; -import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import defaultDispatcher, { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; import ContentMessages from "../../../src/ContentMessages"; +import { ComposerApi } from "../../../src/modules/ComposerApi"; +import type { ComposerInsertFilesPayload } from "../../../src/dispatcher/payloads/ComposerInsertFilePayload"; +import { ScopedRoomContextProvider } from "../../../src/contexts/ScopedRoomContext"; +import { Action } from "../../../src/dispatcher/actions"; +import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom"); describe("RoomUploadViewModel", () => { @@ -31,7 +37,7 @@ describe("RoomUploadViewModel", () => { jest.restoreAllMocks(); }); - it.each([true, false])("handles state of mayUpload when room.maySendMessage = %s", (maySendMessage) => { + it.each([true, false])("handles state when room.maySendMessage = %s", (maySendMessage) => { room.maySendMessage.mockReturnValue(maySendMessage); const vm = new RoomUploadViewModel( room, @@ -42,10 +48,45 @@ describe("RoomUploadViewModel", () => { undefined, () => {}, ); - expect(vm.getSnapshot().mayUpload).toEqual(maySendMessage); + expect(vm.getSnapshot().options).toHaveLength(maySendMessage ? 1 : 0); room.maySendMessage.mockReturnValue(!maySendMessage); room.emit(RoomEvent.CurrentStateUpdated, room, null as any, null as any); - expect(vm.getSnapshot().mayUpload).toEqual(!maySendMessage); + expect(vm.getSnapshot().options).toHaveLength(maySendMessage ? 0 : 1); + }); + + it("handles custom upload option", async () => { + const compApi = new ComposerApi(new MatrixDispatcher()); + const replyEv = mkEvent({ type: "fake", content: {}, user: "any", event: true }); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + replyEv, + { + rel_type: "any_type", + }, + () => {}, + compApi, + ); + const onSelected = jest.fn(); + const icon = { myicon: 5 } as any; + compApi.addFileUploadOption({ + type: "org.example.test", + label: "My uploader", + icon, + onSelected, + }); + expect(vm.getSnapshot().options).toContainEqual({ type: "org.example.test", label: "My uploader", icon }); + vm.onUploadOptionSelected("org.example.test"); + expect(onSelected).toHaveBeenCalledWith( + room.roomId, + { view: "room" }, + { + inReplyToEventId: replyEv.getId(), + relType: "any_type", + }, + ); }); describe("uploads via input", () => { @@ -171,4 +212,42 @@ describe("RoomUploadViewModel", () => { ); }); }); + + describe("RoomUploadContextProvider", () => { + it("uploads when called via module API", async () => { + sendContentListToRoomSpy.mockResolvedValue(undefined); + render( + + + +

Any child

+
+
+
, + ); + const files = [ + { + name: "fake.png", + size: 1024, + type: "image/png", + }, + ] as File[]; + defaultDispatcher.dispatch( + { + action: Action.ComposerFileInsert, + files, + timelineRenderingType: TimelineRenderingType.Room, + } satisfies ComposerInsertFilesPayload, + true, + ); + expect(sendContentListToRoomSpy).toHaveBeenCalledWith( + files, + room.roomId, + undefined, + undefined, + client, + TimelineRenderingType.Room, + ); + }); + }); }); diff --git a/packages/module-api/element-web-module-api.api.md b/packages/module-api/element-web-module-api.api.md index 097adf064b..47ef6a5828 100644 --- a/packages/module-api/element-web-module-api.api.md +++ b/packages/module-api/element-web-module-api.api.md @@ -1,566 +1,587 @@ -## API Report File for "@element-hq/element-web-module-api" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { ComponentType } from 'react'; -import { IWidget } from 'matrix-widget-api'; -import { JSX } from 'react'; -import { ModuleApi } from '@matrix-org/react-sdk-module-api'; -import { ReactNode } from 'react'; -import { Root } from 'react-dom/client'; -import { RuntimeModule } from '@matrix-org/react-sdk-module-api'; - -// @public -export interface AccountAuthApiExtension { - overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise; -} - -// @public -export interface AccountAuthInfo { - accessToken: string; - deviceId: string; - homeserverUrl: string; - refreshToken?: string; - userId: string; -} - -// @public -export interface AccountDataApi { - delete(eventType: string): Promise; - get(eventType: string): Watchable; - set(eventType: string, content: unknown): Promise; -} - -// @alpha @deprecated (undocumented) -export interface AliasCustomisations { - // (undocumented) - getDisplayAliasForAliasSet?(canonicalAlias: string | null, altAliases: string[]): string | null; -} - -// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyModuleApiExtension" which is marked as @alpha -// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyCustomisationsApiExtension" which is marked as @alpha -// -// @public -export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension, DialogApiExtension, AccountAuthApiExtension, ProfileApiExtension { - // @alpha - readonly builtins: BuiltinsApi; - readonly client: ClientApi; - // @alpha - readonly composer: ComposerApi; - readonly config: ConfigApi; - createRoot(element: Element): Root; - // @alpha - readonly customComponents: CustomComponentsApi; - // @alpha - readonly customisations: CustomisationsApi; - // @alpha - readonly extras: ExtrasApi; - readonly i18n: I18nApi; - readonly navigation: NavigationApi; - readonly rootNode: HTMLElement; - readonly stores: StoresApi; - // @alpha - readonly widget: WidgetApi; - // @alpha - readonly widgetLifecycle: WidgetLifecycleApi; -} - -// @alpha -export interface BuiltinsApi { - renderNotificationDecoration(roomId: string): React.ReactNode; - renderRoomAvatar(roomId: string, size?: string): React.ReactNode; - renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode; -} - -// @alpha -export type CapabilitiesApprover = (widget: WidgetDescriptor, requestedCapabilities: Set) => MaybePromise | undefined>; - -// @alpha @deprecated (undocumented) -export interface ChatExportCustomisations { - getForceChatExportParameters(): { - format?: ExportFormat; - range?: ExportType; - numberOfMessages?: number; - includeAttachments?: boolean; - sizeMb?: number; - }; -} - -// @public -export interface ClientApi { - accountData: AccountDataApi; - getRoom: (id: string) => Room | null; -} - -// @alpha @deprecated (undocumented) -export interface ComponentVisibilityCustomisations { - shouldShowComponent?(component: "UIComponent.sendInvites" | "UIComponent.roomCreation" | "UIComponent.spaceCreation" | "UIComponent.exploreRooms" | "UIComponent.addIntegrations" | "UIComponent.filterContainer" | "UIComponent.roomOptionsMenu"): boolean; -} - -// @alpha -export interface ComposerApi { - insertPlaintextIntoComposer(plaintext: string): void; -} - -// @public -export interface Config { - // (undocumented) - brand: string; -} - -// @public -export interface ConfigApi { - // (undocumented) - get(): Config; - // (undocumented) - get(key: K): Config[K]; - // (undocumented) - get(key?: K): Config | Config[K]; -} - -// @alpha -export type Container = "top" | "right" | "center"; - -// @alpha -export interface CustomComponentsApi { - registerLoginComponent(renderer: CustomLoginRenderFunction): void; - registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints?: CustomMessageRenderHints): void; - registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void; -} - -// @alpha -export interface CustomisationsApi { - registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void; -} - -// @alpha -export type CustomLoginComponentProps = { - serverConfig: CustomLoginComponentPropsServerConfig; - fragmentAfterLogin?: string; - children?: ReactNode; - onLoggedIn(data: AccountAuthInfo): void; - onServerConfigChange(config: CustomLoginComponentPropsServerConfig): void; -}; - -// @alpha -export interface CustomLoginComponentPropsServerConfig { - hsName: string; - hsUrl: string; -} - -// @alpha -export type CustomLoginRenderFunction = ExtendablePropsRenderFunction; - -// @alpha -export type CustomMessageComponentProps = { - mxEvent: MatrixEvent; -}; - -// @alpha -export type CustomMessageRenderFunction = ( -props: CustomMessageComponentProps, -originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element) => JSX.Element; - -// @alpha -export type CustomMessageRenderHints = { - allowEditingEvent?: boolean; - allowDownloadingMedia?: (mxEvent: MatrixEvent) => Promise; -}; - -// @alpha -export type CustomRoomPreviewBarComponentProps = { - roomId?: string; - roomAlias?: string; -}; - -// @alpha -export type CustomRoomPreviewBarRenderFunction = ( -props: CustomRoomPreviewBarComponentProps, -originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element) => JSX.Element; - -// @public -export interface DialogApiExtension { - openDialog(initialOptions: DialogOptions, dialog: ComponentType

>, props: P): DialogHandle; -} - -// @public -export type DialogHandle = { - finished: Promise<{ - ok: boolean; - model: M | null; - }>; - close(): void; -}; - -// @public -export interface DialogOptions { - title: string; -} - -// @public -export type DialogProps = { - onSubmit(model: M): void; - onCancel(): void; -}; - -// @alpha @deprecated (undocumented) -export interface DirectoryCustomisations { - // (undocumented) - requireCanonicalAliasAccessToPublish?(): boolean; -} - -// @alpha -export type ExtendablePropsRenderFunction =

( -props: P, -originalComponent: (props: P) => JSX.Element) => JSX.Element; - -// @alpha -export interface ExtrasApi { - addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; - getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; - setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; -} - -// @public -export interface I18nApi { - humanizeTime(this: void, timeMillis: number): string; - get language(): string; - register(this: void, translations: Partial): void; - translate(this: void, key: keyof Translations, variables?: StringVariables): string; - translate(this: void, key: keyof Translations, variables: Variables | undefined, tags: Tags): ReactNode; -} - -// @alpha -export type IdentityApprover = (widget: WidgetDescriptor) => MaybePromise; - -// @alpha @deprecated (undocumented) -export type LegacyCustomisations = (customisations: T) => void; - -// @alpha @deprecated (undocumented) -export interface LegacyCustomisationsApiExtension { - // @deprecated (undocumented) - readonly _registerLegacyAliasCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyChatExportCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyComponentVisibilityCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyDirectoryCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyLifecycleCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyMediaCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyRoomListCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyUserIdentifierCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyWidgetPermissionsCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyWidgetVariablesCustomisations: LegacyCustomisations; -} - -// @alpha @deprecated (undocumented) -export interface LegacyModuleApiExtension { - // @deprecated - _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise; -} - -// @alpha @deprecated (undocumented) -export interface LifecycleCustomisations { - // (undocumented) - onLoggedOutAndStorageCleared?(): void; -} - -// @alpha -export type LocationRenderFunction = () => JSX.Element; - -// @alpha -export interface MatrixEvent { - content: Record; - eventId: string; - originServerTs: number; - roomId: string; - sender: string; - stateKey?: string; - type: string; - unsigned: Record; -} - -// @public -export type MaybePromise = T | PromiseLike; - -// @alpha @deprecated (undocumented) -export interface Media { - // (undocumented) - downloadSource(): Promise; - // (undocumented) - getSquareThumbnailHttp(dim: number): string | null; - // (undocumented) - getThumbnailHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; - // (undocumented) - getThumbnailOfSourceHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; - // (undocumented) - readonly hasThumbnail: boolean; - // (undocumented) - readonly isEncrypted: boolean; - // (undocumented) - readonly srcHttp: string | null; - // (undocumented) - readonly srcMxc: string; - // (undocumented) - readonly thumbnailHttp: string | null; - // (undocumented) - readonly thumbnailMxc: string | null | undefined; -} - -// @alpha @deprecated (undocumented) -export interface MediaContructable { - // (undocumented) - new (prepared: PreparedMedia): Media; -} - -// @alpha @deprecated (undocumented) -export interface MediaCustomisations { - // (undocumented) - readonly Media: MediaContructable; - // (undocumented) - mediaFromContent(content: Content, client?: Client): Media; - // (undocumented) - mediaFromMxc(mxc?: string, client?: Client): Media; -} - -// @public -export interface Module { - // (undocumented) - load(): Promise; -} - -// @public -export interface ModuleFactory { - // (undocumented) - new (api: Api): Module; - // (undocumented) - readonly moduleApiVersion: string; - // (undocumented) - readonly prototype: Module; -} - -// @public -export class ModuleIncompatibleError extends Error { - constructor(pluginVersion: string); -} - -// @public -export class ModuleLoader { - constructor(api: Api); - // Warning: (ae-forgotten-export) The symbol "ModuleExport" needs to be exported by the entry point index.d.ts - // - // (undocumented) - load(moduleExport: ModuleExport): Promise; - // (undocumented) - start(): Promise; -} - -// @public -export interface NavigationApi { - openRoom(roomIdOrAlias: string, opts?: OpenRoomOptions): void; - // @alpha - registerLocationRenderer(path: string, renderer: LocationRenderFunction): void; - toMatrixToLink(link: string, join?: boolean): Promise; -} - -// @public -export interface OpenRoomOptions { - autoJoin?: boolean; - viaServers?: string[]; -} - -// @alpha -export type OriginalMessageComponentProps = { - showUrlPreview?: boolean; -}; - -// @alpha -export type PreloadApprover = (widget: WidgetDescriptor) => MaybePromise; - -// @public -export interface Profile { - displayName?: string; - isGuest?: boolean; - userId?: string; -} - -// @public -export interface ProfileApiExtension { - readonly profile: Watchable; -} - -// @public -export interface RichVariables { - // (undocumented) - [key: string]: SubstitutionValue; - // (undocumented) - count?: number; -} - -// @public -export interface Room { - getLastActiveTimestamp: () => number; - id: string; - name: Watchable; -} - -// @alpha -export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; - -// @alpha @deprecated (undocumented) -export interface RoomListCustomisations { - isRoomVisible?(room: Room): boolean; -} - -// @public -export interface RoomListStoreApi { - getRooms(): Watchable; - waitForReady(): Promise; -} - -// @alpha -export interface RoomViewProps { - enableReadReceiptsAndMarkersOnActivity?: boolean; - hideComposer?: boolean; - hideHeader?: boolean; - hidePinnedMessageBanner?: boolean; - hideRightPanel?: boolean; - hideWidgets?: boolean; -} - -// @alpha @deprecated (undocumented) -export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule; - -// @alpha -export interface SpacePanelItemProps { - className?: string; - icon?: JSX.Element; - label: string; - onSelected: () => void; - style?: React.CSSProperties; - tooltip?: string; -} - -// @public -export interface StoresApi { - roomListStore: RoomListStoreApi; -} - -// @public -export interface StringVariables { - // (undocumented) - [key: string]: number | string | null | undefined; - // (undocumented) - count?: number; -} - -// @public -export type SubstitutionValue = number | string | ReactNode | ((sub: string) => ReactNode); - -// @public -export type Tags = Record; - -// @public -export type Translations = Record; - -// @alpha -export const enum UIComponent { - AddIntegrations = "UIComponent.addIntegrations", - CreateRooms = "UIComponent.roomCreation", - CreateSpaces = "UIComponent.spaceCreation", - ExploreRooms = "UIComponent.exploreRooms", - FilterContainer = "UIComponent.filterContainer", - InviteUsers = "UIComponent.sendInvites", - RoomOptionsMenu = "UIComponent.roomOptionsMenu" -} - -// @alpha @deprecated (undocumented) -export interface UserIdentifierCustomisations { - getDisplayUserIdentifier(userId: string, opts: { - roomId?: string; - withDisplayName?: boolean; - }): string | null; -} - -// @public -export function useWatchable(watchable: Watchable): T; - -// @public -export type Variables = StringVariables | RichVariables; - -// @public -export class Watchable { - constructor(currentValue: T); - // Warning: (ae-forgotten-export) The symbol "WatchFn" needs to be exported by the entry point index.d.ts - // - // (undocumented) - protected readonly listeners: Set>; - protected onFirstWatch(): void; - protected onLastWatch(): void; - // (undocumented) - unwatch(listener: (value: T) => void): void; - get value(): T; - set value(value: T); - // (undocumented) - watch(listener: (value: T) => void): void; -} - -// @alpha -export interface WidgetApi { - getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; - getWidgetsInRoom(roomId: string): IWidget[]; - isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; - moveAppToContainer(app: IWidget, container: Container, roomId: string): void; -} - -// @alpha -export type WidgetDescriptor = { - id: string; - templateUrl: string; - creatorUserId: string; - type: string; - origin: string; - roomId?: string; -}; - -// @alpha -export interface WidgetLifecycleApi { - registerCapabilitiesApprover(approver: CapabilitiesApprover): void; - registerIdentityApprover(approver: IdentityApprover): void; - registerPreloadApprover(approver: PreloadApprover): void; -} - -// @alpha @deprecated (undocumented) -export interface WidgetPermissionsCustomisations { - preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; -} - -// @alpha @deprecated (undocumented) -export interface WidgetVariablesCustomisations { - isReady?(): Promise; - provideVariables?(): { - currentUserId: string; - userDisplayName?: string; - userHttpAvatarUrl?: string; - clientId?: string; - clientTheme?: string; - clientLanguage?: string; - deviceId?: string; - baseUrl?: string; - }; -} - -// (No @packageDocumentation comment for this package) - -``` +## API Report File for "@element-hq/element-web-module-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ComponentType } from 'react'; +import { IWidget } from 'matrix-widget-api'; +import { JSX } from 'react'; +import { ModuleApi } from '@matrix-org/react-sdk-module-api'; +import { ReactNode } from 'react'; +import { Root } from 'react-dom/client'; +import { RuntimeModule } from '@matrix-org/react-sdk-module-api'; +import { SVGAttributes } from 'react'; + +// @public +export interface AccountAuthApiExtension { + overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise; +} + +// @public +export interface AccountAuthInfo { + accessToken: string; + deviceId: string; + homeserverUrl: string; + refreshToken?: string; + userId: string; +} + +// @public +export interface AccountDataApi { + delete(eventType: string): Promise; + get(eventType: string): Watchable; + set(eventType: string, content: unknown): Promise; +} + +// @alpha @deprecated (undocumented) +export interface AliasCustomisations { + // (undocumented) + getDisplayAliasForAliasSet?(canonicalAlias: string | null, altAliases: string[]): string | null; +} + +// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyModuleApiExtension" which is marked as @alpha +// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyCustomisationsApiExtension" which is marked as @alpha +// +// @public +export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension, DialogApiExtension, AccountAuthApiExtension, ProfileApiExtension { + // @alpha + readonly builtins: BuiltinsApi; + readonly client: ClientApi; + // @alpha + readonly composer: ComposerApi; + readonly config: ConfigApi; + createRoot(element: Element): Root; + // @alpha + readonly customComponents: CustomComponentsApi; + // @alpha + readonly customisations: CustomisationsApi; + // @alpha + readonly extras: ExtrasApi; + readonly i18n: I18nApi; + readonly navigation: NavigationApi; + readonly rootNode: HTMLElement; + readonly stores: StoresApi; + // @alpha + readonly widget: WidgetApi; + // @alpha + readonly widgetLifecycle: WidgetLifecycleApi; +} + +// @alpha +export interface BuiltinsApi { + renderNotificationDecoration(roomId: string): React.ReactNode; + renderRoomAvatar(roomId: string, size?: string): React.ReactNode; + renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode; +} + +// @alpha +export type CapabilitiesApprover = (widget: WidgetDescriptor, requestedCapabilities: Set) => MaybePromise | undefined>; + +// @alpha @deprecated (undocumented) +export interface ChatExportCustomisations { + getForceChatExportParameters(): { + format?: ExportFormat; + range?: ExportType; + numberOfMessages?: number; + includeAttachments?: boolean; + sizeMb?: number; + }; +} + +// @public +export interface ClientApi { + accountData: AccountDataApi; + getRoom: (id: string) => Room | null; +} + +// @alpha @deprecated (undocumented) +export interface ComponentVisibilityCustomisations { + shouldShowComponent?(component: "UIComponent.sendInvites" | "UIComponent.roomCreation" | "UIComponent.spaceCreation" | "UIComponent.exploreRooms" | "UIComponent.addIntegrations" | "UIComponent.filterContainer" | "UIComponent.roomOptionsMenu"): boolean; +} + +// @alpha +export interface ComposerApi { + addFileUploadOption(option: ComposerApiFileUploadOption): void; + insertPlaintextIntoComposer(plaintext: string, view: ComposerApiTarget): void; + openFileUploadConfirmation(files: File[], view: ComposerApiTarget): void; +} + +// @alpha +export type ComposerApiFileUploadOption = { + type: string; + label: string; + icon?: ComponentType>; + onSelected: (roomId?: string, view?: ComposerApiTarget, relation?: { + inReplyToEventId?: string; + relType?: string; + }) => Promise | void; +}; + +// @alpha +export type ComposerApiTarget = { + view: "room"; +} | { + view: "thread"; +}; + +// @public +export interface Config { + // (undocumented) + brand: string; +} + +// @public +export interface ConfigApi { + // (undocumented) + get(): Config; + // (undocumented) + get(key: K): Config[K]; + // (undocumented) + get(key?: K): Config | Config[K]; +} + +// @alpha +export type Container = "top" | "right" | "center"; + +// @alpha +export interface CustomComponentsApi { + registerLoginComponent(renderer: CustomLoginRenderFunction): void; + registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints?: CustomMessageRenderHints): void; + registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void; +} + +// @alpha +export interface CustomisationsApi { + registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void; +} + +// @alpha +export type CustomLoginComponentProps = { + serverConfig: CustomLoginComponentPropsServerConfig; + fragmentAfterLogin?: string; + children?: ReactNode; + onLoggedIn(data: AccountAuthInfo): void; + onServerConfigChange(config: CustomLoginComponentPropsServerConfig): void; +}; + +// @alpha +export interface CustomLoginComponentPropsServerConfig { + hsName: string; + hsUrl: string; +} + +// @alpha +export type CustomLoginRenderFunction = ExtendablePropsRenderFunction; + +// @alpha +export type CustomMessageComponentProps = { + mxEvent: MatrixEvent; +}; + +// @alpha +export type CustomMessageRenderFunction = ( +props: CustomMessageComponentProps, +originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element) => JSX.Element; + +// @alpha +export type CustomMessageRenderHints = { + allowEditingEvent?: boolean; + allowDownloadingMedia?: (mxEvent: MatrixEvent) => Promise; +}; + +// @alpha +export type CustomRoomPreviewBarComponentProps = { + roomId?: string; + roomAlias?: string; +}; + +// @alpha +export type CustomRoomPreviewBarRenderFunction = ( +props: CustomRoomPreviewBarComponentProps, +originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element) => JSX.Element; + +// @public +export interface DialogApiExtension { + openDialog(initialOptions: DialogOptions, dialog: ComponentType

>, props: P): DialogHandle; +} + +// @public +export type DialogHandle = { + finished: Promise<{ + ok: boolean; + model: M | null; + }>; + close(): void; +}; + +// @public +export interface DialogOptions { + title: string; +} + +// @public +export type DialogProps = { + onSubmit(model: M): void; + onCancel(): void; +}; + +// @alpha @deprecated (undocumented) +export interface DirectoryCustomisations { + // (undocumented) + requireCanonicalAliasAccessToPublish?(): boolean; +} + +// @alpha +export type ExtendablePropsRenderFunction =

( +props: P, +originalComponent: (props: P) => JSX.Element) => JSX.Element; + +// @alpha +export interface ExtrasApi { + addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; + getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; + setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; +} + +// @public +export interface I18nApi { + humanizeTime(this: void, timeMillis: number): string; + get language(): string; + register(this: void, translations: Partial): void; + translate(this: void, key: keyof Translations, variables?: StringVariables): string; + translate(this: void, key: keyof Translations, variables: Variables | undefined, tags: Tags): ReactNode; +} + +// @alpha +export type IdentityApprover = (widget: WidgetDescriptor) => MaybePromise; + +// @alpha @deprecated (undocumented) +export type LegacyCustomisations = (customisations: T) => void; + +// @alpha @deprecated (undocumented) +export interface LegacyCustomisationsApiExtension { + // @deprecated (undocumented) + readonly _registerLegacyAliasCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyChatExportCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyComponentVisibilityCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyDirectoryCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyLifecycleCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyMediaCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyRoomListCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyUserIdentifierCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyWidgetPermissionsCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyWidgetVariablesCustomisations: LegacyCustomisations; +} + +// @alpha @deprecated (undocumented) +export interface LegacyModuleApiExtension { + // @deprecated + _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise; +} + +// @alpha @deprecated (undocumented) +export interface LifecycleCustomisations { + // (undocumented) + onLoggedOutAndStorageCleared?(): void; +} + +// @alpha +export type LocationRenderFunction = () => JSX.Element; + +// @alpha +export interface MatrixEvent { + content: Record; + eventId: string; + originServerTs: number; + roomId: string; + sender: string; + stateKey?: string; + type: string; + unsigned: Record; +} + +// @public +export type MaybePromise = T | PromiseLike; + +// @alpha @deprecated (undocumented) +export interface Media { + // (undocumented) + downloadSource(): Promise; + // (undocumented) + getSquareThumbnailHttp(dim: number): string | null; + // (undocumented) + getThumbnailHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + // (undocumented) + getThumbnailOfSourceHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + // (undocumented) + readonly hasThumbnail: boolean; + // (undocumented) + readonly isEncrypted: boolean; + // (undocumented) + readonly srcHttp: string | null; + // (undocumented) + readonly srcMxc: string; + // (undocumented) + readonly thumbnailHttp: string | null; + // (undocumented) + readonly thumbnailMxc: string | null | undefined; +} + +// @alpha @deprecated (undocumented) +export interface MediaContructable { + // (undocumented) + new (prepared: PreparedMedia): Media; +} + +// @alpha @deprecated (undocumented) +export interface MediaCustomisations { + // (undocumented) + readonly Media: MediaContructable; + // (undocumented) + mediaFromContent(content: Content, client?: Client): Media; + // (undocumented) + mediaFromMxc(mxc?: string, client?: Client): Media; +} + +// @public +export interface Module { + // (undocumented) + load(): Promise; +} + +// @public +export interface ModuleFactory { + // (undocumented) + new (api: Api): Module; + // (undocumented) + readonly moduleApiVersion: string; + // (undocumented) + readonly prototype: Module; +} + +// @public +export class ModuleIncompatibleError extends Error { + constructor(pluginVersion: string); +} + +// @public +export class ModuleLoader { + constructor(api: Api); + // Warning: (ae-forgotten-export) The symbol "ModuleExport" needs to be exported by the entry point index.d.ts + // + // (undocumented) + load(moduleExport: ModuleExport): Promise; + // (undocumented) + start(): Promise; +} + +// @public +export interface NavigationApi { + openRoom(roomIdOrAlias: string, opts?: OpenRoomOptions): void; + // @alpha + registerLocationRenderer(path: string, renderer: LocationRenderFunction): void; + toMatrixToLink(link: string, join?: boolean): Promise; +} + +// @public +export interface OpenRoomOptions { + autoJoin?: boolean; + viaServers?: string[]; +} + +// @alpha +export type OriginalMessageComponentProps = { + showUrlPreview?: boolean; +}; + +// @alpha +export type PreloadApprover = (widget: WidgetDescriptor) => MaybePromise; + +// @public +export interface Profile { + displayName?: string; + isGuest?: boolean; + userId?: string; +} + +// @public +export interface ProfileApiExtension { + readonly profile: Watchable; +} + +// @public +export interface RichVariables { + // (undocumented) + [key: string]: SubstitutionValue; + // (undocumented) + count?: number; +} + +// @public +export interface Room { + getLastActiveTimestamp: () => number; + id: string; + name: Watchable; +} + +// @alpha +export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; + +// @alpha @deprecated (undocumented) +export interface RoomListCustomisations { + isRoomVisible?(room: Room): boolean; +} + +// @public +export interface RoomListStoreApi { + getRooms(): Watchable; + waitForReady(): Promise; +} + +// @alpha +export interface RoomViewProps { + enableReadReceiptsAndMarkersOnActivity?: boolean; + hideComposer?: boolean; + hideHeader?: boolean; + hidePinnedMessageBanner?: boolean; + hideRightPanel?: boolean; + hideWidgets?: boolean; +} + +// @alpha @deprecated (undocumented) +export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule; + +// @alpha +export interface SpacePanelItemProps { + className?: string; + icon?: JSX.Element; + label: string; + onSelected: () => void; + style?: React.CSSProperties; + tooltip?: string; +} + +// @public +export interface StoresApi { + roomListStore: RoomListStoreApi; +} + +// @public +export interface StringVariables { + // (undocumented) + [key: string]: number | string | null | undefined; + // (undocumented) + count?: number; +} + +// @public +export type SubstitutionValue = number | string | ReactNode | ((sub: string) => ReactNode); + +// @public +export type Tags = Record; + +// @public +export type Translations = Record; + +// @alpha +export const enum UIComponent { + AddIntegrations = "UIComponent.addIntegrations", + CreateRooms = "UIComponent.roomCreation", + CreateSpaces = "UIComponent.spaceCreation", + ExploreRooms = "UIComponent.exploreRooms", + FilterContainer = "UIComponent.filterContainer", + InviteUsers = "UIComponent.sendInvites", + RoomOptionsMenu = "UIComponent.roomOptionsMenu" +} + +// @alpha @deprecated (undocumented) +export interface UserIdentifierCustomisations { + getDisplayUserIdentifier(userId: string, opts: { + roomId?: string; + withDisplayName?: boolean; + }): string | null; +} + +// @public +export function useWatchable(watchable: Watchable): T; + +// @public +export type Variables = StringVariables | RichVariables; + +// @public +export class Watchable { + constructor(currentValue: T); + // Warning: (ae-forgotten-export) The symbol "WatchFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected readonly listeners: Set>; + protected onFirstWatch(): void; + protected onLastWatch(): void; + // (undocumented) + unwatch(listener: (value: T) => void): void; + get value(): T; + set value(value: T); + // (undocumented) + watch(listener: (value: T) => void): void; +} + +// @alpha +export interface WidgetApi { + getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; + getWidgetsInRoom(roomId: string): IWidget[]; + isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; + moveAppToContainer(app: IWidget, container: Container, roomId: string): void; +} + +// @alpha +export type WidgetDescriptor = { + id: string; + templateUrl: string; + creatorUserId: string; + type: string; + origin: string; + roomId?: string; +}; + +// @alpha +export interface WidgetLifecycleApi { + registerCapabilitiesApprover(approver: CapabilitiesApprover): void; + registerIdentityApprover(approver: IdentityApprover): void; + registerPreloadApprover(approver: PreloadApprover): void; +} + +// @alpha @deprecated (undocumented) +export interface WidgetPermissionsCustomisations { + preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; +} + +// @alpha @deprecated (undocumented) +export interface WidgetVariablesCustomisations { + isReady?(): Promise; + provideVariables?(): { + currentUserId: string; + userDisplayName?: string; + userHttpAvatarUrl?: string; + clientId?: string; + clientTheme?: string; + clientLanguage?: string; + deviceId?: string; + baseUrl?: string; + }; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/module-api/src/api/composer.ts b/packages/module-api/src/api/composer.ts index 6080110992..ba1ac94db4 100644 --- a/packages/module-api/src/api/composer.ts +++ b/packages/module-api/src/api/composer.ts @@ -5,16 +5,74 @@ 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 { ComponentType, SVGAttributes } from "react"; + +/** + * An option presented to the user for uploading a file. + * @alpha Unlikely to change + */ +export type ComposerApiFileUploadOption = { + /** + * An unique string to refer to the type of upload + * @example org.example.my_uploader + */ + type: string; + /** + * Human-readable string used in labelling for the option. + */ + label: string; + /** + * An icon to attach to the option. If omitted, no icon is shown. + */ + icon?: ComponentType>; + /** + * 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, + view?: ComposerApiTarget, + relation?: { + inReplyToEventId?: string; + relType?: string; + }, + ) => Promise | void; +}; + +/** + * When handling composer interactions, this represents the target composer. + * @alpha Likely to change. This is intentionally left as an object so it may be extended later. + */ +export type ComposerApiTarget = { view: "room" } | { view: "thread" }; + /** * API to interact with the message composer. * @alpha Likely to change */ export interface ComposerApi { /** - * Insert plaintext into the current composer. - * @param plaintext - The plain text to insert + * Add a new file upload option for the user. + * @throws If another option is already using the same `type`. + * @alpha Likely to change + */ + addFileUploadOption(option: ComposerApiFileUploadOption): void; + /** + * Open the file upload confirmation dialog. This may be used in conjunction + * with `addFileUploadOption` to support an alternative file upload kind. + * @param files - The files to prompt for + * @param view - The target view to send the file into. Defaults to `{ view: "room" }` * @returns Returns immediately, does not await action. * @alpha Likely to change */ - insertPlaintextIntoComposer(plaintext: string): void; + openFileUploadConfirmation(files: File[], view: ComposerApiTarget): void; + /** + * Insert plaintext into the current composer. + * @param plaintext - The plain text to insert + * @param view - The target view to insert text into. Defaults to `{ view: "room" }` + * @returns Returns immediately, does not await action. + * @alpha Likely to change + */ + insertPlaintextIntoComposer(plaintext: string, view: ComposerApiTarget): void; } 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 0000000000..fb3b0a2aa8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/with-open-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/with-open-auto.png new file mode 100644 index 0000000000..5dbbc8f8b5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/with-open-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index a7a06ec999..59e0ffe7ae 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -13,6 +13,7 @@ export * from "./audio/SeekBar"; export * from "./core/AvatarWithDetails"; export * from "./core/roving"; export * from "./room/composer/Banner"; +export * from "./room/composer/UploadButton"; export * from "./crypto/SasEmoji"; export * from "./menus/UserMenu"; export * from "./room/timeline/ReadMarker"; diff --git a/packages/shared-components/src/room/composer/UploadButton/UploadButton.stories.tsx b/packages/shared-components/src/room/composer/UploadButton/UploadButton.stories.tsx new file mode 100644 index 0000000000..fd32e8702f --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/UploadButton.stories.tsx @@ -0,0 +1,93 @@ +/* + * 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 JSX } from "react"; +import { type StoryObj, type Meta } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { AttachmentIcon, ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { UploadButton, type UploadButtonViewActions, type UploadButtonViewSnapshot } from "./UploadButton"; +import { useMockedViewModel } from "../../../core/viewmodel"; +import { withViewDocs } from "../../../../.storybook/withViewDocs"; + +const UploadButtonWrapperImpl = ({ + onUploadOptionSelected, + defaultOpen, + ...rest +}: UploadButtonViewSnapshot & UploadButtonViewActions & { defaultOpen: boolean }): JSX.Element => { + const vm = useMockedViewModel(rest, { + onUploadOptionSelected, + }); + return ; +}; + +const UploadButtonWrapper = withViewDocs(UploadButtonWrapperImpl, UploadButton); + +const meta = { + title: "Room/UploadButton", + component: UploadButtonWrapper, + tags: ["autodocs"], + args: { + defaultOpen: false, + onUploadOptionSelected: fn(), + options: [ + { + type: "local", + label: "Attachment", + icon: AttachmentIcon, + }, + { + label: "Fun Button", + icon: ReactionIcon, + type: "fun", + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithOneOption: Story = { + // No visible difference + tags: ["skip-test"], + args: { + options: [ + { + type: "local", + label: "Attachment", + icon: AttachmentIcon, + }, + ], + }, +}; + +export const WithOpen: Story = { + args: { + defaultOpen: true, + }, + parameters: { + a11y: { + config: { + rules: [ + { + // Menu contains a header which is invalid + id: "aria-required-children", + enabled: false, + }, + { + // Menu pops open by default + id: "aria-hidden-focus", + enabled: false, + }, + ], + }, + }, + }, +}; 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..8781a58ba2 --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/UploadButton.test.tsx @@ -0,0 +1,46 @@ +/* + * 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, screen } 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, WithOneOption } = composeStories(stories); + +describe("UploadButton", () => { + it("renders a default button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("will provide one option when only one is available", async () => { + userEvent.setup(); + const onUploadOptionSelected = fn(); + const { getByRole } = render(); + await userEvent.click(getByRole("button", { name: "Attachment" })); + expect(onUploadOptionSelected).toHaveBeenCalledWith("local"); + }); + it("can open the menu and select an option", async () => { + const onUploadOptionSelected = fn(); + const { container, getByRole } = render(); + await userEvent.click(getByRole("button", { name: "Attachment" })); + expect(container).toMatchSnapshot(); + await userEvent.click(screen.getByRole("menuitem", { name: "Fun Button" })); + expect(onUploadOptionSelected).toHaveBeenCalledWith("fun"); + }); + it("can ctrl click to get the first option", async () => { + userEvent.setup(); + const onUploadOptionSelected = fn(); + const { getByRole } = render(); + await userEvent.keyboard("[ControlLeft>]"); + await userEvent.click(getByRole("button", { name: "Attachment" })); + expect(onUploadOptionSelected).toHaveBeenCalledWith("local"); + }); +}); 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..b037d6fa04 --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/UploadButton.tsx @@ -0,0 +1,112 @@ +/* + * 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 { + options: { type: string; label: string; icon?: ComponentType> }[]; +} + +export interface UploadButtonViewActions { + onUploadOptionSelected(type: string): void; +} + +/** + * A composer button to initiate uploading files. The button may also be + * Ctrl+Clicked to pick the first option automatically. + * + * @example + * ```tsx + * + * ``` + */ +export function UploadButton({ + vm, + defaultOpen = false, + ...rootButtonProps +}: PropsWithChildren< + { vm: ViewModel; defaultOpen?: boolean } & ComponentProps< + typeof IconButton + > +>): ReactElement { + const i18n = useI18n(); + const [open, setOpen] = useState(defaultOpen); + const { options } = useViewModel(vm); + + // Ctrl+click is a shortcut to selecting the first item. + const onMenuClick: MouseEventHandler = useCallback( + (ev) => { + if (!ev.ctrlKey) { + 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)} + > + {Icon ? : } + + ); + } + + const trigger = ( + + + + ); + + return ( +

setOpen(o)} + > + {options.map((o) => ( + vm.onUploadOptionSelected(o.type)} + /> + ))} + + ); +} diff --git a/packages/shared-components/src/room/composer/UploadButton/__snapshots__/UploadButton.test.tsx.snap b/packages/shared-components/src/room/composer/UploadButton/__snapshots__/UploadButton.test.tsx.snap new file mode 100644 index 0000000000..11d4ef2724 --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/__snapshots__/UploadButton.test.tsx.snap @@ -0,0 +1,79 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`UploadButton > can open the menu and select an option 1`] = ` + +`; + +exports[`UploadButton > renders a default button 1`] = ` +
+ +
+`; 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";