Module API for adding new file upload mechanisms (#33355)

* Initial reword of upload to MVVM.

* Update tests

* More incremental improvements

* Refactor tests to use helper method for composer uploads.

* Add drag and drop tests

* lint

* Add commentary

* fixup test

* More precise selector

* Retarget uploads

* lint

* fixup

* one more type

* update snap

* Fixup composerUploadFiles

* fix import

* lint

* Copy and paste fixes too

* Add tests for pasting

* Add tests for pasting files.

* Remove redundant fn

* rm comment

* tidy up

* Test cleanup

* More clean up

* another fix

* Begin fleshing out

* Park changes

* More stuff

* Use condensed version

* Cleanup tests

* more cleaning

* last bity

* Add a test for the composer

* Park up changes

* Rewrite Measured to be a functional component

* Add tests to cover narrow viewports

* lint

* breakpoint is optional

* Cleanup

* Support narrow mode

* fixup

* begone

* Provide default value

* add label

* fixup test

* update copyright

* cleanup

* Be a bit more lazy with FileDropTarget

* remove a debug statement

* Fixup

* fix two snaps

* Update screenshot

* and the other one

* Update snaps

* unfake CIDER

* update screens again

* remove extra test

* Undo accidental snapshots

* Bit of tidyup

* fixup

* even more tidyup

* may drag and drop file

* tidy up again

* snap snap snap

* Use load to make sonarQube happy

* Bunch of refactors

* More cleanup

* cleanup debug code

* tweaks

* remove a test we no longer need

* make it happy

* fix import

* fixup

* Update snaps

* typo

* one off

* Add tests

* lint

* remove only

* Reduce screenshot scope

* fix snapshot usage

* cleanup
This commit is contained in:
Will Hunt
2026-05-18 22:41:38 +01:00
committed by GitHub
parent 66b739fea9
commit 02b6520f09
30 changed files with 1627 additions and 831 deletions
@@ -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();
});
});
@@ -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,
);
},
});
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

