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
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
@@ -29,10 +29,10 @@ const FileDropTarget: React.FC<IProps> = ({ parent }) => {
|
|||||||
counter: 0,
|
counter: 0,
|
||||||
});
|
});
|
||||||
const vm = useRoomUploadViewModel();
|
const vm = useRoomUploadViewModel();
|
||||||
const { mayUpload } = useViewModel(vm);
|
const { mayDragAndDropFile } = useViewModel(vm);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mayUpload || !parent || parent.ondrop) return;
|
if (!mayDragAndDropFile || !parent || parent.ondrop) return;
|
||||||
|
|
||||||
const onDragEnter = (ev: DragEvent): void => {
|
const onDragEnter = (ev: DragEvent): void => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@@ -106,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent }) => {
|
|||||||
parent?.removeEventListener("dragenter", onDragEnter);
|
parent?.removeEventListener("dragenter", onDragEnter);
|
||||||
parent?.removeEventListener("dragleave", onDragLeave);
|
parent?.removeEventListener("dragleave", onDragLeave);
|
||||||
};
|
};
|
||||||
}, [parent, mayUpload, vm]);
|
}, [parent, mayDragAndDropFile, vm]);
|
||||||
|
|
||||||
if (mayUpload && state.dragging) {
|
if (mayDragAndDropFile && state.dragging) {
|
||||||
return (
|
return (
|
||||||
<div className="mx_FileDropTarget">
|
<div className="mx_FileDropTarget">
|
||||||
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
|
<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;
|
const composerInsertPayload = payload as ComposerInsertPayload;
|
||||||
if (composerInsertPayload.composerType) break;
|
if (composerInsertPayload.composerType) break;
|
||||||
|
|
||||||
let timelineRenderingType: TimelineRenderingType | undefined;
|
let timelineRenderingType = composerInsertPayload.timelineRenderingType;
|
||||||
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
|
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
|
||||||
if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break;
|
if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break;
|
||||||
if (
|
if (
|
||||||
@@ -1311,12 +1311,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
timelineRenderingType = TimelineRenderingType.Room;
|
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
|
// re-dispatch to the correct composer
|
||||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||||
...composerInsertPayload,
|
...composerInsertPayload,
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import {
|
|||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react";
|
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react";
|
||||||
import {
|
import {
|
||||||
AttachmentIcon,
|
|
||||||
MicOnIcon,
|
MicOnIcon,
|
||||||
OverflowHorizontalIcon,
|
OverflowHorizontalIcon,
|
||||||
PollsIcon,
|
PollsIcon,
|
||||||
StickerIcon,
|
StickerIcon,
|
||||||
TextFormattingIcon,
|
TextFormattingIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { UploadButton, useViewModel } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { CollapsibleButton } from "./CollapsibleButton";
|
import { CollapsibleButton } from "./CollapsibleButton";
|
||||||
@@ -34,7 +34,10 @@ import Modal from "../../../Modal";
|
|||||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
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 { EmojiButton } from "./EmojiButton";
|
||||||
import { filterBoolean } from "../../../utils/arrays";
|
import { filterBoolean } from "../../../utils/arrays";
|
||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
@@ -64,6 +67,8 @@ export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null
|
|||||||
|
|
||||||
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||||
const matrixClient = useContext(MatrixClientContext);
|
const matrixClient = useContext(MatrixClientContext);
|
||||||
|
const roomUploadVM = useRoomUploadViewModel();
|
||||||
|
const roomUploadSnapshot = useViewModel(roomUploadVM);
|
||||||
const { room, narrow } = useScopedRoomContext("room", "narrow");
|
const { room, narrow } = useScopedRoomContext("room", "narrow");
|
||||||
|
|
||||||
const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer");
|
const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer");
|
||||||
@@ -87,7 +92,15 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
moreButtons = [
|
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),
|
showStickersButton(props),
|
||||||
voiceRecordingButton(props, narrow),
|
voiceRecordingButton(props, narrow),
|
||||||
props.showPollsButton ? pollButton(room, props.relation) : null,
|
props.showPollsButton ? pollButton(room, props.relation) : null,
|
||||||
@@ -104,7 +117,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
|||||||
) : (
|
) : (
|
||||||
emojiButton(props)
|
emojiButton(props)
|
||||||
),
|
),
|
||||||
uploadButton(), // props passed via UploadButtonContext
|
<UploadButton key="upload" vm={roomUploadVM} />,
|
||||||
];
|
];
|
||||||
moreButtons = [
|
moreButtons = [
|
||||||
showStickersButton(props),
|
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 {
|
function showStickersButton(props: IProps): ReactElement | null {
|
||||||
return props.showStickersButton ? (
|
return props.showStickersButton ? (
|
||||||
<CollapsibleButton
|
<CollapsibleButton
|
||||||
@@ -296,5 +288,4 @@ function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonP
|
|||||||
</CollapsibleButton>
|
</CollapsibleButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MessageComposerButtons;
|
export default MessageComposerButtons;
|
||||||
|
|||||||
@@ -195,6 +195,11 @@ export enum Action {
|
|||||||
*/
|
*/
|
||||||
ComposerInsert = "composer_insert",
|
ComposerInsert = "composer_insert",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a file into a target composer.
|
||||||
|
*/
|
||||||
|
ComposerFileInsert = "composer_insert_file",
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches space. Should be used with SwitchSpacePayload.
|
* 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 {
|
interface IBaseComposerInsertPayload extends ActionPayload {
|
||||||
action: Action.ComposerInsert;
|
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
|
composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 type { MatrixDispatcher } from "../dispatcher/dispatcher";
|
||||||
import { Action } from "../dispatcher/actions";
|
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 {
|
export enum ModuleComposerApiEvents {
|
||||||
public constructor(private readonly dispatcher: MatrixDispatcher) {}
|
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({
|
this.dispatcher.dispatch({
|
||||||
action: Action.ComposerInsert,
|
action: Action.ComposerInsert,
|
||||||
text: plaintext,
|
text: plaintext,
|
||||||
|
timelineRenderingType: view.view === "room" ? TimelineRenderingType.Room : TimelineRenderingType.Thread,
|
||||||
|
// We only support send.
|
||||||
|
composerType: ComposerType.Send,
|
||||||
} satisfies ComposerInsertPayload);
|
} satisfies ComposerInsertPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,15 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { AttachmentIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import React, {
|
import React, {
|
||||||
type ChangeEventHandler,
|
type ChangeEventHandler,
|
||||||
createContext,
|
createContext,
|
||||||
@@ -24,30 +31,32 @@ import {
|
|||||||
RoomEvent,
|
RoomEvent,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import type { ComposerApiFileUploadOption } from "@element-hq/element-web-module-api";
|
||||||
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
|
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
|
||||||
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";
|
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";
|
||||||
import ContentMessages from "../../ContentMessages";
|
import ContentMessages from "../../ContentMessages";
|
||||||
import type { TimelineRenderingType } from "../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||||
import { chromeFileInputFix } from "../../utils/BrowserWorkarounds";
|
import { chromeFileInputFix } from "../../utils/BrowserWorkarounds";
|
||||||
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
|
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
|
||||||
import defaultDispatcher 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");
|
const logger = rootLogger.getChild("RoomUploadViewModel");
|
||||||
|
|
||||||
export interface RoomUploadViewSnapshot {
|
interface RoomUploadViewSnapshot extends UploadButtonViewSnapshot {
|
||||||
mayUpload: boolean;
|
mayDragAndDropFile: boolean;
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomUploadViewActions {
|
|
||||||
initiateViaInputFiles(files: FileList | null): Promise<void>;
|
|
||||||
initiateViaDataTransfer(dataTransfer: DataTransfer): Promise<void>;
|
|
||||||
openUploadDialog(): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RoomUploadViewModel
|
export class RoomUploadViewModel
|
||||||
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
|
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
|
||||||
implements RoomUploadViewActions
|
implements UploadButtonViewActions
|
||||||
{
|
{
|
||||||
|
private readonly uploadSelectFns = new Map<string, ComposerApiFileUploadOption["onSelected"]>();
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly room: Room,
|
private readonly room: Room,
|
||||||
private readonly client: MatrixClient,
|
private readonly client: MatrixClient,
|
||||||
@@ -56,22 +65,66 @@ export class RoomUploadViewModel
|
|||||||
private replyToEvent: MatrixEvent | undefined,
|
private replyToEvent: MatrixEvent | undefined,
|
||||||
private threadRelation: IEventRelation | undefined,
|
private threadRelation: IEventRelation | undefined,
|
||||||
public readonly openUploadDialog: () => void,
|
public readonly openUploadDialog: () => void,
|
||||||
|
private readonly moduleComposerApi = ModuleApi.instance.composer,
|
||||||
) {
|
) {
|
||||||
super(
|
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);
|
room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
|
||||||
this.disposables.track(() => {
|
this.disposables.trackListener(room, RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
|
||||||
room.off(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 => {
|
private onRoomCurrentStateUpdated = (): void => {
|
||||||
|
const maySendMessage = this.room.maySendMessage();
|
||||||
this.snapshot.merge({
|
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 {
|
private checkCanUpload(): boolean {
|
||||||
if (this.client.isGuest()) {
|
if (this.client.isGuest()) {
|
||||||
this.dispatcher.dispatch({ action: "require_registration" });
|
this.dispatcher.dispatch({ action: "require_registration" });
|
||||||
@@ -175,6 +250,7 @@ export function RoomUploadContextProvider({
|
|||||||
return new RoomUploadViewModel(
|
return new RoomUploadViewModel(
|
||||||
room,
|
room,
|
||||||
client,
|
client,
|
||||||
|
// Checked earlier
|
||||||
timelineRenderingType,
|
timelineRenderingType,
|
||||||
defaultDispatcher,
|
defaultDispatcher,
|
||||||
replyToEvent,
|
replyToEvent,
|
||||||
@@ -208,6 +284,21 @@ export function RoomUploadContextProvider({
|
|||||||
[vm],
|
[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
|
// 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.
|
// it does not enjoy support across all our target platforms.
|
||||||
// Therefore, we use the invisible input element trick.
|
// 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 { useMockedViewModel } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import FileDropTarget from "../../../../src/components/structures/FileDropTarget.tsx";
|
import FileDropTarget from "../../../../src/components/structures/FileDropTarget.tsx";
|
||||||
import {
|
import { RoomUploadContext, type RoomUploadViewModel } from "../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
RoomUploadContext,
|
|
||||||
type RoomUploadViewActions,
|
|
||||||
type RoomUploadViewModel,
|
|
||||||
type RoomUploadViewSnapshot,
|
|
||||||
} from "../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
|
||||||
|
|
||||||
function FileDropTargetWrapped({
|
function FileDropTargetWrapped({
|
||||||
element,
|
element,
|
||||||
snapshot,
|
mayDragAndDropFile = false,
|
||||||
actions,
|
onFileDrop,
|
||||||
}: {
|
}: {
|
||||||
element: HTMLDivElement;
|
element: HTMLDivElement;
|
||||||
snapshot: RoomUploadViewSnapshot;
|
mayDragAndDropFile?: boolean;
|
||||||
actions: Partial<RoomUploadViewActions>;
|
onFileDrop: RoomUploadViewModel["initiateViaDataTransfer"];
|
||||||
}) {
|
}) {
|
||||||
const mockVm = useMockedViewModel<RoomUploadViewSnapshot, RoomUploadViewActions>(
|
const mockVm = useMockedViewModel<
|
||||||
snapshot,
|
{ mayDragAndDropFile: boolean },
|
||||||
actions as RoomUploadViewActions,
|
Pick<RoomUploadViewModel, "initiateViaDataTransfer">
|
||||||
);
|
>({ mayDragAndDropFile }, { initiateViaDataTransfer: onFileDrop });
|
||||||
return (
|
return (
|
||||||
<RoomUploadContext.Provider value={mockVm as RoomUploadViewModel}>
|
<RoomUploadContext.Provider value={mockVm as RoomUploadViewModel}>
|
||||||
<FileDropTarget parent={element} />
|
<FileDropTarget parent={element} />
|
||||||
@@ -43,11 +38,7 @@ describe("FileDropTarget", () => {
|
|||||||
const onFileDrop = jest.fn();
|
const onFileDrop = jest.fn();
|
||||||
|
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<FileDropTargetWrapped
|
<FileDropTargetWrapped element={element} mayDragAndDropFile onFileDrop={onFileDrop} />,
|
||||||
element={element}
|
|
||||||
snapshot={{ mayUpload: true }}
|
|
||||||
actions={{ initiateViaDataTransfer: onFileDrop }}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@@ -56,11 +47,7 @@ describe("FileDropTarget", () => {
|
|||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
const onFileDrop = jest.fn();
|
const onFileDrop = jest.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<FileDropTargetWrapped
|
<FileDropTargetWrapped element={element} mayDragAndDropFile onFileDrop={onFileDrop} />,
|
||||||
element={element}
|
|
||||||
snapshot={{ mayUpload: true }}
|
|
||||||
actions={{ initiateViaDataTransfer: onFileDrop }}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
fireEvent.dragEnter(element, {
|
fireEvent.dragEnter(element, {
|
||||||
dataTransfer: {
|
dataTransfer: {
|
||||||
@@ -73,13 +60,7 @@ describe("FileDropTarget", () => {
|
|||||||
it("should not render drop file prompt on mouse over with file if permissions do not allow", () => {
|
it("should not render drop file prompt on mouse over with file if permissions do not allow", () => {
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
const onFileDrop = jest.fn();
|
const onFileDrop = jest.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(<FileDropTargetWrapped element={element} onFileDrop={onFileDrop} />);
|
||||||
<FileDropTargetWrapped
|
|
||||||
element={element}
|
|
||||||
snapshot={{ mayUpload: false }}
|
|
||||||
actions={{ initiateViaDataTransfer: onFileDrop }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
fireEvent.dragEnter(element, {
|
fireEvent.dragEnter(element, {
|
||||||
dataTransfer: {
|
dataTransfer: {
|
||||||
types: ["Files"],
|
types: ["Files"],
|
||||||
|
|||||||
@@ -1079,27 +1079,6 @@ describe("RoomView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handles Action.ComposerInsert", () => {
|
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 () => {
|
it("redispatches an empty composerType with the current state", async () => {
|
||||||
await mountRoomView();
|
await mountRoomView();
|
||||||
const promise = untilDispatch((payload) => {
|
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>
|
</div>
|
||||||
<div
|
<div
|
||||||
aria-labelledby="_r_1cl_"
|
aria-labelledby="_r_1dt_"
|
||||||
class="_banner_n7ud0_8"
|
class="_banner_n7ud0_8"
|
||||||
data-type="critical"
|
data-type="critical"
|
||||||
role="status"
|
role="status"
|
||||||
@@ -238,7 +238,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
|||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_mcq5y_24"
|
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
|
Could not start a chat with this user
|
||||||
</p>
|
</p>
|
||||||
@@ -423,7 +423,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-label="Messages in this room are not end-to-end encrypted"
|
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"
|
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
|
||||||
color="var(--cpd-color-icon-info-primary)"
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -608,24 +608,32 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
aria-label="Attachment"
|
aria-label="Attachment"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button"
|
aria-labelledby="_r_1bs_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 26px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
fill="currentColor"
|
class="_indicator-icon_147l5_17"
|
||||||
height="1em"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
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"
|
fill="currentColor"
|
||||||
/>
|
height="1em"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
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
|
<div
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
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>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
aria-label="New members see history"
|
aria-label="New members see history"
|
||||||
aria-labelledby="_r_1cf_"
|
aria-labelledby="_r_1dn_"
|
||||||
class="mx_RoomHeader_icon"
|
class="mx_RoomHeader_icon"
|
||||||
color="var(--cpd-color-icon-info-primary)"
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -981,24 +989,32 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
aria-label="Attachment"
|
aria-label="Attachment"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button"
|
aria-labelledby="_r_1dc_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 26px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
fill="currentColor"
|
class="_indicator-icon_147l5_17"
|
||||||
height="1em"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
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"
|
fill="currentColor"
|
||||||
/>
|
height="1em"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
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
|
<div
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
||||||
@@ -1599,24 +1615,32 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
aria-label="Attachment"
|
aria-label="Attachment"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button"
|
aria-labelledby="_r_27_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 26px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
fill="currentColor"
|
class="_indicator-icon_147l5_17"
|
||||||
height="1em"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
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"
|
fill="currentColor"
|
||||||
/>
|
height="1em"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
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
|
<div
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_5e_"
|
aria-labelledby="_r_5i_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_5j_"
|
aria-labelledby="_r_5n_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -1766,7 +1790,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Threads"
|
aria-label="Threads"
|
||||||
aria-labelledby="_r_5o_"
|
aria-labelledby="_r_5s_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -1793,7 +1817,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Room info"
|
aria-label="Room info"
|
||||||
aria-labelledby="_r_5t_"
|
aria-labelledby="_r_61_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -1823,7 +1847,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-label="0 members"
|
aria-label="0 members"
|
||||||
aria-labelledby="_r_62_"
|
aria-labelledby="_r_66_"
|
||||||
class="mx_AccessibleButton mx_FacePile"
|
class="mx_AccessibleButton mx_FacePile"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -1890,7 +1914,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-label="Messages in this room are not end-to-end encrypted"
|
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"
|
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
|
||||||
color="var(--cpd-color-icon-info-primary)"
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -2075,24 +2099,32 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
aria-label="Attachment"
|
aria-label="Attachment"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button"
|
aria-labelledby="_r_7n_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 26px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
fill="currentColor"
|
class="_indicator-icon_147l5_17"
|
||||||
height="1em"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
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"
|
fill="currentColor"
|
||||||
/>
|
height="1em"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
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
|
<div
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_2g_"
|
aria-labelledby="_r_2i_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_2l_"
|
aria-labelledby="_r_2n_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -2242,7 +2274,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Threads"
|
aria-label="Threads"
|
||||||
aria-labelledby="_r_2q_"
|
aria-labelledby="_r_2s_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -2269,7 +2301,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Room info"
|
aria-label="Room info"
|
||||||
aria-labelledby="_r_2v_"
|
aria-labelledby="_r_31_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -2299,7 +2331,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-label="0 members"
|
aria-label="0 members"
|
||||||
aria-labelledby="_r_34_"
|
aria-labelledby="_r_36_"
|
||||||
class="mx_AccessibleButton mx_FacePile mx_FacePile_toggled"
|
class="mx_AccessibleButton mx_FacePile mx_FacePile_toggled"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -2366,7 +2398,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-label="Messages in this room are not end-to-end encrypted"
|
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"
|
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
|
||||||
color="var(--cpd-color-icon-info-primary)"
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -2551,24 +2583,32 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
aria-label="Attachment"
|
aria-label="Attachment"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button"
|
aria-labelledby="_r_4n_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 26px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
fill="currentColor"
|
class="_indicator-icon_147l5_17"
|
||||||
height="1em"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
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"
|
fill="currentColor"
|
||||||
/>
|
height="1em"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
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
|
<div
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_fc_"
|
aria-labelledby="_r_fo_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_fh_"
|
aria-labelledby="_r_ft_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -2718,7 +2758,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Threads"
|
aria-label="Threads"
|
||||||
aria-labelledby="_r_fm_"
|
aria-labelledby="_r_g2_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -2745,7 +2785,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Room info"
|
aria-label="Room info"
|
||||||
aria-labelledby="_r_fr_"
|
aria-labelledby="_r_g7_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -2775,7 +2815,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-label="0 members"
|
aria-label="0 members"
|
||||||
aria-labelledby="_r_g0_"
|
aria-labelledby="_r_gc_"
|
||||||
class="mx_AccessibleButton mx_FacePile"
|
class="mx_AccessibleButton mx_FacePile"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -2881,7 +2921,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
aria-label="New members see history"
|
aria-label="New members see history"
|
||||||
aria-labelledby="_r_gc_"
|
aria-labelledby="_r_go_"
|
||||||
class="mx_RoomHeader_icon"
|
class="mx_RoomHeader_icon"
|
||||||
color="var(--cpd-color-icon-info-primary)"
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
fill="currentColor"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_fc_"
|
aria-labelledby="_r_fo_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
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%;"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-labelledby="_r_fh_"
|
aria-labelledby="_r_ft_"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="1em"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -2956,7 +2996,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Threads"
|
aria-label="Threads"
|
||||||
aria-labelledby="_r_fm_"
|
aria-labelledby="_r_g2_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -2983,7 +3023,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Room info"
|
aria-label="Room info"
|
||||||
aria-labelledby="_r_fr_"
|
aria-labelledby="_r_g7_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -3013,7 +3053,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-label="0 members"
|
aria-label="0 members"
|
||||||
aria-labelledby="_r_g0_"
|
aria-labelledby="_r_gc_"
|
||||||
class="mx_AccessibleButton mx_FacePile"
|
class="mx_AccessibleButton mx_FacePile"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -3082,7 +3122,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-labelledby="_r_gh_"
|
aria-labelledby="_r_gt_"
|
||||||
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
|
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
|
||||||
data-testid="e2e-icon"
|
data-testid="e2e-icon"
|
||||||
style="width: 12px; height: 12px;"
|
style="width: 12px; height: 12px;"
|
||||||
@@ -3275,24 +3315,32 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
aria-label="Attachment"
|
aria-label="Attachment"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button"
|
aria-labelledby="_r_i2_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 26px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
fill="currentColor"
|
class="_indicator-icon_147l5_17"
|
||||||
height="1em"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
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"
|
fill="currentColor"
|
||||||
/>
|
height="1em"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
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
|
<div
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
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>
|
||||||
<button
|
<button
|
||||||
aria-label="Chat"
|
aria-label="Chat"
|
||||||
aria-labelledby="_r_ks_"
|
aria-labelledby="_r_lc_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -3445,7 +3493,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Threads"
|
aria-label="Threads"
|
||||||
aria-labelledby="_r_l1_"
|
aria-labelledby="_r_lh_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -3472,7 +3520,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Room info"
|
aria-label="Room info"
|
||||||
aria-labelledby="_r_l6_"
|
aria-labelledby="_r_lm_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -3502,7 +3550,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-label="0 members"
|
aria-label="0 members"
|
||||||
aria-labelledby="_r_lb_"
|
aria-labelledby="_r_lr_"
|
||||||
class="mx_AccessibleButton mx_FacePile"
|
class="mx_AccessibleButton mx_FacePile"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -3587,7 +3635,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="_r_lk_"
|
aria-labelledby="_r_m4_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-testid="base-card-close-button"
|
data-testid="base-card-close-button"
|
||||||
@@ -3647,7 +3695,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-label="Messages in this room are not end-to-end encrypted"
|
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"
|
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
|
||||||
color="var(--cpd-color-icon-info-primary)"
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -3832,24 +3880,32 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
aria-label="Attachment"
|
aria-label="Attachment"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button"
|
aria-labelledby="_r_ne_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
role="button"
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 26px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
fill="currentColor"
|
class="_indicator-icon_147l5_17"
|
||||||
height="1em"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
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"
|
fill="currentColor"
|
||||||
/>
|
height="1em"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
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
|
<div
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"
|
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 UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
|
||||||
import { Action } from "../../../../../src/dispatcher/actions";
|
import { Action } from "../../../../../src/dispatcher/actions";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
import { TimelineRenderingType, type RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
||||||
import {
|
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
RoomUploadContext,
|
|
||||||
type RoomUploadViewModel,
|
|
||||||
} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
|
||||||
|
|
||||||
const openStickerPicker = async (): Promise<void> => {
|
const openStickerPicker = async (): Promise<void> => {
|
||||||
await userEvent.click(screen.getByLabelText("More options"));
|
await userEvent.click(screen.getByLabelText("More options"));
|
||||||
@@ -462,7 +459,8 @@ function wrapAndRender(
|
|||||||
canSendMessages,
|
canSendMessages,
|
||||||
tombstone,
|
tombstone,
|
||||||
narrow,
|
narrow,
|
||||||
} as unknown as RoomContextType;
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
} satisfies Partial<RoomContextType> as RoomContextType;
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
room,
|
room,
|
||||||
@@ -473,9 +471,9 @@ function wrapAndRender(
|
|||||||
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
|
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<ScopedRoomContextProvider {...context}>
|
<ScopedRoomContextProvider {...context}>
|
||||||
<RoomUploadContext.Provider value={{} as RoomUploadViewModel}>
|
<RoomUploadContextProvider>
|
||||||
<MessageComposer {...defaultProps} {...props} />
|
<MessageComposer {...defaultProps} {...props} />
|
||||||
</RoomUploadContext.Provider>
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>
|
</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.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
||||||
import { Action } from "../../../src/dispatcher/actions";
|
import { Action } from "../../../src/dispatcher/actions";
|
||||||
import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
|
import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
|
||||||
import { ComposerApi } from "../../../src/modules/ComposerApi";
|
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", () => {
|
describe("ComposerApi", () => {
|
||||||
it("should be able to insert text via insertTextIntoComposer()", () => {
|
describe("insertPlaintextIntoComposer()", () => {
|
||||||
const dispatcher = {
|
it("should be able to insert text", () => {
|
||||||
dispatch: jest.fn(),
|
const dispatcher = {
|
||||||
} as unknown as MatrixDispatcher;
|
dispatch: jest.fn(),
|
||||||
const api = new ComposerApi(dispatcher);
|
} as unknown as MatrixDispatcher;
|
||||||
api.insertPlaintextIntoComposer("Hello world");
|
const api = new ComposerApi(dispatcher);
|
||||||
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
api.insertPlaintextIntoComposer("Hello world", { view: "room" });
|
||||||
action: Action.ComposerInsert,
|
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||||
text: "Hello world",
|
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
|
* 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.
|
* 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 { 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 type { MockedObject } from "jest-mock";
|
||||||
import { RoomUploadViewModel } from "../../../src/viewmodels/room/RoomUploadViewModel";
|
import { RoomUploadContextProvider, RoomUploadViewModel } from "../../../src/viewmodels/room/RoomUploadViewModel";
|
||||||
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
|
import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../test-utils";
|
||||||
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
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 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");
|
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
|
||||||
|
|
||||||
describe("RoomUploadViewModel", () => {
|
describe("RoomUploadViewModel", () => {
|
||||||
@@ -31,7 +37,7 @@ describe("RoomUploadViewModel", () => {
|
|||||||
jest.restoreAllMocks();
|
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);
|
room.maySendMessage.mockReturnValue(maySendMessage);
|
||||||
const vm = new RoomUploadViewModel(
|
const vm = new RoomUploadViewModel(
|
||||||
room,
|
room,
|
||||||
@@ -42,10 +48,45 @@ describe("RoomUploadViewModel", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
expect(vm.getSnapshot().mayUpload).toEqual(maySendMessage);
|
expect(vm.getSnapshot().options).toHaveLength(maySendMessage ? 1 : 0);
|
||||||
room.maySendMessage.mockReturnValue(!maySendMessage);
|
room.maySendMessage.mockReturnValue(!maySendMessage);
|
||||||
room.emit(RoomEvent.CurrentStateUpdated, room, null as any, null as any);
|
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", () => {
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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.
|
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.
|
* API to interact with the message composer.
|
||||||
* @alpha Likely to change
|
* @alpha Likely to change
|
||||||
*/
|
*/
|
||||||
export interface ComposerApi {
|
export interface ComposerApi {
|
||||||
/**
|
/**
|
||||||
* Insert plaintext into the current composer.
|
* Add a new file upload option for the user.
|
||||||
* @param plaintext - The plain text to insert
|
* @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.
|
* @returns Returns immediately, does not await action.
|
||||||
* @alpha Likely to change
|
* @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;
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -13,6 +13,7 @@ export * from "./audio/SeekBar";
|
|||||||
export * from "./core/AvatarWithDetails";
|
export * from "./core/AvatarWithDetails";
|
||||||
export * from "./core/roving";
|
export * from "./core/roving";
|
||||||
export * from "./room/composer/Banner";
|
export * from "./room/composer/Banner";
|
||||||
|
export * from "./room/composer/UploadButton";
|
||||||
export * from "./crypto/SasEmoji";
|
export * from "./crypto/SasEmoji";
|
||||||
export * from "./menus/UserMenu";
|
export * from "./menus/UserMenu";
|
||||||
export * from "./room/timeline/ReadMarker";
|
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";
|
||||||