Refactor MessageActionBar using MVVM and move to shared-components (#32784)

* Refactor MessageActionBar into MVVM ActionBarView

* Adding tooltips for menu items and correct i18n strings

* Layout changes

* Renaming some properties

* Rename property

* Create a first version of the view model and refactor media visibility logic

* Refactor view to take options and rections menu as optional properties

* Cleaner interface between view and view model

* Refactor view properties and replace Menu and MenuItem

* Bugfixes and switching to ActionBarView instead of MessageActionBar in element-web

* Avoid creating view models and render toolbar until it is actually shown

* Added unit and playwright tests and documented the view

* Added view model unit tests and updated snapshots of dependant tests

* Remove unused components and unnecessary css

* Remove unused language tags

* Fix for handling join-rules correctly

* Prettier

* Add handling of stale view model in async calls

* Prettier

* Split the element-web css into two different. One for legacy components and one for the ActionBarView

* Missing variables used for linting

* Fix for showing ActionBarView when using keyboard for navigation

* Handle visibility on context menu closing

* ThreadPanel uses the ActionBarView so restore css rule

* Fix for visibility of the ActionBarView in Thread panel

* Fix for ActionBarVuew visibility when closing right-click context menu and not still hovering

* Add roving index to function as a toolbar

* Adjust the RoomView test to send hover to the EventTile instead of the message text

* Fix SonarCloud issues

* Fix for SonarCloud issue

* Merge fix

* Rename mx_LegacyActionBar to mx_ThreadActionBar

* Added documentation and simplified join rules

* Generalize the ActionBarView and move logic to view model

* Add the four new buttons to the ActionBarView

* Update view model and tests to use the updated ActionBarView

* Refactor element-web to use ActionBarView

* Clean up styling in element-web

* Clean up and updating snaps and screenshots

* Added unit-tests for better coverage

* Moving ActionBarView to the correct folder in shared components

* Update snaps in element-web

* Better documentation in stories

* Merge fixes

* Updates after review comments

* Review comment fixes

* Added documentation to view models and updated snaps

* Hide button had the wrong label

* Replace createRef with useRef
This commit is contained in:
rbondesson
2026-04-01 12:27:03 +00:00
committed by GitHub
parent 0391543bbc
commit 4315038346
46 changed files with 4071 additions and 2155 deletions
@@ -4,6 +4,7 @@
},
"action": {
"back": "Back",
"click": "Click",
"collapse": "Collapse",
"delete": "Delete",
"dismiss": "Dismiss",
@@ -11,24 +12,35 @@
"edit": "Edit",
"explore_rooms": "Explore rooms",
"go": "Go",
"hide": "Hide",
"invite": "Invite",
"new_conversation": "New conversation",
"new_room": "New room",
"new_video_room": "New video room",
"open_menu": "Open menu",
"pause": "Pause",
"pin": "Pin",
"play": "Play",
"react": "React",
"remove": "Remove",
"reply": "Reply",
"reply_in_thread": "Reply in thread",
"retry": "Retry",
"search": "Search",
"start_chat": "Start chat"
"start_chat": "Start chat",
"unpin": "Unpin",
"view_source": "View Source"
},
"common": {
"attachment": "Attachment",
"encryption_enabled": "Encryption enabled",
"options": "Options",
"preferences": "Preferences",
"state_encryption_enabled": "Experimental state encryption enabled"
},
"keyboard": {
"shift": "Shift"
},
"left_panel": {
"open_dial_pad": "Open dial pad",
"separator_label": "Click or drag to expand"
@@ -146,6 +158,9 @@
"terms": {
"tac_button": "Review terms and conditions"
},
"threads": {
"error_start_thread_existing_relation": "Can't create a thread from an event with an existing relation"
},
"time": {
"about_day_ago": "about a day ago",
"about_hour_ago": "about an hour ago",
@@ -172,6 +187,8 @@
"sender_unsigned_device": "Sent from an insecure device.",
"unable_to_decrypt": "Unable to decrypt message"
},
"download_action_decrypting": "Decrypting",
"download_action_downloading": "Downloading",
"m.audio": {
"audio_player": "Audio player",
"error_downloading_audio": "Error downloading audio",
@@ -190,6 +207,13 @@
"state_enabled": "Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.",
"unsupported": "The encryption used by this room isn't supported."
},
"mab": {
"collapse_reply_chain": "Collapse quotes",
"copy_link_thread": "Copy link to thread",
"expand_reply_chain": "Expand quotes",
"label": "Message Actions",
"view_in_room": "View in room"
},
"message_timestamp_received_at": "Received at: %(dateTime)s",
"message_timestamp_sent_at": "Sent at: %(dateTime)s",
"url_preview": {
+1
View File
@@ -24,6 +24,7 @@ export * from "./room/WidgetPip";
export * from "./room/HistoryVisibilityBadge";
export * from "./room/timeline/DateSeparatorView";
export * from "./room/timeline/TimelineSeparator";
export * from "./room/timeline/event-tile/actions/ActionBarView";
export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile";
export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView";
export * from "./room/timeline/event-tile/EventTileView/EventTileBubble";
@@ -0,0 +1,67 @@
/*
* 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 React, { type JSX } from "react";
import { Button, Tooltip } from "@vector-im/compound-web";
import styles from "./ActionBarView.module.css";
interface ActionBarButtonProps {
presentation: "icon" | "label";
buttonRef: React.Ref<HTMLButtonElement>;
label: string;
onActivate?: (anchor: HTMLElement | null) => void;
icon?: React.ComponentProps<typeof Button>["Icon"];
disabled?: boolean;
ariaPressed?: boolean;
ariaExpanded?: boolean;
tooltipDescription?: string;
tooltipCaption?: string;
}
export function ActionBarButton({
presentation,
buttonRef,
label,
onActivate,
icon,
disabled,
ariaPressed,
ariaExpanded,
tooltipDescription,
tooltipCaption,
}: Readonly<ActionBarButtonProps>): JSX.Element {
const iconOnly = presentation === "icon";
const handleContextMenu = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
event.stopPropagation();
onActivate?.(event.currentTarget);
};
return (
<Tooltip description={tooltipDescription ?? label} caption={tooltipCaption} placement="top">
<Button
data-presentation={presentation}
ref={buttonRef}
kind="tertiary"
size="sm"
iconOnly={iconOnly}
aria-label={label}
aria-pressed={ariaPressed}
aria-expanded={ariaExpanded}
disabled={disabled}
onClick={(event) => onActivate?.(event.currentTarget)}
onContextMenu={handleContextMenu}
className={styles.toolbar_item}
Icon={iconOnly ? icon : undefined}
>
{iconOnly ? undefined : label}
</Button>
</Tooltip>
);
}
@@ -0,0 +1,44 @@
/*
* 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.
*/
.toolbar {
gap: var(--cpd-space-0-5x);
border-radius: 8px;
background-color: var(--cpd-color-bg-canvas-default);
border: var(--cpd-border-width-1) solid var(--cpd-color-border-disabled);
.toolbar_item {
align-items: center;
justify-content: center;
padding: 0 !important;
min-block-size: 0 !important;
margin: 3px !important;
border-radius: 6px !important;
&:hover {
background-color: var(--cpd-color-bg-subtle-secondary) !important;
z-index: 1;
}
}
.toolbar_item[data-presentation="icon"] {
block-size: 28px !important;
inline-size: 28px !important;
color: var(--cpd-color-icon-secondary) !important;
}
.toolbar_item[data-presentation="label"] {
padding-inline-start: var(--cpd-space-2x) !important;
padding-inline-end: var(--cpd-space-2x) !important;
font: var(--cpd-font-body-md-regular);
text-decoration: none !important;
white-space: nowrap;
}
}
@@ -0,0 +1,194 @@
/*
* 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 React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ActionBarAction, ActionBarView, type ActionBarViewActions, type ActionBarViewSnapshot } from "./ActionBarView";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
type ActionBarProps = ActionBarViewSnapshot & ActionBarViewActions;
const ActionBarViewWrapperImpl = ({ ...snapshotAndActions }: ActionBarProps): JSX.Element => {
const {
onCancelClick = fn(),
onCopyLinkClick = fn(),
onDownloadClick = fn(),
onEditClick = fn(),
onHideClick = fn(),
onOptionsClick = fn(),
onPinClick = fn(),
onReactionsClick = fn(),
onRemoveClick = fn(),
onReplyClick = fn(),
onReplyInThreadClick = fn(),
onResendClick = fn(),
onToggleThreadExpanded = fn(),
onViewInRoomClick = fn(),
onViewSourceClick = fn(),
...snapshot
} = snapshotAndActions;
const vm = useMockedViewModel(snapshot, {
onCancelClick,
onCopyLinkClick,
onDownloadClick,
onEditClick,
onHideClick,
onOptionsClick,
onPinClick,
onReactionsClick,
onRemoveClick,
onReplyClick,
onReplyInThreadClick,
onResendClick,
onToggleThreadExpanded,
onViewInRoomClick,
onViewSourceClick,
});
return <ActionBarView vm={vm} className="mx_MessageActionBar" />;
};
const ActionBarViewWrapper = withViewDocs(ActionBarViewWrapperImpl, ActionBarView);
const meta = {
title: "Room/Timeline/EventTile/Actions/ActionBarView",
component: ActionBarViewWrapper,
tags: ["autodocs"],
argTypes: {
presentation: {
control: { type: "select" },
options: ["icon", "label"],
},
},
args: {
actions: [
ActionBarAction.Hide,
ActionBarAction.Download,
ActionBarAction.React,
ActionBarAction.Reply,
ActionBarAction.ReplyInThread,
ActionBarAction.Edit,
ActionBarAction.Pin,
ActionBarAction.Resend,
ActionBarAction.Cancel,
ActionBarAction.Expand,
ActionBarAction.Options,
ActionBarAction.ViewInRoom,
ActionBarAction.CopyLink,
ActionBarAction.Remove,
ActionBarAction.ViewSource,
],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
},
} satisfies Meta<typeof ActionBarViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllIconActions: Story = {};
export const AllLabelActions: Story = {
args: {
actions: [
ActionBarAction.Hide,
ActionBarAction.Download,
ActionBarAction.React,
ActionBarAction.Reply,
ActionBarAction.ReplyInThread,
ActionBarAction.Edit,
ActionBarAction.Pin,
ActionBarAction.Resend,
ActionBarAction.Cancel,
ActionBarAction.Expand,
ActionBarAction.Options,
ActionBarAction.ViewInRoom,
ActionBarAction.CopyLink,
ActionBarAction.Remove,
ActionBarAction.ViewSource,
],
presentation: "label",
},
};
export const DownloadingAttachment: Story = {
args: {
actions: [ActionBarAction.Download, ActionBarAction.Options],
isDownloadLoading: true,
isDownloadEncrypted: false,
},
parameters: {
docs: {
description: {
story: "Attachment download in progress. The download action is disabled and shows a spinner with the downloading label.",
},
},
},
};
export const DecryptingAttachment: Story = {
args: {
...DownloadingAttachment.args,
isDownloadEncrypted: true,
},
parameters: {
docs: {
description: {
story: "Encrypted attachment state. Uses the same loading UI as download, but with the decrypting label.",
},
},
},
};
export const PinnedMessage: Story = {
args: {
actions: [ActionBarAction.React, ActionBarAction.Reply, ActionBarAction.Pin, ActionBarAction.Options],
isPinned: true,
},
parameters: {
docs: {
description: {
story: "Pinned-state variant showing the unpin affordance instead of pin.",
},
},
},
};
export const ExpandedReplyChain: Story = {
args: {
actions: [ActionBarAction.Reply, ActionBarAction.Expand, ActionBarAction.Options],
isQuoteExpanded: true,
},
parameters: {
docs: {
description: {
story: "Reply-chain control in its expanded state, showing the collapse action and tooltip copy.",
},
},
},
};
export const DisabledThreadReply: Story = {
args: {
actions: [ActionBarAction.React, ActionBarAction.Reply, ActionBarAction.ReplyInThread, ActionBarAction.Options],
isThreadReplyAllowed: false,
},
parameters: {
docs: {
description: {
story: "Thread reply action present but disabled.",
},
},
},
};
@@ -0,0 +1,315 @@
/*
* 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 React from "react";
import { composeStories } from "@storybook/react-vite";
import { fireEvent, render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as stories from "./ActionBarView.stories.tsx";
import {
ActionBarAction,
type ActionBarViewActions,
ActionBarView,
type ActionBarViewSnapshot,
} from "./ActionBarView.tsx";
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel.ts";
const composedStories = composeStories(stories);
const { DownloadingAttachment, DecryptingAttachment, PinnedMessage, ExpandedReplyChain, DisabledThreadReply } =
composedStories;
describe("ActionBarView", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("story snapshots", () => {
for (const [storyName, Story] of Object.entries(composedStories)) {
it(`renders ${storyName}`, () => {
const { container } = render(<Story />);
expect(screen.getByRole("toolbar")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
}
});
it("renders retry and delete only when those are the resolved actions", () => {
const vm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.Resend, ActionBarAction.Cancel],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} />);
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /options/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /reply/i })).not.toBeInTheDocument();
});
it("renders a loading download action with the downloading label", () => {
render(<DownloadingAttachment />);
const button = screen.getByRole("button", { name: /downloading/i });
expect(button).toBeDisabled();
});
it("renders a loading download action with the decrypting label for encrypted media", () => {
render(<DecryptingAttachment />);
const button = screen.getByRole("button", { name: /decrypting/i });
expect(button).toBeDisabled();
});
it("renders the pinned message state with an unpin action", () => {
render(<PinnedMessage />);
expect(screen.getByRole("button", { name: /unpin/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^pin$/i })).not.toBeInTheDocument();
});
it("renders the expanded reply chain state with a collapse action", () => {
render(<ExpandedReplyChain />);
expect(screen.getByRole("button", { name: /collapse/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /expand/i })).not.toBeInTheDocument();
});
it("renders a disabled thread reply button when thread reply is not allowed", () => {
render(<DisabledThreadReply />);
const threadButton = screen.getByRole("button", { name: /reply in thread/i });
expect(threadButton).toHaveAttribute("aria-disabled", "true");
});
it("renders thread-list actions in icon mode", () => {
const vm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} />);
expect(screen.getByRole("button", { name: /view in room/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /copy link/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /options/i })).not.toBeInTheDocument();
});
it("renders edit-history actions in label mode", () => {
const vm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.Remove, ActionBarAction.ViewSource],
presentation: "label",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} />);
expect(screen.getByRole("button", { name: /remove/i })).toHaveTextContent(/remove/i);
expect(screen.getByRole("button", { name: /view source/i })).toHaveTextContent(/view source/i);
});
it("renders only the options button in the minimal state", () => {
const vm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.Options],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} />);
expect(screen.getByRole("button", { name: /options/i })).toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(1);
});
it("uses roving tab index and arrow keys within the toolbar", async () => {
const user = userEvent.setup();
const minimalVm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.Options],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
const pinnedVm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.React, ActionBarAction.Reply, ActionBarAction.Pin, ActionBarAction.Options],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: true,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
const { rerender } = render(<ActionBarView vm={minimalVm} />);
const optionsButton = screen.getByRole("button", { name: /options/i });
expect(optionsButton).toHaveAttribute("tabindex", "0");
rerender(<ActionBarView vm={pinnedVm} />);
const reactButton = screen.getByRole("button", { name: /react/i });
const replyButton = screen.getByRole("button", { name: /^reply$/i });
const unpinButton = screen.getByRole("button", { name: /unpin/i });
const optionsButtonInToolbar = screen.getByRole("button", { name: /options/i });
expect(reactButton).toHaveAttribute("tabindex", "0");
expect(replyButton).toHaveAttribute("tabindex", "-1");
expect(unpinButton).toHaveAttribute("tabindex", "-1");
expect(optionsButtonInToolbar).toHaveAttribute("tabindex", "-1");
await user.tab();
expect(reactButton).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(replyButton).toHaveFocus();
expect(replyButton).toHaveAttribute("tabindex", "0");
expect(reactButton).toHaveAttribute("tabindex", "-1");
await user.keyboard("{End}");
expect(optionsButtonInToolbar).toHaveFocus();
expect(optionsButtonInToolbar).toHaveAttribute("tabindex", "0");
await user.keyboard("{ArrowLeft}");
expect(unpinButton).toHaveFocus();
expect(unpinButton).toHaveAttribute("tabindex", "0");
});
it("uses roving tab index and arrow keys within a label toolbar", async () => {
const user = userEvent.setup();
const vm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.Remove, ActionBarAction.ViewSource],
presentation: "label",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} />);
const removeButton = screen.getByRole("button", { name: /remove/i });
const viewSourceButton = screen.getByRole("button", { name: /view source/i });
expect(removeButton).toHaveAttribute("tabindex", "0");
expect(viewSourceButton).toHaveAttribute("tabindex", "-1");
await user.tab();
expect(removeButton).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(viewSourceButton).toHaveFocus();
expect(viewSourceButton).toHaveAttribute("tabindex", "0");
});
it("applies a custom class name to the toolbar", () => {
const vm = new MockViewModel<ActionBarViewSnapshot>({
actions: [ActionBarAction.Options],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} className="extra_class_1 extra_class_2" />);
expect(screen.getByRole("toolbar")).toHaveClass("extra_class_1", "extra_class_2");
});
it("forwards click and context-menu actions with the triggering button as anchor", async () => {
const user = userEvent.setup();
const onReplyClick = vi.fn();
const onOptionsClick = vi.fn();
class ActionBarViewModel extends MockViewModel<ActionBarViewSnapshot> implements ActionBarViewActions {
public onReplyClick = onReplyClick;
public onOptionsClick = onOptionsClick;
}
const vm = new ActionBarViewModel({
actions: [ActionBarAction.Reply, ActionBarAction.Options],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} />);
const replyButton = screen.getByRole("button", { name: /^reply$/i });
const optionsButton = screen.getByRole("button", { name: /options/i });
await user.click(replyButton);
expect(onReplyClick).toHaveBeenCalledWith(replyButton);
fireEvent.contextMenu(optionsButton);
expect(onOptionsClick).toHaveBeenCalledWith(optionsButton);
});
it("forwards label-mode actions with the triggering button as anchor", async () => {
const user = userEvent.setup();
const onRemoveClick = vi.fn();
const onViewSourceClick = vi.fn();
class ActionBarViewModel extends MockViewModel<ActionBarViewSnapshot> implements ActionBarViewActions {
public onRemoveClick = onRemoveClick;
public onViewSourceClick = onViewSourceClick;
}
const vm = new ActionBarViewModel({
actions: [ActionBarAction.Remove, ActionBarAction.ViewSource],
presentation: "label",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
render(<ActionBarView vm={vm} />);
const removeButton = screen.getByRole("button", { name: /remove/i });
const viewSourceButton = screen.getByRole("button", { name: /view source/i });
await user.click(removeButton);
expect(onRemoveClick).toHaveBeenCalledWith(removeButton);
fireEvent.contextMenu(viewSourceButton);
expect(onViewSourceClick).toHaveBeenCalledWith(viewSourceButton);
});
});
@@ -0,0 +1,460 @@
/*
* 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 React, { type JSX, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import classNames from "classnames";
import {
CollapseIcon,
DeleteIcon,
EditIcon,
ExpandIcon,
InlineCodeIcon,
LinkIcon,
PinIcon,
ReplyIcon,
RestartIcon,
UnpinIcon,
ThreadsIcon,
VisibilityOnIcon,
VisibilityOffIcon,
DownloadIcon,
OverflowHorizontalIcon,
ReactionAddIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { InlineSpinner } from "@vector-im/compound-web";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { Flex } from "../../../../../core/utils/Flex";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { ActionBarButton } from "./ActionBarButton";
import styles from "./ActionBarView.module.css";
/**
* Snapshot state for the message action toolbar.
*
* The snapshot carries the resolved actions to render plus the small amount of
* per-action state the view needs for labels, icons, and disabled state.
*/
export interface ActionBarViewSnapshot {
/** Explicitly resolved actions to render, in order. */
actions: ActionBarAction[];
/** Whether actions should render as icon buttons or label buttons. */
presentation?: "icon" | "label";
/** Whether an in-progress download should be presented as decrypting rather than downloading. */
isDownloadEncrypted: boolean;
/** Whether a media download or decryption is currently in progress. */
isDownloadLoading: boolean;
/** Whether the message is currently pinned. */
isPinned: boolean;
/** Whether the reply chain is currently expanded. */
isQuoteExpanded: boolean;
/** Whether starting or replying in a thread is allowed for this event. */
isThreadReplyAllowed: boolean;
}
/**
* Event handlers for toolbar actions.
*
* Each callback receives the triggering button so menus can be positioned from
* the action anchor when needed.
*/
export interface ActionBarViewActions {
onCancelClick?: (anchor: HTMLElement | null) => void;
onCopyLinkClick?: (anchor: HTMLElement | null) => void;
onDownloadClick?: (anchor: HTMLElement | null) => void;
onEditClick?: (anchor: HTMLElement | null) => void;
onHideClick?: (anchor: HTMLElement | null) => void;
onOptionsClick?: (anchor: HTMLElement | null) => void;
onPinClick?: (anchor: HTMLElement | null) => void;
onReactionsClick?: (anchor: HTMLElement | null) => void;
onRemoveClick?: (anchor: HTMLElement | null) => void;
onReplyClick?: (anchor: HTMLElement | null) => void;
onReplyInThreadClick?: (anchor: HTMLElement | null) => void;
onResendClick?: (anchor: HTMLElement | null) => void;
onToggleThreadExpanded?: (anchor: HTMLElement | null) => void;
onViewInRoomClick?: (anchor: HTMLElement | null) => void;
onViewSourceClick?: (anchor: HTMLElement | null) => void;
}
export type ActionBarViewModel = ViewModel<ActionBarViewSnapshot, ActionBarViewActions>;
/**
* Resolved actions that `ActionBarView` can render.
*
* The order of actions in `ActionBarViewSnapshot.actions` defines the visual
* order of buttons in the toolbar.
*/
export enum ActionBarAction {
Cancel = "cancel",
CopyLink = "copyLink",
Download = "download",
Edit = "edit",
Expand = "expand",
Hide = "hide",
Options = "options",
Pin = "pin",
React = "react",
Remove = "remove",
Reply = "reply",
ReplyInThread = "replyInThread",
Resend = "resend",
ViewInRoom = "viewInRoom",
ViewSource = "viewSource",
}
interface ToolbarButtonMeta {
action: ActionBarAction;
disabled?: boolean;
}
interface ActionBarViewProps {
/** The view model for the component. */
vm: ActionBarViewModel;
/** Optional CSS class names to apply to the component container.*/
className?: string;
}
/**
* Compact toolbar for message-level actions such as reply, react, edit,
* download, and overflow options.
*
* Use `className` for host-level container styling, following standard React patterns.
*
* @example
* ```tsx
* <ActionBarView vm={actionBarVm} className="mx_MessageActionBar" />
* ```
*/
export function ActionBarView({ vm, className }: Readonly<ActionBarViewProps>): JSX.Element | null {
const { translate: _t } = useI18n();
const [activeIndex, setActiveIndex] = useState(0);
const {
actions,
presentation = "icon",
isThreadReplyAllowed,
isDownloadEncrypted,
isDownloadLoading,
isPinned,
isQuoteExpanded,
} = useViewModel(vm);
// Track the live button element for each action and keep the callback refs stable
// so React does not detach and reattach them on every render.
const actionButtonRefs = useRef<Partial<Record<ActionBarAction, HTMLButtonElement | null>>>({});
const actionButtonRefSetters = useMemo(
() =>
Object.fromEntries(
Object.values(ActionBarAction).map((action) => [
action,
(element: HTMLButtonElement | null) => {
actionButtonRefs.current[action] = element;
},
]),
) as Record<ActionBarAction, React.RefCallback<HTMLButtonElement>>,
[],
);
const actionButtons: Partial<Record<ActionBarAction, JSX.Element>> = {};
actionButtons[ActionBarAction.Edit] = (
<ActionBarButton
key={ActionBarAction.Edit}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Edit]}
label={_t("action|edit")}
onActivate={vm.onEditClick}
icon={EditIcon}
/>
);
const pinDescription = isPinned ? _t("action|unpin") : _t("action|pin");
actionButtons[ActionBarAction.Pin] = (
<ActionBarButton
key={ActionBarAction.Pin}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Pin]}
label={pinDescription}
onActivate={vm.onPinClick}
icon={isPinned ? UnpinIcon : PinIcon}
ariaPressed={isPinned}
/>
);
actionButtons[ActionBarAction.Cancel] = (
<ActionBarButton
key={ActionBarAction.Cancel}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Cancel]}
label={_t("action|delete")}
onActivate={vm.onCancelClick}
icon={DeleteIcon}
/>
);
actionButtons[ActionBarAction.CopyLink] = (
<ActionBarButton
key={ActionBarAction.CopyLink}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.CopyLink]}
label={_t("timeline|mab|copy_link_thread")}
onActivate={vm.onCopyLinkClick}
icon={LinkIcon}
/>
);
actionButtons[ActionBarAction.Reply] = (
<ActionBarButton
key={ActionBarAction.Reply}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Reply]}
label={_t("action|reply")}
onActivate={vm.onReplyClick}
icon={ReplyIcon}
/>
);
actionButtons[ActionBarAction.React] = (
<ActionBarButton
key={ActionBarAction.React}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.React]}
label={_t("action|react")}
onActivate={vm.onReactionsClick}
icon={ReactionAddIcon}
/>
);
let downloadTitle = _t("action|download");
if (isDownloadLoading) {
downloadTitle = isDownloadEncrypted
? _t("timeline|download_action_decrypting")
: _t("timeline|download_action_downloading");
}
actionButtons[ActionBarAction.Download] = (
<ActionBarButton
key={ActionBarAction.Download}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Download]}
label={downloadTitle}
onActivate={vm.onDownloadClick}
icon={isDownloadLoading ? InlineSpinner : DownloadIcon}
disabled={isDownloadLoading}
/>
);
actionButtons[ActionBarAction.Hide] = (
<ActionBarButton
key={ActionBarAction.Hide}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Hide]}
label={_t("action|hide")}
onActivate={vm.onHideClick}
icon={VisibilityOffIcon}
/>
);
const threadTooltipDescription = isThreadReplyAllowed
? _t("action|reply_in_thread")
: _t("threads|error_start_thread_existing_relation");
actionButtons[ActionBarAction.ReplyInThread] = (
<ActionBarButton
key={ActionBarAction.ReplyInThread}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.ReplyInThread]}
label={_t("action|reply_in_thread")}
tooltipDescription={threadTooltipDescription}
onActivate={vm.onReplyInThreadClick}
icon={ThreadsIcon}
disabled={!isThreadReplyAllowed}
/>
);
actionButtons[ActionBarAction.Resend] = (
<ActionBarButton
key={ActionBarAction.Resend}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Resend]}
label={_t("action|retry")}
onActivate={vm.onResendClick}
icon={RestartIcon}
/>
);
const expandDescription = isQuoteExpanded
? _t("timeline|mab|collapse_reply_chain")
: _t("timeline|mab|expand_reply_chain");
actionButtons[ActionBarAction.Expand] = (
<ActionBarButton
key={ActionBarAction.Expand}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Expand]}
label={expandDescription}
tooltipCaption={`${_t("keyboard|shift")} + ${_t("action|click")}`}
onActivate={vm.onToggleThreadExpanded}
icon={isQuoteExpanded ? CollapseIcon : ExpandIcon}
ariaExpanded={isQuoteExpanded}
/>
);
actionButtons[ActionBarAction.Options] = (
<ActionBarButton
key={ActionBarAction.Options}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Options]}
label={_t("common|options")}
onActivate={vm.onOptionsClick}
icon={OverflowHorizontalIcon}
/>
);
actionButtons[ActionBarAction.Remove] = (
<ActionBarButton
key={ActionBarAction.Remove}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.Remove]}
label={_t("action|remove")}
onActivate={vm.onRemoveClick}
icon={DeleteIcon}
/>
);
actionButtons[ActionBarAction.ViewInRoom] = (
<ActionBarButton
key={ActionBarAction.ViewInRoom}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.ViewInRoom]}
label={_t("timeline|mab|view_in_room")}
onActivate={vm.onViewInRoomClick}
icon={VisibilityOnIcon}
/>
);
actionButtons[ActionBarAction.ViewSource] = (
<ActionBarButton
key={ActionBarAction.ViewSource}
presentation={presentation}
buttonRef={actionButtonRefSetters[ActionBarAction.ViewSource]}
label={_t("action|view_source")}
onActivate={vm.onViewSourceClick}
icon={InlineCodeIcon}
/>
);
const isActionDisabled = useCallback(
(action: ActionBarAction): boolean => {
switch (action) {
case ActionBarAction.Download:
return isDownloadLoading;
case ActionBarAction.ReplyInThread:
return !isThreadReplyAllowed;
default:
return false;
}
},
[isDownloadLoading, isThreadReplyAllowed],
);
const toolbarButtons = useMemo<ToolbarButtonMeta[]>(() => {
return actions.map((action) => ({
action,
disabled: isActionDisabled(action),
}));
}, [actions, isActionDisabled]);
// Handle RovingIndex for toolbar
const enabledIndices = toolbarButtons
.map((item, index) => (item.disabled ? -1 : index))
.filter((index) => index >= 0);
const fallbackIndex = enabledIndices[0] ?? 0;
const currentIndex =
toolbarButtons[activeIndex] && !toolbarButtons[activeIndex].disabled ? activeIndex : fallbackIndex;
useLayoutEffect(() => {
setActiveIndex(currentIndex);
toolbarButtons.forEach(({ action }, index) => {
const button = actionButtonRefs.current[action] ?? null;
if (button) {
button.tabIndex = index === currentIndex ? 0 : -1;
}
});
}, [currentIndex, toolbarButtons]);
const focusButtonAtIndex = (index: number): void => {
const action = toolbarButtons[index]?.action;
const button = action ? (actionButtonRefs.current[action] ?? null) : null;
if (!button) {
return;
}
setActiveIndex(index);
button.focus();
};
const handleToolbarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (enabledIndices.length === 0) {
return;
}
const focusedIndex = toolbarButtons.findIndex(
({ action }) => actionButtonRefs.current[action] === document.activeElement,
);
const startIndex = focusedIndex >= 0 ? focusedIndex : currentIndex;
switch (event.key) {
case "ArrowRight": {
event.preventDefault();
const nextIndex = enabledIndices.find((index) => index > startIndex) ?? enabledIndices[0];
focusButtonAtIndex(nextIndex);
break;
}
case "ArrowLeft": {
event.preventDefault();
const previousIndex = [...enabledIndices].reverse().find((index) => index < startIndex);
focusButtonAtIndex(previousIndex ?? enabledIndices[enabledIndices.length - 1]);
break;
}
case "Home":
event.preventDefault();
focusButtonAtIndex(enabledIndices[0]);
break;
case "End":
event.preventDefault();
focusButtonAtIndex(enabledIndices[enabledIndices.length - 1]);
break;
}
};
const handleToolbarFocusCapture = (): void => {
const focusedIndex = toolbarButtons.findIndex(
({ action }) => actionButtonRefs.current[action] === document.activeElement,
);
if (focusedIndex >= 0 && focusedIndex !== activeIndex) {
setActiveIndex(focusedIndex);
}
};
if (toolbarButtons.length === 0) {
return null;
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return (
<Flex
display="inline-flex"
direction="row"
role="toolbar"
aria-label={_t("timeline|mab|label")}
aria-live="off"
onKeyDown={handleToolbarKeyDown}
onFocusCapture={handleToolbarFocusCapture}
className={classNames(className, styles.toolbar)}
>
{toolbarButtons.map((meta) => actionButtons[meta.action])}
</Flex>
);
}
@@ -0,0 +1,962 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ActionBarView > story snapshots > renders AllIconActions 1`] = `
<div>
<div
aria-label="Message Actions"
aria-live="off"
class="flex mx_MessageActionBar toolbar"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<button
aria-label="Hide"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m16.1 13.3-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.424-.2.863-.3A4.2 4.2 0 0 1 12 7q1.875 0 3.188 1.312Q16.5 9.625 16.5 11.5q0 .5-.1.938t-.3.862m3.2 3.15-1.45-1.4a11 11 0 0 0 1.688-1.588A9 9 0 0 0 20.8 11.5q-1.25-2.524-3.588-4.013Q14.875 6 12 6q-.724 0-1.425.1a10 10 0 0 0-1.375.3L7.65 4.85A11.1 11.1 0 0 1 12 4q3.575 0 6.425 1.887T22.7 10.8a.8.8 0 0 1 .1.313q.025.188.025.387a2 2 0 0 1-.125.7 10.9 10.9 0 0 1-3.4 4.25m-.2 5.45-3.5-3.45q-.874.274-1.762.413Q12.95 19 12 19q-3.575 0-6.425-1.887T1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.763.8.8 0 0 1 .1-.3Q1.825 9.7 2.55 8.75A13.3 13.3 0 0 1 4.15 7L2.075 4.9a.93.93 0 0 1-.275-.688q0-.412.3-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275l17 17q.275.275.288.688a.93.93 0 0 1-.288.712.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275M5.55 8.4q-.725.65-1.325 1.425A9 9 0 0 0 3.2 11.5q1.25 2.524 3.588 4.012T12 17q.5 0 .975-.062.475-.063.975-.138l-.9-.95q-.274.075-.525.113A3.5 3.5 0 0 1 12 16q-1.875 0-3.187-1.312Q7.5 13.375 7.5 11.5q0-.274.038-.525.037-.25.112-.525z"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-label="Download"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
</button>
<button
aria-label="React"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.74 2.38C13.87 2.133 12.95 2 12 2 6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10c0-.95-.133-1.87-.38-2.74a5 5 0 0 1-1.886.687 8 8 0 1 1-5.68-5.68c.1-.684.339-1.323.687-1.887"
/>
<path
d="M15.536 14.121a1 1 0 0 1 0 1.415A5 5 0 0 1 12 17c-1.38 0-2.632-.56-3.535-1.464a1 1 0 1 1 1.414-1.415A3 3 0 0 0 12 15c.829 0 1.577-.335 2.121-.879a1 1 0 0 1 1.415 0M8.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m8.5-1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M18 6h-1a.97.97 0 0 1-.712-.287A.97.97 0 0 1 16 5q0-.424.288-.713A.97.97 0 0 1 17 4h1V3q0-.424.288-.712A.97.97 0 0 1 19 2q.424 0 .712.288Q20 2.575 20 3v1h1q.424 0 .712.287Q22 4.576 22 5t-.288.713A.97.97 0 0 1 21 6h-1v1q0 .424-.288.713A.97.97 0 0 1 19 8a.97.97 0 0 1-.712-.287A.97.97 0 0 1 18 7z"
/>
</svg>
</button>
<button
aria-label="Reply"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.405 5.708c.39-.39.39-1.025 0-1.416a.996.996 0 0 0-1.412 0L3.294 9.006a1.004 1.004 0 0 0 0 1.416l4.699 4.714a.996.996 0 0 0 1.412 0c.39-.39.39-1.025 0-1.416l-3.043-3.053h9.153c1.887 0 3.485 1.604 3.485 3.666C19 16.396 17.402 18 15.515 18h-2.093a1 1 0 1 0 0 2h2.093C18.58 20 21 17.425 21 14.333s-2.419-5.666-5.485-5.666H6.456z"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-label="Reply in thread"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 10a.97.97 0 0 1-.713-.287A.97.97 0 0 1 6 9q0-.424.287-.713A.97.97 0 0 1 7 8h10q.424 0 .712.287Q18 8.576 18 9t-.288.713A.97.97 0 0 1 17 10zm0 4a.97.97 0 0 1-.713-.287A.97.97 0 0 1 6 13q0-.424.287-.713A.97.97 0 0 1 7 12h6q.424 0 .713.287.287.288.287.713 0 .424-.287.713A.97.97 0 0 1 13 14z"
/>
<path
d="M3.707 21.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6zM6 17h14V5H4v13.172l.586-.586A2 2 0 0 1 6 17"
/>
</svg>
</button>
<button
aria-label="Edit"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</button>
<button
aria-label="Pin"
aria-pressed="false"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M6.119 2a.5.5 0 0 0-.35.857L7.85 4.9a.5.5 0 0 1 .15.357v4.487a.5.5 0 0 1-.15.356l-3.7 3.644A.5.5 0 0 0 4 14.1v1.4a.5.5 0 0 0 .5.5H11v6a1 1 0 1 0 2 0v-6h6.5a.5.5 0 0 0 .5-.5v-1.4a.5.5 0 0 0-.15-.356l-3.7-3.644a.5.5 0 0 1-.15-.356V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857zM10 4h4v5.744a2.5 2.5 0 0 0 .746 1.781L17.26 14H6.74l2.514-2.475A2.5 2.5 0 0 0 10 9.744z"
fill-rule="evenodd"
/>
</svg>
</button>
<button
aria-label="Retry"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.93 8A8 8 0 1 1 4 12a1 1 0 1 0-2 0c0 5.523 4.477 10 10 10s10-4.477 10-10a10 10 0 0 0-.832-4A10 10 0 0 0 12 2a9.99 9.99 0 0 0-8 3.999V4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2H5.755A7.99 7.99 0 0 1 12 4a8 8 0 0 1 6.93 4"
/>
</svg>
</button>
<button
aria-label="Delete"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
</button>
<button
aria-expanded="false"
aria-label="Expand quotes"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 3.997a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 20 3h-8a1 1 0 1 0 0 2h5.586L5 17.586V12a1 1 0 1 0-2 0v8.003a1 1 0 0 0 .29.702l.005.004c.18.18.43.291.705.291h8a1 1 0 1 0 0-2H6.414L19 6.414V12a1 1 0 1 0 2 0z"
/>
</svg>
</button>
<button
aria-label="Options"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
<button
aria-label="View in room"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 16q1.875 0 3.188-1.312Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7 8.813 8.313 7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.787A2.6 2.6 0 0 1 9.3 11.5q0-1.125.787-1.912A2.6 2.6 0 0 1 12 8.8q1.125 0 1.912.787.788.788.788 1.913t-.787 1.912A2.6 2.6 0 0 1 12 14.2m0 4.8q-3.475 0-6.35-1.837Q2.775 15.324 1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.775.8.8 0 0 1 .1-.313q1.475-3.125 4.35-4.962Q8.525 4 12 4t6.35 1.838T22.7 10.8a.8.8 0 0 1 .1.313 3 3 0 0 1 0 .774.8.8 0 0 1-.1.313q-1.475 3.125-4.35 4.963Q15.475 19 12 19m0-2a9.54 9.54 0 0 0 5.188-1.488A9.77 9.77 0 0 0 20.8 11.5a9.77 9.77 0 0 0-3.613-4.012A9.54 9.54 0 0 0 12 6a9.55 9.55 0 0 0-5.187 1.487A9.77 9.77 0 0 0 3.2 11.5a9.77 9.77 0 0 0 3.613 4.012A9.54 9.54 0 0 0 12 17"
/>
</svg>
</button>
<button
aria-label="Copy link to thread"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 19.071q-1.467 1.467-3.536 1.467-2.067 0-3.535-1.467t-1.467-3.535q0-2.07 1.467-3.536L7.05 9.879q.3-.3.707-.3t.707.3.301.707-.3.707l-2.122 2.121a2.9 2.9 0 0 0-.884 2.122q0 1.237.884 2.12.884.885 2.121.885t2.122-.884l2.121-2.121q.3-.3.707-.3t.707.3.3.707q0 .405-.3.707zm-1.414-4.243q-.3.3-.707.301a.97.97 0 0 1-.707-.3q-.3-.3-.301-.708 0-.405.3-.707l4.243-4.242q.3-.3.707-.3t.707.3.3.707-.3.707zm6.364-.707q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.301-.707 0-.405.3-.707l2.122-2.121q.884-.885.884-2.121 0-1.238-.884-2.122a2.9 2.9 0 0 0-2.121-.884q-1.237 0-2.122.884l-2.121 2.122q-.3.3-.707.3a.97.97 0 0 1-.707-.3q-.3-.3-.3-.708 0-.405.3-.707L12 4.93q1.467-1.467 3.536-1.467t3.535 1.467 1.467 3.536T19.071 12z"
/>
</svg>
</button>
<button
aria-label="Remove"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
</button>
<button
aria-label="View Source"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.958 5.62a1 1 0 0 0-1.916-.574l-4 13.333a1 1 0 0 0 1.916.575zM5.974 7.232a1 1 0 0 0-1.409.128l-3.333 4a1 1 0 0 0 0 1.28l3.333 4a1 1 0 1 0 1.537-1.28L3.302 12l2.8-3.36a1 1 0 0 0-.128-1.408m12.053 0a1 1 0 0 1 1.408.128l3.333 4a1 1 0 0 1 0 1.28l-3.333 4a1 1 0 1 1-1.537-1.28l2.8-3.36-2.8-3.36a1 1 0 0 1 .128-1.408"
/>
</svg>
</button>
</div>
</div>
`;
exports[`ActionBarView > story snapshots > renders AllLabelActions 1`] = `
<div>
<div
aria-label="Message Actions"
aria-live="off"
class="flex mx_MessageActionBar toolbar"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<button
aria-label="Hide"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Hide
</button>
<button
aria-disabled="false"
aria-label="Download"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Download
</button>
<button
aria-label="React"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
React
</button>
<button
aria-label="Reply"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Reply
</button>
<button
aria-disabled="false"
aria-label="Reply in thread"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Reply in thread
</button>
<button
aria-label="Edit"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Edit
</button>
<button
aria-label="Pin"
aria-pressed="false"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Pin
</button>
<button
aria-label="Retry"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Retry
</button>
<button
aria-label="Delete"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Delete
</button>
<button
aria-expanded="false"
aria-label="Expand quotes"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Expand quotes
</button>
<button
aria-label="Options"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Options
</button>
<button
aria-label="View in room"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
View in room
</button>
<button
aria-label="Copy link to thread"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Copy link to thread
</button>
<button
aria-label="Remove"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
Remove
</button>
<button
aria-label="View Source"
class="_button_13vu4_8 toolbar_item"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="-1"
>
View Source
</button>
</div>
</div>
`;
exports[`ActionBarView > story snapshots > renders DecryptingAttachment 1`] = `
<div>
<div
aria-label="Message Actions"
aria-live="off"
class="flex mx_MessageActionBar toolbar"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="true"
aria-label="Decrypting"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
class="_icon_11k6c_18"
fill="currentColor"
height="20"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</button>
<button
aria-label="Options"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
</div>
</div>
`;
exports[`ActionBarView > story snapshots > renders DisabledThreadReply 1`] = `
<div>
<div
aria-label="Message Actions"
aria-live="off"
class="flex mx_MessageActionBar toolbar"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<button
aria-label="React"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.74 2.38C13.87 2.133 12.95 2 12 2 6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10c0-.95-.133-1.87-.38-2.74a5 5 0 0 1-1.886.687 8 8 0 1 1-5.68-5.68c.1-.684.339-1.323.687-1.887"
/>
<path
d="M15.536 14.121a1 1 0 0 1 0 1.415A5 5 0 0 1 12 17c-1.38 0-2.632-.56-3.535-1.464a1 1 0 1 1 1.414-1.415A3 3 0 0 0 12 15c.829 0 1.577-.335 2.121-.879a1 1 0 0 1 1.415 0M8.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m8.5-1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M18 6h-1a.97.97 0 0 1-.712-.287A.97.97 0 0 1 16 5q0-.424.288-.713A.97.97 0 0 1 17 4h1V3q0-.424.288-.712A.97.97 0 0 1 19 2q.424 0 .712.288Q20 2.575 20 3v1h1q.424 0 .712.287Q22 4.576 22 5t-.288.713A.97.97 0 0 1 21 6h-1v1q0 .424-.288.713A.97.97 0 0 1 19 8a.97.97 0 0 1-.712-.287A.97.97 0 0 1 18 7z"
/>
</svg>
</button>
<button
aria-label="Reply"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.405 5.708c.39-.39.39-1.025 0-1.416a.996.996 0 0 0-1.412 0L3.294 9.006a1.004 1.004 0 0 0 0 1.416l4.699 4.714a.996.996 0 0 0 1.412 0c.39-.39.39-1.025 0-1.416l-3.043-3.053h9.153c1.887 0 3.485 1.604 3.485 3.666C19 16.396 17.402 18 15.515 18h-2.093a1 1 0 1 0 0 2h2.093C18.58 20 21 17.425 21 14.333s-2.419-5.666-5.485-5.666H6.456z"
/>
</svg>
</button>
<button
aria-disabled="true"
aria-label="Reply in thread"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 10a.97.97 0 0 1-.713-.287A.97.97 0 0 1 6 9q0-.424.287-.713A.97.97 0 0 1 7 8h10q.424 0 .712.287Q18 8.576 18 9t-.288.713A.97.97 0 0 1 17 10zm0 4a.97.97 0 0 1-.713-.287A.97.97 0 0 1 6 13q0-.424.287-.713A.97.97 0 0 1 7 12h6q.424 0 .713.287.287.288.287.713 0 .424-.287.713A.97.97 0 0 1 13 14z"
/>
<path
d="M3.707 21.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6zM6 17h14V5H4v13.172l.586-.586A2 2 0 0 1 6 17"
/>
</svg>
</button>
<button
aria-label="Options"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
</div>
</div>
`;
exports[`ActionBarView > story snapshots > renders DownloadingAttachment 1`] = `
<div>
<div
aria-label="Message Actions"
aria-live="off"
class="flex mx_MessageActionBar toolbar"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="true"
aria-label="Downloading"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
class="_icon_11k6c_18"
fill="currentColor"
height="20"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</button>
<button
aria-label="Options"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
</div>
</div>
`;
exports[`ActionBarView > story snapshots > renders ExpandedReplyChain 1`] = `
<div>
<div
aria-label="Message Actions"
aria-live="off"
class="flex mx_MessageActionBar toolbar"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<button
aria-label="Reply"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.405 5.708c.39-.39.39-1.025 0-1.416a.996.996 0 0 0-1.412 0L3.294 9.006a1.004 1.004 0 0 0 0 1.416l4.699 4.714a.996.996 0 0 0 1.412 0c.39-.39.39-1.025 0-1.416l-3.043-3.053h9.153c1.887 0 3.485 1.604 3.485 3.666C19 16.396 17.402 18 15.515 18h-2.093a1 1 0 1 0 0 2h2.093C18.58 20 21 17.425 21 14.333s-2.419-5.666-5.485-5.666H6.456z"
/>
</svg>
</button>
<button
aria-expanded="true"
aria-label="Collapse quotes"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</button>
<button
aria-label="Options"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
</div>
</div>
`;
exports[`ActionBarView > story snapshots > renders PinnedMessage 1`] = `
<div>
<div
aria-label="Message Actions"
aria-live="off"
class="flex mx_MessageActionBar toolbar"
role="toolbar"
style="--mx-flex-display: inline-flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<button
aria-label="React"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.74 2.38C13.87 2.133 12.95 2 12 2 6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10c0-.95-.133-1.87-.38-2.74a5 5 0 0 1-1.886.687 8 8 0 1 1-5.68-5.68c.1-.684.339-1.323.687-1.887"
/>
<path
d="M15.536 14.121a1 1 0 0 1 0 1.415A5 5 0 0 1 12 17c-1.38 0-2.632-.56-3.535-1.464a1 1 0 1 1 1.414-1.415A3 3 0 0 0 12 15c.829 0 1.577-.335 2.121-.879a1 1 0 0 1 1.415 0M8.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m8.5-1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M18 6h-1a.97.97 0 0 1-.712-.287A.97.97 0 0 1 16 5q0-.424.288-.713A.97.97 0 0 1 17 4h1V3q0-.424.288-.712A.97.97 0 0 1 19 2q.424 0 .712.288Q20 2.575 20 3v1h1q.424 0 .712.287Q22 4.576 22 5t-.288.713A.97.97 0 0 1 21 6h-1v1q0 .424-.288.713A.97.97 0 0 1 19 8a.97.97 0 0 1-.712-.287A.97.97 0 0 1 18 7z"
/>
</svg>
</button>
<button
aria-label="Reply"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.405 5.708c.39-.39.39-1.025 0-1.416a.996.996 0 0 0-1.412 0L3.294 9.006a1.004 1.004 0 0 0 0 1.416l4.699 4.714a.996.996 0 0 0 1.412 0c.39-.39.39-1.025 0-1.416l-3.043-3.053h9.153c1.887 0 3.485 1.604 3.485 3.666C19 16.396 17.402 18 15.515 18h-2.093a1 1 0 1 0 0 2h2.093C18.58 20 21 17.425 21 14.333s-2.419-5.666-5.485-5.666H6.456z"
/>
</svg>
</button>
<button
aria-label="Unpin"
aria-pressed="true"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M5.457 2.083a1 1 0 0 0-1.414 1.414L8.04 7.494v2.25a.5.5 0 0 1-.15.356l-3.7 3.644a.5.5 0 0 0-.15.356v1.4a.5.5 0 0 0 .5.5h6.5v6a1 1 0 0 0 2 0v-6h3.506l4.497 4.497a1 1 0 0 0 1.414-1.414zM14.546 14 10.04 9.494v.25a2.5 2.5 0 0 1-.746 1.781L6.78 14z"
fill-rule="evenodd"
/>
<path
d="M14.04 4v3.85l2.015 2.015a.5.5 0 0 1-.015-.12V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857h-9.73l2 2z"
/>
</svg>
</button>
<button
aria-label="Options"
class="_button_13vu4_8 toolbar_item _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="tertiary"
data-presentation="icon"
data-size="sm"
role="button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
</div>
</div>
`;
@@ -0,0 +1,14 @@
/*
* 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.
*/
export {
ActionBarView,
type ActionBarViewActions,
type ActionBarViewModel,
type ActionBarViewSnapshot,
ActionBarAction,
} from "./ActionBarView";