Park changes

This commit is contained in:
Half-Shot 2026-05-01 15:30:51 +01:00
parent 62ff6d0fb0
commit 9e9dbe6239
17 changed files with 369 additions and 193 deletions

View File

@ -439,7 +439,7 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) {
this.editorRef.current?.insertPlaintext(payload.text);
}
} // File inserts explcitly unsupported in the edit composer.
} else if (payload.action === Action.FocusEditMessageComposer) {
this.editorRef.current.focus();
}

View File

@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
@ -14,38 +15,32 @@ import {
THREAD_RELATION_TYPE,
M_POLL_START,
} from "matrix-js-sdk/src/matrix";
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext, useRef } from "react";
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 { ComposerApiFileUploadLocal } from "@element-hq/element-web-module-api";
import { MultiOptionButton } from "@element-hq/web-shared-components";
import { UploadButton, useViewModel } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import { CollapsibleButton } from "./CollapsibleButton";
import { type MenuProps } from "../../structures/ContextMenu";
import dis from "../../../dispatcher/dispatcher";
import ErrorDialog from "../dialogs/ErrorDialog";
import { LocationButton } from "../location";
import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import ContentMessages from "../../../ContentMessages";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import { EmojiButton } from "./EmojiButton";
import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { ModuleApi } from "../../../modules/Api.ts";
import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
interface IProps {
addEmoji: (emoji: string) => boolean;
@ -69,6 +64,8 @@ export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null
const MessageComposerButtons: React.FC<IProps> = (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<IProps> = (props: IProps) => {
),
];
moreButtons = [
<UploadButton key="uploads" />, // props passed via UploadButtonContext
<UploadButton key="upload" options={extraUploadOptions} onSelect={roomUploadVM.onUploadOptionSelected} />,
showStickersButton(props),
voiceRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
@ -109,7 +106,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
) : (
emojiButton(props)
),
<UploadButton key="uploads" />, // props passed via UploadButtonContext
<UploadButton key="upload" options={extraUploadOptions} onSelect={roomUploadVM.onUploadOptionSelected} />,
];
moreButtons = [
showStickersButton(props),
@ -129,7 +126,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
});
return (
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
<>
{mainButtons}
{moreButtons.length > 0 && (
<AccessibleButton
@ -152,7 +149,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
</OverflowMenuContext.Provider>
</IconizedContextMenu>
)}
</UploadButtonContextProvider>
</>
);
};
@ -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 (
<MultiOptionButton
options={options}
multipleOptionsButton={{ label: _t("action|upload_file"), icon: AttachmentIcon }}
/>
);
};
type UploadButtonFn = () => void;
export const UploadButtonContext = createContext<UploadButtonFn | null>(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<IUploadButtonProps> = ({ roomId, relation, children }) => {
const cli = useContext(MatrixClientContext);
const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent");
const uploadInput = useRef<HTMLInputElement>(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<HTMLInputElement>): 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 (
<UploadButtonContext.Provider value={onUploadClick}>
{children}
<input
ref={uploadInput}
type="file"
style={uploadInputStyle}
multiple
onClick={chromeFileInputFix}
onChange={onUploadFileInputChange}
/>
</UploadButtonContext.Provider>
);
};
function showStickersButton(props: IProps): ReactElement | null {
return props.showStickersButton ? (
<CollapsibleButton

View File

@ -48,7 +48,7 @@ import { type ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import DocumentPosition from "../../../editor/position";
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { ComposerInsertPayload, ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
@ -541,18 +541,22 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
this.editorRef.current?.focus();
}
break;
case Action.ComposerInsert:
if (payload.timelineRenderingType !== this.context.timelineRenderingType) break;
if (payload.composerType !== ComposerType.Send) break;
case Action.ComposerInsert: {
const insertPayload = payload as ComposerInsertPayload;
if (insertPayload.timelineRenderingType !== this.context.timelineRenderingType) break;
if (insertPayload.composerType !== ComposerType.Send) break;
if (payload.userId) {
this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) {
this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) {
this.editorRef.current?.insertPlaintext(payload.text);
if (insertPayload.userId) {
this.editorRef.current?.insertMention(insertPayload.userId);
} else if (insertPayload.event) {
this.editorRef.current?.insertQuotedMessage(insertPayload.event);
} else if (insertPayload.text) {
this.editorRef.current?.insertPlaintext(insertPayload.text);
} else if (insertPayload.files) {
this.props.uploadVm.initiateViaInputFiles(insertPayload.files);
}
break;
}
}
};

View File

@ -15,10 +15,11 @@ import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { focusComposer } from "./utils";
import { type ComposerFunctions } from "../types";
import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
import { ComposerInsertPayload, ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
import { useComposerContext } from "../ComposerContext";
import { setSelection } from "../utils/selection";
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
export function useWysiwygSendActionHandler(
disabled: boolean,
@ -27,6 +28,7 @@ export function useWysiwygSendActionHandler(
): void {
const roomContext = useScopedRoomContext("timelineRenderingType");
const composerContext = useComposerContext();
const uploadVm = useRoomUploadViewModel();
const timeoutId = useRef<number | null>(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);

View File

@ -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;

View File

@ -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<ModuleComposerApiEvents, ModuleComposerApiEventsMap>
implements ModuleComposerApi
{
private allowLocalFileUploads = true;
private readonly configuredFileUploadOptions = new Map<string, ComposerApiFileUploadOption>();
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,

View File

@ -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<void>;
initiateViaDataTransfer(dataTransfer: DataTransfer): Promise<void>;
openUploadDialog(): void;
}
export class RoomUploadViewModel
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
implements RoomUploadViewActions
{
private readonly extraUploadSelectFns = new Map<string, ComposerApiFileUploadOption["onSelected"]>();
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" });

View File

@ -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<SVGAttributes<SVGElement>>;
onSelected: (roomId: string, relation?: ComposerApiFileUploadRelation) => Promise<void> | void;
};
// @public (undocumented)
export type ComposerApiFileUploadRelation = {
inReplyToEventId?: string;
relType?: "m.thread" | "m";
onSelected: (roomId: string, relation?: {
inReplyToEventId?: string;
relType?: string;
}) => Promise<void> | 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;

View File

@ -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<SVGAttributes<SVGElement>>;
/**
* 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> | void;
onSelected: (
roomId: string,
relation?: {
inReplyToEventId?: string;
relType?: string;
},
) => Promise<void> | 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

View File

@ -238,6 +238,9 @@
"view_image": "View image"
}
},
"composer": {
"attachment_button_label": "Open attachment menu"
},
"widget": {
"context_menu": {
"move_left": "Move left",

View File

@ -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";

View File

@ -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 <UploadButton vm={vm} />;
};
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<typeof UploadButtonWrapper>;
const Template: StoryFn<typeof UploadButtonWrapper> = (args) => <UploadButtonWrapper {...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,
},
],
};

View File

@ -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(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@ -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<SVGAttributes<SVGElement>> }[];
}
export interface UploadButtonViewActions {
onUploadOptionSelected(type: string): void;
}
/**
* A button that may have one or more options that the user can select.
*
* @example
* ```tsx
* <UploadButton vm={} />
* ```
*/
export function UploadButton({
vm,
...rootButtonProps
}: PropsWithChildren<
{ vm: ViewModel<UploadButtonViewSnapshot, UploadButtonViewActions> } & ComponentProps<typeof IconButton>
>): 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<HTMLButtonElement> = 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 (
<IconButton
tooltip={label}
{...rootButtonProps}
title={label}
onClick={() => vm.onUploadOptionSelected(options[0].type)}
>
<Icon />
</IconButton>
);
}
const trigger = (
<IconButton
tooltip={i18n.translate("common|attachment")}
onClick={onMenuClick}
{...rootButtonProps}
title={i18n.translate("common|attachment")}
>
<AttachmentIcon />
</IconButton>
);
return (
<Menu
side="top"
title={i18n.translate("composer|attachment_button_label")}
trigger={trigger}
open={open}
onOpenChange={(o) => setOpen(o)}
>
{options.map((o) => (
<MenuItem
key={o.label}
label={o.label}
Icon={o.icon}
onSelect={() => vm.onUploadOptionSelected(o.type)}
/>
))}
</Menu>
);
}

View File

@ -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";