Bunch of refactors

This commit is contained in:
Half-Shot 2026-05-15 13:40:56 +01:00
parent 2719502240
commit bf558dbc18
18 changed files with 216 additions and 138 deletions

View File

@ -24,10 +24,11 @@ export default class CustomComponentModule {
this.api.composer.addFileUploadOption({
type: "org.example.uploader",
label: "Example uploader",
onSelected: () => {
this.api.composer.openFileUploadConfirmation([
new File(["test"], "testfile.txt", { type: "text/plain" }),
]);
onSelected: (_roomId, view) => {
this.api.composer.openFileUploadConfirmation(
[new File(["test"], "testfile.txt", { type: "text/plain" })],
view,
);
},
});
}

View File

@ -1299,7 +1299,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const composerInsertPayload = payload as ComposerInsertPayload;
if (composerInsertPayload.composerType) break;
let timelineRenderingType: TimelineRenderingType | undefined;
let timelineRenderingType = composerInsertPayload.timelineRenderingType;
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break;
if (
@ -1311,12 +1311,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
timelineRenderingType = TimelineRenderingType.Room;
}
// If the dispatchee didn't request a timeline rendering type, use the current one.
timelineRenderingType =
timelineRenderingType ??
composerInsertPayload.timelineRenderingType ??
this.state.timelineRenderingType;
// re-dispatch to the correct composer
defaultDispatcher.dispatch<ComposerInsertPayload>({
...composerInsertPayload,

View File

@ -38,7 +38,6 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import Measured from "../elements/Measured";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
import { RoomUploadContextProvider } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
interface IProps {
@ -215,49 +214,47 @@ export default class TimelineCard extends React.Component<IProps, IState> {
header={_t("right_panel|video_room_chat|title")}
ref={this.card}
>
<RoomUploadContextProvider>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_TimelineCard_timeline">
{jumpToBottom}
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
ref={this.timelinePanel}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={false} // No RM support in the TimelineCard
sendReadReceiptOnLoad={true}
timelineSet={this.props.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// The right panel timeline (and therefore threads) don't support IRC layout at this time
layout={layout}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
/>
</EventPresentationContextProvider>
</div>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
{showComposer && (
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_TimelineCard_timeline">
{jumpToBottom}
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
ref={this.timelinePanel}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={false} // No RM support in the TimelineCard
sendReadReceiptOnLoad={true}
timelineSet={this.props.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// The right panel timeline (and therefore threads) don't support IRC layout at this time
layout={layout}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
membersLoaded={true}
editState={this.state.editState}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
/>
)}
</RoomUploadContextProvider>
</EventPresentationContextProvider>
</div>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
{showComposer && (
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
</BaseCard>
</ScopedRoomContextProvider>
);

View File

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

View File

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

View File

@ -15,11 +15,10 @@ import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { focusComposer } from "./utils";
import { type ComposerFunctions } from "../types";
import { type ComposerInsertPayload, ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
import { useComposerContext } from "../ComposerContext";
import { setSelection } from "../utils/selection";
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
export function useWysiwygSendActionHandler(
disabled: boolean,
@ -28,7 +27,6 @@ export function useWysiwygSendActionHandler(
): void {
const roomContext = useScopedRoomContext("timelineRenderingType");
const composerContext = useComposerContext();
const uploadVm = useRoomUploadViewModel();
const timeoutId = useRef<number | null>(null);
const handler = useCallback(
@ -52,25 +50,21 @@ export function useWysiwygSendActionHandler(
composerFunctions.clear();
focusComposer(composerElement, context, roomContext, timeoutId);
break;
case Action.ComposerInsert: {
const insertPayload = payload as ComposerInsertPayload;
if (insertPayload.timelineRenderingType !== roomContext.timelineRenderingType) break;
if (insertPayload.composerType !== ComposerType.Send) break;
case Action.ComposerInsert:
if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break;
if (payload.composerType !== ComposerType.Send) break;
if (insertPayload.userId) {
if (payload.userId) {
// TODO insert mention - see SendMessageComposer
} else if (insertPayload.event) {
} else if (payload.event) {
// TODO insert quote message - see SendMessageComposer
} else if (insertPayload.text) {
} else if (payload.text) {
setSelection(composerContext.selection).then(() => composerFunctions.insertText(payload.text));
} else if (insertPayload.files) {
uploadVm.initiateViaInputFiles(insertPayload.files);
}
break;
}
}
},
[disabled, composerElement, roomContext, composerFunctions, composerContext, uploadVm],
[disabled, composerElement, roomContext, composerFunctions, composerContext],
);
useDispatcher(defaultDispatcher, handler);

View File

@ -195,6 +195,11 @@ export enum Action {
*/
ComposerInsert = "composer_insert",
/**
* Inserts a file into a target composer.
*/
ComposerFileInsert = "composer_insert_file",
/**
* Switches space. Should be used with SwitchSpacePayload.
*/

View File

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

View File

@ -17,7 +17,7 @@ export enum ComposerType {
interface IBaseComposerInsertPayload extends ActionPayload {
action: Action.ComposerInsert;
timelineRenderingType?: TimelineRenderingType; // undefined if this should just use the current in-focus type.
timelineRenderingType: TimelineRenderingType;
composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
}
@ -29,11 +29,4 @@ interface IComposerInsertPlaintextPayload extends IBaseComposerInsertPayload {
text: string;
}
interface IComposerInsertFilesPayload extends IBaseComposerInsertPayload {
files: File[];
}
export type ComposerInsertPayload =
| IComposerInsertMentionPayload
| IComposerInsertPlaintextPayload
| IComposerInsertFilesPayload;
export type ComposerInsertPayload = IComposerInsertMentionPayload | IComposerInsertPlaintextPayload;

View File

@ -8,12 +8,15 @@ Please see LICENSE files in the repository root for full details.
import {
type ComposerApi as ModuleComposerApi,
type ComposerApiFileUploadOption,
type ComposerApiView,
} from "@element-hq/element-web-module-api";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
import { ComposerType, type ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
import { TimelineRenderingType } from "../contexts/RoomContext";
import type { ComposerInsertFilesPayload } from "../dispatcher/payloads/ComposerInsertFilePayload";
export enum ModuleComposerApiEvents {
UploaderOptionsChanged = "uploaderOptionsChanged",
@ -51,17 +54,27 @@ export class ComposerApi
this.emit(ModuleComposerApiEvents.UploaderOptionsChanged, option);
}
public openFileUploadConfirmation(files: File[]): void {
public openFileUploadConfirmation(files: File[], view: ComposerApiView): void {
if (!["room", "thread"].includes(view.view)) {
throw new Error(`Invalid view '${view.view}'`);
}
this.dispatcher.dispatch({
action: Action.ComposerInsert,
action: Action.ComposerFileInsert,
files,
} satisfies ComposerInsertPayload);
timelineRenderingType: view.view === "room" ? TimelineRenderingType.Room : TimelineRenderingType.Thread,
} satisfies ComposerInsertFilesPayload);
}
public insertPlaintextIntoComposer(plaintext: string): void {
public insertPlaintextIntoComposer(plaintext: string, view: ComposerApiView): void {
if (!["room", "thread"].includes(view.view)) {
throw new Error(`Invalid view '${view.view}'`);
}
this.dispatcher.dispatch({
action: Action.ComposerInsert,
text: plaintext,
timelineRenderingType: view.view === "room" ? TimelineRenderingType.Room : TimelineRenderingType.Thread,
// We only support send.
composerType: ComposerType.Send,
} satisfies ComposerInsertPayload);
}
}

View File

@ -35,12 +35,16 @@ import type { ComposerApiFileUploadOption } from "@element-hq/element-web-module
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";
import ContentMessages from "../../ContentMessages";
import type { TimelineRenderingType } from "../../contexts/RoomContext";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import { chromeFileInputFix } from "../../utils/BrowserWorkarounds";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ModuleApi } from "../../modules/Api";
import { ModuleComposerApiEvents } from "../../modules/ComposerApi";
import { Action } from "../../dispatcher/actions";
import type { ComposerInsertFilesPayload } from "../../dispatcher/payloads/ComposerInsertFilePayload";
import { useDispatcher } from "../../hooks/useDispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
const logger = rootLogger.getChild("RoomUploadViewModel");
@ -49,6 +53,7 @@ export class RoomUploadViewModel
implements UploadButtonViewActions
{
private readonly uploadSelectFns = new Map<string, ComposerApiFileUploadOption["onSelected"]>();
private readonly instanceId = window.crypto.randomUUID();
public constructor(
private readonly room: Room,
private readonly client: MatrixClient,
@ -65,21 +70,29 @@ export class RoomUploadViewModel
options: [],
},
);
logger.info(`Creating ${this.instanceId}`);
// Initial check.
this.onRoomCurrentStateUpdated();
// Configure upload functions
for (const option of moduleComposerApi.fileUploadOptions) {
this.uploadSelectFns.set(option.type, option.onSelected);
}
this.uploadSelectFns.set("local", this.openUploadDialog);
room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
this.disposables.trackListener(room, RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
moduleComposerApi.on(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged);
this.disposables.track(() => {
room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
moduleComposerApi.off(ModuleComposerApiEvents.UploaderOptionsChanged, this.onUploaderOptionsChanged);
});
this.disposables.trackListener(
moduleComposerApi,
ModuleComposerApiEvents.UploaderOptionsChanged,
// Types issue.
this.onUploaderOptionsChanged as any,
);
}
public dispose(): void {
logger.info(`Disposing of ${this.instanceId}`);
super.dispose();
}
private onRoomCurrentStateUpdated = (): void => {
@ -168,14 +181,23 @@ export class RoomUploadViewModel
};
public onUploadOptionSelected = (type: ComposerApiFileUploadOption["type"]): void => {
if (![TimelineRenderingType.Room, TimelineRenderingType.Thread].includes(this.timelineRenderingType)) {
throw new Error("Unexpectedly called onUploadOptionSelected outside the context of a room or thread");
}
const fn = this.uploadSelectFns.get(type);
if (!fn) {
throw new Error("Unexpectedly called onUploadOptionSelected with an unknown type");
}
fn(this.room.roomId, {
inReplyToEventId: this.replyToEvent?.getId(),
relType: this.threadRelation?.rel_type,
});
fn(
this.room.roomId,
{
view: this.timelineRenderingType === TimelineRenderingType.Room ? "room" : "thread",
},
{
inReplyToEventId: this.replyToEvent?.getId(),
relType: this.threadRelation?.rel_type,
},
);
};
private checkCanUpload(): boolean {
@ -209,6 +231,9 @@ export function RoomUploadContextProvider({
"timelineRenderingType",
"replyToEvent",
);
if (!room) {
throw new Error("RoomUploadContextProvider must have a room");
}
const client = useMatrixClientContext();
const uploadInput = useRef<HTMLInputElement>(null);
@ -219,20 +244,18 @@ export function RoomUploadContextProvider({
uploadInput.current.click();
}, [uploadInput]);
const vm = useCreateAutoDisposedViewModel(() => {
if (!room) {
throw new Error("RoomUploadContextProvider must have a room");
}
return new RoomUploadViewModel(
room,
client,
timelineRenderingType,
defaultDispatcher,
replyToEvent,
threadRelation,
openFilePicker,
);
});
const vm = useCreateAutoDisposedViewModel(
() =>
new RoomUploadViewModel(
room,
client,
timelineRenderingType,
defaultDispatcher,
replyToEvent,
threadRelation,
openFilePicker,
),
);
useEffect(() => {
vm.setReplyToEvent(replyToEvent);
@ -259,6 +282,21 @@ export function RoomUploadContextProvider({
[vm],
);
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
if (payload.action !== Action.ComposerFileInsert) {
return;
}
const fileInsert = payload as ComposerInsertFilesPayload;
if (fileInsert.timelineRenderingType === timelineRenderingType) {
logger.info(
`Got ComposerFileInsert with ${fileInsert.files.length} files`,
timelineRenderingType,
threadRelation,
);
vm.initiateViaInputFiles(fileInsert.files);
}
});
// Note, while this logic could be largely replaced with https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker
// it does not enjoy support across all our target platforms.
// Therefore, we use the invisible input element trick.

View File

@ -5,8 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { Action } from "../../../src/dispatcher/actions";
import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
import { type ComposerInsertPayload, ComposerType } from "../../../src/dispatcher/payloads/ComposerInsertPayload";
import { ComposerApi } from "../../../src/modules/ComposerApi";
describe("ComposerApi", () => {
@ -15,10 +17,12 @@ describe("ComposerApi", () => {
dispatch: jest.fn(),
} as unknown as MatrixDispatcher;
const api = new ComposerApi(dispatcher);
api.insertPlaintextIntoComposer("Hello world");
api.insertPlaintextIntoComposer("Hello world", { view: "room" });
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ComposerInsert,
text: "Hello world",
});
timelineRenderingType: TimelineRenderingType.Room,
composerType: ComposerType.Send,
} satisfies ComposerInsertPayload);
});
});

View File

@ -103,8 +103,8 @@ export interface ComponentVisibilityCustomisations {
// @alpha
export interface ComposerApi {
addFileUploadOption(option: ComposerApiFileUploadOption): void;
insertPlaintextIntoComposer(plaintext: string): void;
openFileUploadConfirmation(file: File[]): void;
insertPlaintextIntoComposer(plaintext: string, view: ComposerApiTarget): void;
openFileUploadConfirmation(files: File[], view: ComposerApiTarget): void;
}
// @alpha
@ -112,12 +112,19 @@ export type ComposerApiFileUploadOption = {
type: string;
label: string;
icon?: ComponentType<SVGAttributes<SVGElement>>;
onSelected: (roomId: string, relation?: {
onSelected: (roomId?: string, view?: ComposerApiTarget, relation?: {
inReplyToEventId?: string;
relType?: string;
}) => Promise<void> | void;
};
// @alpha
export type ComposerApiTarget = {
view: "room";
} | {
view: "thread";
};
// @public
export interface Config {
// (undocumented)

View File

@ -26,6 +26,7 @@
],
"scripts": {
"prepack": "nx build",
"start": "nx start",
"lint:types": "nx lint:types",
"test:unit": "vitest"
},

View File

@ -9,7 +9,7 @@
"inputs": ["src"],
"outputs": ["{projectRoot}/lib"],
"options": {
"commands": ["vite build", "api-extractor run"],
"commands": ["vite build", "api-extractor run -l"],
"parallel": false,
"cwd": "packages/module-api"
}

View File

@ -32,7 +32,8 @@ export type ComposerApiFileUploadOption = {
* @returns
*/
onSelected: (
roomId: string,
roomId?: string,
view?: ComposerApiTarget,
relation?: {
inReplyToEventId?: string;
relType?: string;
@ -40,6 +41,12 @@ export type ComposerApiFileUploadOption = {
) => Promise<void> | void;
};
/**
* When handling composer interactions, this represents the target composer.
* @alpha Likely to change. This is intentionally left as an object so it may be extended later.
*/
export type ComposerApiTarget = { view: "room" } | { view: "thread" };
/**
* API to interact with the message composer.
* @alpha Likely to change
@ -54,13 +61,18 @@ export interface ComposerApi {
/**
* Open the file upload confirmation dialog. This may be used in conjunction
* with `addFileUploadOption` to support an alternative file upload kind.
*/
openFileUploadConfirmation(file: File[]): void;
/**
* Insert plaintext into the current composer.
* @param plaintext - The plain text to insert
* @param files - The files to prompt for
* @param view - The target view to send the file into.
* @returns Returns immediately, does not await action.
* @alpha Likely to change
*/
insertPlaintextIntoComposer(plaintext: string): void;
openFileUploadConfirmation(files: File[], view: ComposerApiTarget): void;
/**
* Insert plaintext into the current composer.
* @param plaintext - The plain text to insert
* @param view - The target view to insert into
* @returns Returns immediately, does not await action.
* @alpha Likely to change
*/
insertPlaintextIntoComposer(plaintext: string, view: ComposerApiTarget): void;
}

View File

@ -13,13 +13,20 @@ import { fn } from "storybook/test";
import * as stories from "./UploadButton.stories.tsx";
const { Default } = composeStories(stories);
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} />);

View File

@ -6,15 +6,15 @@ exports[`UploadButton > can open the menu and select an option 1`] = `
data-aria-hidden="true"
>
<button
aria-controls="radix-_r_9_"
aria-controls="radix-_r_f_"
aria-disabled="false"
aria-expanded="true"
aria-haspopup="menu"
aria-labelledby="_r_a_"
aria-labelledby="_r_g_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="open"
id="radix-_r_8_"
id="radix-_r_e_"
role="button"
style="--cpd-icon-button-size: 26px;"
tabindex="0"