@@ -29,10 +29,10 @@ const FileDropTarget: React.FC<IProps> = ({ parent }) => {
counter: 0,
});
const vm = useRoomUploadViewModel();
const { mayUpload } = useViewModel(vm);
const { mayDragAndDropFile } = useViewModel(vm);
useEffect(() => {
if (!mayUpload || !parent || parent.ondrop) return;
if (!mayDragAndDropFile || !parent || parent.ondrop) return;
const onDragEnter = (ev: DragEvent): void => {
ev.stopPropagation();
@@ -106,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent }) => {
parent?.removeEventListener("dragenter", onDragEnter);
parent?.removeEventListener("dragleave", onDragLeave);
};
}, [parent, mayUpload, vm]);
}, [parent, mayDragAndDropFile, vm]);
if (mayUpload && state.dragging) {
if (mayDragAndDropFile && state.dragging) {
return (
<div className="mx_FileDropTarget">
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
@@ -1299,7 +1299,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const composerInsertPayload = payload as ComposerInsertPayload;
if (composerInsertPayload.composerType) break;
let timelineRenderingType: TimelineRenderingType | undefined;
let timelineRenderingType = composerInsertPayload.timelineRenderingType;
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break;
if (
@@ -1311,12 +1311,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
timelineRenderingType = TimelineRenderingType.Room;
}
// If the dispatchee didn't request a timeline rendering type, use the current one.
timelineRenderingType =
timelineRenderingType ??
composerInsertPayload.timelineRenderingType ??
this.state.timelineRenderingType;
// re-dispatch to the correct composer
defaultDispatcher.dispatch<ComposerInsertPayload>({
...composerInsertPayload,
@@ -17,13 +17,13 @@ import {
} from "matrix-js-sdk/src/matrix";
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react";
import {
AttachmentIcon,
MicOnIcon,
OverflowHorizontalIcon,
PollsIcon,
StickerIcon,
TextFormattingIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { UploadButton, useViewModel } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import { CollapsibleButton } from "./CollapsibleButton";
@@ -34,7 +34,10 @@ import Modal from "../../../Modal";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import { EmojiButton } from "./EmojiButton";
import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
@@ -64,6 +67,8 @@ export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
const matrixClient = useContext(MatrixClientContext);
const roomUploadVM = useRoomUploadViewModel();
const roomUploadSnapshot = useViewModel(roomUploadVM);
const { room, narrow } = useScopedRoomContext("room", "narrow");
const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer");
@@ -87,7 +92,15 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
),
];
moreButtons = [
uploadButton(), // props passed via UploadButtonContext
// This a textual list of buttons, so we can't use the UploadButton here.
roomUploadSnapshot.options.map(({ type, icon: Icon, label }) => (
<IconizedContextMenuOption
onClick={() => roomUploadVM.onUploadOptionSelected(type)}
icon={Icon && <Icon />}
label={label}
key={type}
/>
)),
showStickersButton(props),
voiceRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
@@ -104,7 +117,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
) : (
emojiButton(props)
),
uploadButton(), // props passed via UploadButtonContext
<UploadButton key="upload" vm={roomUploadVM} />,
];
moreButtons = [
showStickersButton(props),
@@ -162,27 +175,6 @@ function emojiButton(props: IProps): ReactElement {
);
}
function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
}
// Must be rendered within an UploadButtonContextProvider
const UploadButton: React.FC = () => {
const overflowMenuCloser = useContext(OverflowMenuContext);
const vm = useRoomUploadViewModel();
const onClick = (): void => {
vm.openUploadDialog();
overflowMenuCloser?.(); // close overflow menu
};
return (
<CollapsibleButton className="mx_MessageComposer_button" onClick={onClick} title={_t("common|attachment")}>
<AttachmentIcon />
</CollapsibleButton>
);
};
function showStickersButton(props: IProps): ReactElement | null {
return props.showStickersButton ? (
<CollapsibleButton
@@ -296,5 +288,4 @@ function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonP
</CollapsibleButton>
);
}
export default MessageComposerButtons;
+5
View File
@@ -195,6 +195,11 @@ export enum Action {
*/
ComposerInsert = "composer_insert",
/**
* Inserts a file into a target composer.
*/
ComposerFileInsert = "composer_insert_file",
/**
* Switches space. Should be used with SwitchSpacePayload.
*/
@@ -0,0 +1,16 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ActionPayload } from "../payloads";
import { type Action } from "../actions";
import { type TimelineRenderingType } from "../../contexts/RoomContext";
export interface ComposerInsertFilesPayload extends ActionPayload {
action: Action.ComposerFileInsert;
files: File[];
timelineRenderingType: TimelineRenderingType;
}
@@ -17,7 +17,7 @@ export enum ComposerType {
interface IBaseComposerInsertPayload extends ActionPayload {
action: Action.ComposerInsert;
timelineRenderingType?: TimelineRenderingType; // undefined if this should just use the current in-focus type.
timelineRenderingType: TimelineRenderingType;
composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
}
+62 -5
View File
@@ -5,19 +5,76 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ComposerApi as ModuleComposerApi } from "@element-hq/element-web-module-api";
import {
type ComposerApi as ModuleComposerApi,
type ComposerApiFileUploadOption,
type ComposerApiTarget,
} from "@element-hq/element-web-module-api";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
import { ComposerType, type ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
import { TimelineRenderingType } from "../contexts/RoomContext";
import type { ComposerInsertFilesPayload } from "../dispatcher/payloads/ComposerInsertFilePayload";
export class ComposerApi implements ModuleComposerApi {
public constructor(private readonly dispatcher: MatrixDispatcher) {}
export enum ModuleComposerApiEvents {
UploaderOptionsChanged = "uploaderOptionsChanged",
}
public insertPlaintextIntoComposer(plaintext: string): void {
interface ModuleComposerApiEventsMap {
[ModuleComposerApiEvents.UploaderOptionsChanged]: (option: ComposerApiFileUploadOption) => void;
}
export class ComposerApi
extends TypedEventEmitter<ModuleComposerApiEvents, ModuleComposerApiEventsMap>
implements ModuleComposerApi
{
private readonly configuredFileUploadOptions = new Map<string, ComposerApiFileUploadOption>();
public constructor(private readonly dispatcher: MatrixDispatcher) {
super();
}
/**
* List of possible file upload options.
*/
public get fileUploadOptions(): ComposerApiFileUploadOption[] {
return [...this.configuredFileUploadOptions.values()];
}
public addFileUploadOption(option: ComposerApiFileUploadOption): void {
if (this.configuredFileUploadOptions.has(option.type)) {
throw new Error(`Option "${option.type}" already exists`);
}
if (option.type === "local") {
throw new Error(`Option "local" is reserved`);
}
this.configuredFileUploadOptions.set(option.type, option);
this.emit(ModuleComposerApiEvents.UploaderOptionsChanged, option);
}
public openFileUploadConfirmation(files: File[], view: ComposerApiTarget = { view: "room" }): void {
if (!["room", "thread"].includes(view.view)) {
throw new Error(`Invalid view '${view.view}'`);
}
this.dispatcher.dispatch({
action: Action.ComposerFileInsert,
files,
timelineRenderingType: view.view === "room" ? TimelineRenderingType.Room : TimelineRenderingType.Thread,
} satisfies ComposerInsertFilesPayload);
}
public insertPlaintextIntoComposer(plaintext: string, view: ComposerApiTarget = { view: "room" }): void {
if (!["room", "thread"].includes(view.view)) {
throw new Error(`Invalid view '${view.view}'`);
}
this.dispatcher.dispatch({
action: Action.ComposerInsert,
text: plaintext,
timelineRenderingType: view.view === "room" ? TimelineRenderingType.Room : TimelineRenderingType.Thread,
// We only support send.
composerType: ComposerType.Send,
} satisfies ComposerInsertPayload);
}
}
@@ -5,8 +5,15 @@
* Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import {
_t,
BaseViewModel,
type UploadButtonViewActions,
type UploadButtonViewSnapshot,
useCreateAutoDisposedViewModel,
} from "@element-hq/web-shared-components";
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { AttachmentIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import React, {
type ChangeEventHandler,
createContext,
@@ -24,30 +31,32 @@ import {
RoomEvent,
} from "matrix-js-sdk/src/matrix";
import type { ComposerApiFileUploadOption } from "@element-hq/element-web-module-api";
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";
import ContentMessages from "../../ContentMessages";
import type { TimelineRenderingType } from "../../contexts/RoomContext";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import { chromeFileInputFix } from "../../utils/BrowserWorkarounds";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ModuleApi } from "../../modules/Api";
import { ModuleComposerApiEvents } from "../../modules/ComposerApi";
import { Action } from "../../dispatcher/actions";
import type { ComposerInsertFilesPayload } from "../../dispatcher/payloads/ComposerInsertFilePayload";
import { useDispatcher } from "../../hooks/useDispatcher";
import type { ActionPayload } from "../../dispatcher/payloads";
const logger = rootLogger.getChild("RoomUploadViewModel");
export interface RoomUploadViewSnapshot {
mayUpload: boolean;
}
export interface RoomUploadViewActions {
initiateViaInputFiles(files: FileList | null): Promise<void>;
initiateViaDataTransfer(dataTransfer: DataTransfer): Promise<void>;
openUploadDialog(): void;
interface RoomUploadViewSnapshot extends UploadButtonViewSnapshot {
mayDragAndDropFile: boolean;
}
export class RoomUploadViewModel
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
implements RoomUploadViewActions
implements UploadButtonViewActions
{
private readonly uploadSelectFns = new Map<string, ComposerApiFileUploadOption["onSelected"]>();
public constructor(
private readonly room: Room,
private readonly client: MatrixClient,
@@ -56,22 +65,66 @@ export class RoomUploadViewModel
private replyToEvent: MatrixEvent | undefined,
private threadRelation: IEventRelation | undefined,
public readonly openUploadDialog: () => void,
private readonly moduleComposerApi = ModuleApi.instance.composer,
) {
super(
{},
{
mayUpload: room.maySendMessage(),
options: [],
mayDragAndDropFile: false,
},
);
// Initial check.
this.onRoomCurrentStateUpdated();
// Configure upload functions
for (const option of moduleComposerApi.fileUploadOptions) {
this.uploadSelectFns.set(option.type, option.onSelected);
}
this.uploadSelectFns.set("local", this.openUploadDialog);
room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
this.disposables.track(() => {
room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
});
this.disposables.trackListener(room, RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
moduleComposerApi.on(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged);
this.disposables.trackListener(
moduleComposerApi,
ModuleComposerApiEvents.UploaderOptionsChanged,
// Types issue.
this.onUploaderOptionsChanged as any,
);
}
private onRoomCurrentStateUpdated = (): void => {
const maySendMessage = this.room.maySendMessage();
this.snapshot.merge({
mayUpload: this.room.maySendMessage(),
mayDragAndDropFile: maySendMessage,
options: maySendMessage
? [
{
type: "local",
label: _t("common|attachment"),
icon: AttachmentIcon,
},
...this.moduleComposerApi.fileUploadOptions.map((option) => ({
type: option.type,
label: option.label,
icon: option.icon,
})),
]
: [],
});
};
private readonly onUploaderOptionsChanged = (option: ComposerApiFileUploadOption): void => {
this.uploadSelectFns.set(option.type, option.onSelected);
this.snapshot.merge({
options: [
...this.snapshot.current.options,
{
type: option.type,
label: option.label,
icon: option.icon,
},
],
});
};
@@ -127,6 +180,28 @@ export class RoomUploadViewModel
}
};
public onUploadOptionSelected = (type: ComposerApiFileUploadOption["type"]): void => {
const fn = this.uploadSelectFns.get(type);
if (!fn) {
throw new Error("Unexpectedly called onUploadOptionSelected with an unknown type");
}
// At the point of this function being called, we should be in a state that is either rendering a room
// or timeline.
if (![TimelineRenderingType.Room, TimelineRenderingType.Thread].includes(this.timelineRenderingType)) {
throw new Error("TimelineRenderingType must be Room or Thread");
}
fn(
this.room.roomId,
{
view: this.timelineRenderingType === TimelineRenderingType.Room ? "room" : "thread",
},
{
inReplyToEventId: this.replyToEvent?.getId(),
relType: this.threadRelation?.rel_type,
},
);
};
private checkCanUpload(): boolean {
if (this.client.isGuest()) {
this.dispatcher.dispatch({ action: "require_registration" });
@@ -175,6 +250,7 @@ export function RoomUploadContextProvider({
return new RoomUploadViewModel(
room,
client,
// Checked earlier
timelineRenderingType,
defaultDispatcher,
replyToEvent,
@@ -208,6 +284,21 @@ export function RoomUploadContextProvider({
[vm],
);
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
if (payload.action !== Action.ComposerFileInsert) {
return;
}
const fileInsert = payload as ComposerInsertFilesPayload;
if (fileInsert.timelineRenderingType === timelineRenderingType) {
logger.info(
`Got ComposerFileInsert with ${fileInsert.files.length} files`,
timelineRenderingType,
threadRelation,
);
vm.initiateViaInputFiles(fileInsert.files);
}
});
// Note, while this logic could be largely replaced with https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker
// it does not enjoy support across all our target platforms.
// Therefore, we use the invisible input element trick.
@@ -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<RoomUploadViewActions>;
mayDragAndDropFile?: boolean;
onFileDrop: RoomUploadViewModel["initiateViaDataTransfer"];
}) {
const mockVm = useMockedViewModel<RoomUploadViewSnapshot, RoomUploadViewActions>(
snapshot,
actions as RoomUploadViewActions,
);
const mockVm = useMockedViewModel<
{ mayDragAndDropFile: boolean },
Pick<RoomUploadViewModel, "initiateViaDataTransfer">
>({ mayDragAndDropFile }, { initiateViaDataTransfer: onFileDrop });
return (
<RoomUploadContext.Provider value={mockVm as RoomUploadViewModel}>
<FileDropTarget parent={element} />
@@ -43,11 +38,7 @@ describe("FileDropTarget", () => {
const onFileDrop = jest.fn();
const { asFragment } = render(
<FileDropTargetWrapped
element={element}
snapshot={{ mayUpload: true }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
<FileDropTargetWrapped element={element} mayDragAndDropFile onFileDrop={onFileDrop} />,
);
expect(asFragment()).toMatchSnapshot();
});
@@ -56,11 +47,7 @@ describe("FileDropTarget", () => {
const element = document.createElement("div");
const onFileDrop = jest.fn();
const { asFragment } = render(
<FileDropTargetWrapped
element={element}
snapshot={{ mayUpload: true }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
<FileDropTargetWrapped element={element} mayDragAndDropFile onFileDrop={onFileDrop} />,
);
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(
<FileDropTargetWrapped
element={element}
snapshot={{ mayUpload: false }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
);
const { asFragment } = render(<FileDropTargetWrapped element={element} onFileDrop={onFileDrop} />);
fireEvent.dragEnter(element, {
dataTransfer: {
types: ["Files"],
@@ -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) => {
@@ -212,7 +212,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
</div>
<div
aria-labelledby="_r_1cl_"
aria-labelledby="_r_1dt_"
class="_banner_n7ud0_8"
data-type="critical"
role="status"
@@ -238,7 +238,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_mcq5y_24"
id="_r_1cl_"
id="_r_1dt_"
>
Could not start a chat with this user
</p>
@@ -423,7 +423,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_19j_"
aria-labelledby="_r_1an_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -608,24 +608,32 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
/>
</svg>
</div>
<div
<button
aria-label="Attachment"
class="mx_AccessibleButton mx_MessageComposer_button"
aria-labelledby="_r_1bs_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
<div
aria-label="More options"
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
@@ -702,7 +710,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
</span>
<svg
aria-label="New members see history"
aria-labelledby="_r_1cf_"
aria-labelledby="_r_1dn_"
class="mx_RoomHeader_icon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -981,24 +989,32 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
/>
</svg>
</div>
<div
<button
aria-label="Attachment"
class="mx_AccessibleButton mx_MessageComposer_button"
aria-labelledby="_r_1dc_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
<div
aria-label="More options"
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
@@ -1599,24 +1615,32 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
/>
</svg>
</div>
<div
<button
aria-label="Attachment"
class="mx_AccessibleButton mx_MessageComposer_button"
aria-labelledby="_r_27_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
<div
aria-label="More options"
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
@@ -1724,7 +1748,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5e_"
aria-labelledby="_r_5i_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -1751,7 +1775,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5j_"
aria-labelledby="_r_5n_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -1766,7 +1790,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
</button>
<button
aria-label="Threads"
aria-labelledby="_r_5o_"
aria-labelledby="_r_5s_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -1793,7 +1817,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
</button>
<button
aria-label="Room info"
aria-labelledby="_r_5t_"
aria-labelledby="_r_61_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -1823,7 +1847,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
>
<div
aria-label="0 members"
aria-labelledby="_r_62_"
aria-labelledby="_r_66_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -1890,7 +1914,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_6e_"
aria-labelledby="_r_6i_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -2075,24 +2099,32 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
/>
</svg>
</div>
<div
<button
aria-label="Attachment"
class="mx_AccessibleButton mx_MessageComposer_button"
aria-labelledby="_r_7n_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
<div
aria-label="More options"
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
@@ -2200,7 +2232,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_2g_"
aria-labelledby="_r_2i_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2227,7 +2259,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_2l_"
aria-labelledby="_r_2n_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2242,7 +2274,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="_r_2q_"
aria-labelledby="_r_2s_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -2269,7 +2301,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="_r_2v_"
aria-labelledby="_r_31_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -2299,7 +2331,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
>
<div
aria-label="0 members"
aria-labelledby="_r_34_"
aria-labelledby="_r_36_"
class="mx_AccessibleButton mx_FacePile mx_FacePile_toggled"
role="button"
tabindex="0"
@@ -2366,7 +2398,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_3g_"
aria-labelledby="_r_3i_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -2551,24 +2583,32 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
/>
</svg>
</div>
<div
<button
aria-label="Attachment"
class="mx_AccessibleButton mx_MessageComposer_button"
aria-labelledby="_r_4n_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
<div
aria-label="More options"
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
@@ -2676,7 +2716,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_fc_"
aria-labelledby="_r_fo_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2703,7 +2743,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_fh_"
aria-labelledby="_r_ft_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2718,7 +2758,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="_r_fm_"
aria-labelledby="_r_g2_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -2745,7 +2785,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="_r_fr_"
aria-labelledby="_r_g7_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -2775,7 +2815,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="_r_g0_"
aria-labelledby="_r_gc_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -2881,7 +2921,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</span>
<svg
aria-label="New members see history"
aria-labelledby="_r_gc_"
aria-labelledby="_r_go_"
class="mx_RoomHeader_icon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -2914,7 +2954,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_fc_"
aria-labelledby="_r_fo_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2941,7 +2981,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_fh_"
aria-labelledby="_r_ft_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2956,7 +2996,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="_r_fm_"
aria-labelledby="_r_g2_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -2983,7 +3023,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="_r_fr_"
aria-labelledby="_r_g7_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -3013,7 +3053,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="_r_g0_"
aria-labelledby="_r_gc_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -3082,7 +3122,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
tabindex="0"
>
<div
aria-labelledby="_r_gh_"
aria-labelledby="_r_gt_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
data-testid="e2e-icon"
style="width: 12px; height: 12px;"
@@ -3275,24 +3315,32 @@ exports[`RoomView should not display the timeline when the room encryption is lo
/>
</svg>
</div>
<div
<button
aria-label="Attachment"
class="mx_AccessibleButton mx_MessageComposer_button"
aria-labelledby="_r_i2_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
<div
aria-label="More options"
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
@@ -3418,7 +3466,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Chat"
aria-labelledby="_r_ks_"
aria-labelledby="_r_lc_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -3445,7 +3493,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="_r_l1_"
aria-labelledby="_r_lh_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -3472,7 +3520,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="_r_l6_"
aria-labelledby="_r_lm_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
@@ -3502,7 +3550,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<div
aria-label="0 members"
aria-labelledby="_r_lb_"
aria-labelledby="_r_lr_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@@ -3587,7 +3635,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</p>
</div>
<button
aria-labelledby="_r_lk_"
aria-labelledby="_r_m4_"
class="_icon-button_1215g_8"
data-kind="secondary"
data-testid="base-card-close-button"
@@ -3647,7 +3695,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_lp_"
aria-labelledby="_r_m9_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -3832,24 +3880,32 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
/>
</svg>
</div>
<div
<button
aria-label="Attachment"
class="mx_AccessibleButton mx_MessageComposer_button"
aria-labelledby="_r_ne_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
<div
aria-label="More options"
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
@@ -35,11 +35,8 @@ import { addTextToComposerRTL } from "../../../../test-utils/composer";
import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
import { Action } from "../../../../../src/dispatcher/actions";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
import {
RoomUploadContext,
type RoomUploadViewModel,
} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
import { TimelineRenderingType, type RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
const openStickerPicker = async (): Promise<void> => {
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<RoomContextType> as RoomContextType;
const defaultProps = {
room,
@@ -473,9 +471,9 @@ function wrapAndRender(
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...context}>
<RoomUploadContext.Provider value={{} as RoomUploadViewModel}>
<RoomUploadContextProvider>
<MessageComposer {...defaultProps} {...props} />
</RoomUploadContext.Provider>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
);
@@ -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');
});
});
});
@@ -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(
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...getRoomContext(room, {})}>
<RoomUploadContextProvider>
<p>Any child</p>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
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,
);
});
});
});
File diff suppressed because it is too large Load Diff
+61 -3
View File
@@ -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<SVGAttributes<SVGElement>>;
/**
* 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> | 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;
}
+1
View File
@@ -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";
@@ -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 <UploadButton defaultOpen={defaultOpen} vm={vm} />;
};
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<typeof UploadButtonWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
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,
},
],
},
},
},
};
@@ -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(<Default />);
expect(container).toMatchSnapshot();
});
it("will provide one option when only one is available", async () => {
userEvent.setup();
const onUploadOptionSelected = fn();
const { getByRole } = render(<WithOneOption onUploadOptionSelected={onUploadOptionSelected} />);
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(<Default onUploadOptionSelected={onUploadOptionSelected} />);
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(<Default onUploadOptionSelected={onUploadOptionSelected} />);
await userEvent.keyboard("[ControlLeft>]");
await userEvent.click(getByRole("button", { name: "Attachment" }));
expect(onUploadOptionSelected).toHaveBeenCalledWith("local");
});
});
@@ -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<SVGAttributes<SVGElement>> }[];
}
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
* <UploadButton vm={} />
* ```
*/
export function UploadButton({
vm,
defaultOpen = false,
...rootButtonProps
}: PropsWithChildren<
{ vm: ViewModel<UploadButtonViewSnapshot, UploadButtonViewActions>; 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<HTMLButtonElement> = 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 (
<IconButton
size="26px"
{...rootButtonProps}
tooltip={label}
aria-label={label}
onClick={() => vm.onUploadOptionSelected(options[0].type)}
>
{Icon ? <Icon /> : <AttachmentIcon />}
</IconButton>
);
}
const trigger = (
<IconButton
{...rootButtonProps}
size="26px"
tooltip={i18n.translate("common|attachment")}
onClick={onMenuClick}
title={i18n.translate("common|attachment")}
>
<AttachmentIcon />
</IconButton>
);
return (
<Menu
side="top"
title={i18n.translate("common|attachment")}
showTitle={false}
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>
);
}
@@ -0,0 +1,79 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UploadButton > can open the menu and select an option 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-controls="radix-_r_f_"
aria-disabled="false"
aria-expanded="true"
aria-haspopup="menu"
aria-labelledby="_r_g_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="open"
id="radix-_r_e_"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
title="Attachment"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
</div>
`;
exports[`UploadButton > renders a default button 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-labelledby="_r_2_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_0_"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"
title="Attachment"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
/>
</svg>
</div>
</button>
</div>
`;
@@ -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";