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 0000000000..0dd6842eba 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-one-option-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/with-one-option-auto.png new file mode 100644 index 0000000000..0dd6842eba Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/composer/UploadButton/UploadButton.stories.tsx/with-one-option-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index fe6d4e0865..691ab4adb9 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -238,6 +238,9 @@ "view_image": "View image" } }, + "composer": { + "attachment_button_label": "Open attachment menu" + }, "widget": { "context_menu": { "move_left": "Move left", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index a4e45bc386..58bb64e88e 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -9,21 +9,25 @@ export * from "./audio/Clock"; export * from "./audio/PlayPauseButton"; export * from "./audio/SeekBar"; -export * from "./core/avatar/AvatarWithDetails"; -export * from "./composer/Banner"; +export * from "./core/AvatarWithDetails"; +export * from "./core/roving"; +export * from "./room/composer/Banner"; +export * from "./room/composer/UploadButton"; export * from "./crypto/SasEmoji"; +export * from "./room/timeline/ReadMarker"; export * from "./room/timeline/event-tile/body/EventContentBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; +export * from "./room/timeline/event-tile/body/MImageBodyView"; export * from "./room/timeline/event-tile/body/MVideoBodyView"; export * from "./room/timeline/event-tile/body/TextualBodyView"; export * from "./room/timeline/event-tile/EventTileView/TileErrorView"; export * from "./core/pill-input/Pill"; export * from "./core/pill-input/PillInput"; -export * from "./core/MultiOptionButton"; export * from "./room/RoomStatusBar"; export * from "./room/WidgetPip"; export * from "./room/HistoryVisibilityBadge"; +export * from "./room/right-panel/WidgetContextMenuView"; export * from "./room/timeline/DateSeparatorView"; export * from "./room/timeline/TimelineSeparator"; export * from "./room/timeline/event-tile/actions/ActionBarView"; @@ -44,16 +48,15 @@ export * from "./core/rich-list/RichItem"; export * from "./core/rich-list/RichList"; export * from "./room-list/RoomListHeaderView"; export * from "./room-list/RoomListSearchView"; -export * from "./room-list/RoomListSectionHeaderView"; export * from "./room-list/RoomListView"; -export * from "./room-list/RoomListItemView"; -export * from "./room-list/RoomListItemAccessibilityWrapper"; +export * from "./room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView"; export * from "./room-list/RoomListPrimaryFilters"; export * from "./room-list/VirtualizedRoomListView"; +export * from "./room-list/VirtualizedRoomListView/RoomListSectionHeaderView"; +export * from "./room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper"; export * from "./core/utils/Box"; export * from "./core/utils/Flex"; export * from "./core/utils/LinkedText"; -export * from "./right-panel/WidgetContextMenu"; export * from "./core/VirtualizedList"; export * from "./resize"; 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..4d540e1be7 --- /dev/null +++ b/packages/shared-components/src/room/composer/UploadButton/UploadButton.stories.tsx @@ -0,0 +1,73 @@ +/* + * 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 Meta, type StoryFn } 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, + ...rest +}: UploadButtonViewSnapshot & UploadButtonViewActions): JSX.Element => { + 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";