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
@@ -86,15 +86,23 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
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;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@@ -224,15 +232,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
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;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@@ -278,15 +294,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
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;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@@ -314,15 +338,23 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</span>
</div>
<div
class="mx_MessageActionBar"
aria-label="Message Actions"
aria-live="off"
class="_flex_4dswl_9 mx_ThreadActionBar mx_HistoryActionBar _toolbar_1ax4y_8"
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;"
>
<div
class="mx_AccessibleButton"
<button
aria-label="Remove"
class="_button_13vu4_8 _toolbar_item_1ax4y_14"
data-kind="tertiary"
data-presentation="label"
data-size="sm"
role="button"
tabindex="0"
>
Remove
</div>
</button>
</div>
</div>
</div>
@@ -332,7 +364,7 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
</div>
</div>
<div
aria-describedby="_r_8_"
aria-describedby="_r_c_"
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
@@ -1,155 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
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 { mocked } from "jest-mock";
import fetchMock from "@fetch-mock/jest";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { clearAllModals, stubClient } from "../../../../test-utils";
import DownloadActionButton from "../../../../../src/components/views/messages/DownloadActionButton";
import Modal from "../../../../../src/Modal";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn().mockResolvedValue(new Blob(["TESTFILE"], { type: "application/octet-stream" })),
}));
describe("DownloadActionButton", () => {
const plainEvent = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
beforeEach(() => {
jest.restoreAllMocks();
});
afterEach(() => {
clearAllModals();
});
it("should show error if media API returns one", async () => {
const cli = stubClient();
// eslint-disable-next-line no-restricted-properties
mocked(cli.mxcUrlToHttp).mockImplementation(
(mxc) => `https://matrix.org/_matrix/media/r0/download/${mxc.slice(6)}`,
);
fetchMock.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Not found" },
});
const mediaEventHelper = new MediaEventHelper(plainEvent);
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const spy = jest.spyOn(Modal, "createDialog");
fireEvent.click(screen.getByRole("button"));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({
title: "Download failed",
}),
),
);
});
it("should show download tooltip on hover", async () => {
stubClient();
const user = userEvent.setup();
fetchMock.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", "TESTFILE");
const event = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
render(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => undefined} />);
const button = screen.getByRole("button");
await user.hover(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Download");
});
});
it("should show downloading tooltip while unencrypted files are downloading", async () => {
const user = userEvent.setup();
stubClient();
fetchMock.getOnce("http://this.is.a.url/matrix.org/1234", "TESTFILE");
const mediaEventHelper = new MediaEventHelper(plainEvent);
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const button = screen.getByRole("button");
await user.hover(button);
await user.click(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Downloading");
});
});
it("should show decrypting tooltip while encrypted files are downloading", async () => {
const user = userEvent.setup();
stubClient();
fetchMock.getOnce("http://this.is.a.url/matrix.org/1234", "UFTUGJMF");
const e2eEvent = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
file: { url: "mxc://matrix.org/1234" },
},
});
const mediaEventHelper = new MediaEventHelper(e2eEvent);
render(<DownloadActionButton mxEvent={e2eEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const button = screen.getByRole("button");
await user.hover(button);
await user.click(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting");
});
});
});
@@ -1,85 +0,0 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
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 { fireEvent, render, screen } from "jest-matrix-react";
import { MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { HideActionButton } from "../../../../../src/components/views/messages/HideActionButton";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import type { Settings } from "../../../../../src/settings/Settings";
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
import { getMockClientWithEventEmitter, withClientContextRenderOptions } from "../../../../test-utils";
import type { MockedObject } from "jest-mock";
function mockSetting(mediaPreviews: MediaPreviewValue, showMediaEventIds: Settings["showMediaEventIds"]["default"]) {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "mediaPreviewConfig") {
return { media_previews: mediaPreviews, invite_avatars: MediaPreviewValue.Off };
} else if (settingName === "showMediaEventIds") {
return showMediaEventIds;
}
throw Error(`Unexpected setting ${settingName}`);
});
}
const EVENT_ID = "$foo:bar";
const event = new MatrixEvent({
event_id: EVENT_ID,
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
describe("HideActionButton", () => {
let cli: MockedObject<MatrixClient>;
beforeEach(() => {
cli = getMockClientWithEventEmitter({
getRoom: jest.fn(),
getUserId: jest.fn(),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should show button when event is visible by showMediaEventIds setting", async () => {
mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: true });
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.getByRole("button")).toBeVisible();
});
it("should show button when event is visible by mediaPreviewConfig setting", async () => {
mockSetting(MediaPreviewValue.On, {});
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.getByRole("button")).toBeVisible();
});
it("should hide button when event is hidden by showMediaEventIds setting", async () => {
mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: false });
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.queryByRole("button")).toBeNull();
});
it("should hide button when event is hidden by showImages setting", async () => {
mockSetting(MediaPreviewValue.Off, {});
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.queryByRole("button")).toBeNull();
});
it("should store event as hidden when clicked", async () => {
const spy = jest.spyOn(SettingsStore, "setValue");
render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
fireEvent.click(screen.getByRole("button"));
expect(spy).toHaveBeenCalledWith("showMediaEventIds", null, SettingLevel.DEVICE, { "$foo:bar": false });
// Button should be hidden after the setting is set.
expect(screen.queryByRole("button")).toBeNull();
});
});
@@ -1,564 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
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 { act, render, fireEvent, screen, waitFor } from "jest-matrix-react";
import {
EventType,
EventStatus,
MatrixEvent,
MatrixEventEvent,
MsgType,
Room,
FeatureSupport,
Thread,
EventTimeline,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import MessageActionBar from "../../../../../src/components/views/messages/MessageActionBar";
import {
getMockClientWithEventEmitter,
mockClientMethodsUser,
mockClientMethodsEvents,
makeBeaconInfoEvent,
} from "../../../../test-utils";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import RoomContext, { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { Action } from "../../../../../src/dispatcher/actions";
import PinningUtils from "../../../../../src/utils/PinningUtils";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
jest.mock("../../../../../src/dispatcher/dispatcher");
describe("<MessageActionBar />", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getRoom: jest.fn(),
setRoomAccountData: jest.fn(),
sendStateEvent: jest.fn(),
});
const room = new Room(roomId, client, userId);
const alicesMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
event_id: "$alices_message",
});
const bobsMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: "@bob:server.org",
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "I am bob",
},
event_id: "$bobs_message",
});
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
redactedEvent.makeRedacted(redactedEvent, room);
const localStorageMock = (() => {
let store: Record<string, any> = {};
return {
getItem: jest.fn().mockImplementation((key) => store[key] ?? null),
setItem: jest.fn().mockImplementation((key, value) => {
store[key] = value;
}),
clear: jest.fn().mockImplementation(() => {
store = {};
}),
removeItem: jest.fn().mockImplementation((key) => delete store[key]),
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
client.getRoom.mockReturnValue(room);
const defaultProps = {
getTile: jest.fn(),
getReplyChain: jest.fn(),
toggleThreadExpanded: jest.fn(),
mxEvent: alicesMessageEvent,
permalinkCreator: new RoomPermalinkCreator(room),
};
const defaultRoomContext = {
...RoomContext,
timelineRenderingType: TimelineRenderingType.Room,
canSendMessages: true,
canReact: true,
room,
} as unknown as RoomContextType;
const getComponent = (props = {}, roomContext: Partial<RoomContextType> = {}) =>
render(
<ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}>
<MessageActionBar {...defaultProps} {...props} />
</ScopedRoomContextProvider>,
);
beforeEach(() => {
jest.clearAllMocks();
// The base case is that we have received the remote echo and have an eventId. No sending status.
alicesMessageEvent.setStatus(null);
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
afterAll(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
jest.spyOn(SettingsStore, "setValue").mockRestore();
});
it("kills event listeners on unmount", () => {
const offSpy = jest.spyOn(alicesMessageEvent, "off").mockClear();
const wrapper = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
wrapper.unmount();
});
expect(offSpy.mock.calls[0][0]).toEqual(MatrixEventEvent.Status);
expect(offSpy.mock.calls[1][0]).toEqual(MatrixEventEvent.Decrypted);
expect(offSpy.mock.calls[2][0]).toEqual(MatrixEventEvent.BeforeRedaction);
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
});
describe("decryption", () => {
it("decrypts event if needed", () => {
getComponent({ mxEvent: alicesMessageEvent });
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
});
it("updates component on decrypted event", () => {
const decryptingEvent = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
sender: userId,
room_id: roomId,
content: {},
});
jest.spyOn(decryptingEvent, "isBeingDecrypted").mockReturnValue(true);
const { queryByLabelText } = getComponent({ mxEvent: decryptingEvent });
// still encrypted event is not actionable => no reply button
expect(queryByLabelText("Reply")).toBeFalsy();
act(() => {
// ''decrypt'' the event
decryptingEvent.event.type = alicesMessageEvent.getType();
decryptingEvent.event.content = alicesMessageEvent.getContent();
decryptingEvent.emit(MatrixEventEvent.Decrypted, decryptingEvent);
});
// new available actions after decryption
expect(queryByLabelText("Reply")).toBeTruthy();
});
});
describe("status", () => {
it("updates component when event status changes", () => {
alicesMessageEvent.setStatus(EventStatus.QUEUED);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
// pending event status, cancel action available
expect(queryByLabelText("Delete")).toBeTruthy();
act(() => {
alicesMessageEvent.setStatus(EventStatus.SENT);
});
// event is sent, no longer cancelable
expect(queryByLabelText("Delete")).toBeFalsy();
});
});
describe("redaction", () => {
// this doesn't do what it's supposed to
// because beforeRedaction event is fired... before redaction
// event is unchanged at point when this component updates
// TODO file bug
it.skip("updates component on before redaction event", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
const { queryByLabelText } = getComponent({ mxEvent: event });
// no pending redaction => no delete button
expect(queryByLabelText("Delete")).toBeFalsy();
act(() => {
const redactionEvent = new MatrixEvent({
type: EventType.RoomRedaction,
sender: userId,
room_id: roomId,
});
redactionEvent.setStatus(EventStatus.QUEUED);
event.markLocallyRedacted(redactionEvent);
});
// updated with local redaction event, delete now available
expect(queryByLabelText("Delete")).toBeTruthy();
});
});
describe("options button", () => {
it("renders options menu", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Options")).toBeTruthy();
});
it("opens message context menu on click", () => {
const { getByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(queryByLabelText("Options")!);
expect(getByTestId("mx_MessageContextMenu")).toBeTruthy();
});
});
describe("reply button", () => {
it("renders reply button on own actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Reply")).toBeTruthy();
});
it("renders reply button on others actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }, { canSendMessages: true });
expect(queryByLabelText("Reply")).toBeTruthy();
});
it("does not render reply button on non-actionable event", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
expect(queryByLabelText("Reply")).toBeFalsy();
});
it("does not render reply button when user cannot send messaged", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canSendMessages: false });
expect(queryByLabelText("Reply")).toBeFalsy();
});
it("dispatches reply event on click", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(queryByLabelText("Reply")!);
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: "reply_to_event",
event: alicesMessageEvent,
context: TimelineRenderingType.Room,
});
});
});
describe("react button", () => {
it("renders react button on own actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("React")).toBeTruthy();
});
it("renders react button on others actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent });
expect(queryByLabelText("React")).toBeTruthy();
});
it("does not render react button on non-actionable event", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
expect(queryByLabelText("React")).toBeFalsy();
});
it("does not render react button when user cannot react", () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canReact: false });
expect(queryByLabelText("React")).toBeFalsy();
});
it("opens reaction picker on click", () => {
const { queryByLabelText, getByTestId } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(queryByLabelText("React")!);
expect(getByTestId("mx_EmojiPicker")).toBeTruthy();
});
});
describe("cancel button", () => {
it("renders cancel button for an event with a cancelable status", () => {
alicesMessageEvent.setStatus(EventStatus.QUEUED);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Delete")).toBeTruthy();
});
it("renders cancel button for an event with a pending edit", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
event.setStatus(EventStatus.SENT);
const replacingEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "replacing event body",
},
});
replacingEvent.setStatus(EventStatus.QUEUED);
event.makeReplaced(replacingEvent);
const { queryByLabelText } = getComponent({ mxEvent: event });
expect(queryByLabelText("Delete")).toBeTruthy();
});
it("renders cancel button for an event with a pending redaction", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
event.setStatus(EventStatus.SENT);
const redactionEvent = new MatrixEvent({
type: EventType.RoomRedaction,
sender: userId,
room_id: roomId,
});
redactionEvent.setStatus(EventStatus.QUEUED);
event.markLocallyRedacted(redactionEvent);
const { queryByLabelText } = getComponent({ mxEvent: event });
expect(queryByLabelText("Delete")).toBeTruthy();
});
it("renders cancel and retry button for an event with NOT_SENT status", () => {
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Retry")).toBeTruthy();
expect(queryByLabelText("Delete")).toBeTruthy();
});
it("only shows retry and delete buttons when event could not be sent", () => {
// Enable pin and other features
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
// Should show retry and cancel buttons
expect(queryByLabelText("Retry")).toBeTruthy();
expect(queryByLabelText("Delete")).toBeTruthy();
// Should NOT show edit, pin, react, reply buttons
expect(queryByLabelText("Edit")).toBeFalsy();
expect(queryByLabelText("Pin")).toBeFalsy();
expect(queryByLabelText("React")).toBeFalsy();
expect(queryByLabelText("Reply")).toBeFalsy();
expect(queryByLabelText("Reply in thread")).toBeFalsy();
});
it.todo("unsends event on cancel click");
it.todo("retrys event on retry click");
});
describe("thread button", () => {
beforeEach(() => {
Thread.setServerSideSupport(FeatureSupport.Stable);
});
describe("when threads feature is enabled", () => {
it("renders thread button on own actionable event", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Reply in thread")).toBeTruthy();
});
it("does not render thread button for a beacon_info event", () => {
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
const { queryByLabelText } = getComponent({ mxEvent: beaconInfoEvent });
expect(queryByLabelText("Reply in thread")).toBeFalsy();
});
it("opens thread on click", () => {
const { getByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.click(getByLabelText("Reply in thread"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ShowThread,
rootEvent: alicesMessageEvent,
push: false,
});
});
it("opens parent thread for a thread reply message", () => {
const threadReplyEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: "this is a thread reply",
},
});
// mock the thread stuff
jest.spyOn(threadReplyEvent, "isThreadRoot", "get").mockReturnValue(false);
// set alicesMessageEvent as the root event
jest.spyOn(threadReplyEvent, "getThread").mockReturnValue({
rootEvent: alicesMessageEvent,
} as unknown as Thread);
const { getByLabelText } = getComponent({ mxEvent: threadReplyEvent });
fireEvent.click(getByLabelText("Reply in thread"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ShowThread,
rootEvent: alicesMessageEvent,
initialEvent: threadReplyEvent,
highlighted: true,
scroll_into_view: true,
push: false,
});
});
});
});
it.each([["React"], ["Reply"], ["Reply in thread"], ["Edit"], ["Pin"]])(
"does not show context menu when right-clicking",
(buttonLabel: string) => {
// For favourite and pin buttons
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
});
event.stopPropagation = jest.fn();
event.preventDefault = jest.fn();
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent(queryByLabelText(buttonLabel)!, event);
expect(event.stopPropagation).toHaveBeenCalled();
expect(event.preventDefault).toHaveBeenCalled();
expect(queryByTestId("mx_MessageContextMenu")).toBeFalsy();
},
);
it("does shows context menu when right-clicking options", () => {
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
fireEvent.contextMenu(queryByLabelText("Options")!);
expect(queryByTestId("mx_MessageContextMenu")).toBeTruthy();
});
describe("pin button", () => {
beforeEach(() => {
// enable pin button
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false);
});
afterEach(() => {
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockRestore();
});
it("should not render pin button when user can't send state event", () => {
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(false);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Pin")).toBeFalsy();
});
it("should render pin button", () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText("Pin")).toBeTruthy();
});
it("should listen to room pinned events", async () => {
getComponent({ mxEvent: alicesMessageEvent });
expect(screen.getByLabelText("Pin")).toBeInTheDocument();
// Event is considered pinned
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
// Emit that the room pinned events have changed
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
roomState.emit(
RoomStateEvent.Events,
{
getType: () => EventType.RoomPinnedEvents,
} as MatrixEvent,
roomState,
null,
);
await waitFor(() => expect(screen.getByLabelText("Unpin")).toBeInTheDocument());
});
});
describe("expand/collapse quote buttons", () => {
it.each([
["expand", false],
["collapse", true],
])("should render %s", (state, value) => {
const { getByLabelText } = getComponent({
mxEvent: new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
"msgtype": MsgType.Text,
"body": "Hello",
"m.relates_to": {
"m.in_reply_to": { event_id: alicesMessageEvent.getId() },
},
},
event_id: "$alices_reply",
}),
isQuoteExpanded: value,
});
expect(getByLabelText(`${state[0].toUpperCase()}${state.slice(1)} quotes`)).toBeInTheDocument();
});
});
});
@@ -1,44 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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 { getByLabelText, render, type RenderResult } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React, { type ComponentProps } from "react";
import { EventTileThreadToolbar } from "../../../../../../src/components/views/rooms/EventTile/EventTileThreadToolbar";
describe("EventTileThreadToolbar", () => {
const viewInRoom = jest.fn();
const copyLink = jest.fn();
function renderComponent(props: Partial<ComponentProps<typeof EventTileThreadToolbar>> = {}): RenderResult {
return render(<EventTileThreadToolbar viewInRoom={viewInRoom} copyLinkToThread={copyLink} {...props} />);
}
afterEach(() => {
jest.resetAllMocks();
});
it("renders", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
});
it("calls the right callbacks", async () => {
const { container } = renderComponent();
const copyBtn = getByLabelText(container, "Copy link to thread");
const viewInRoomBtn = getByLabelText(container, "View in room");
await userEvent.click(copyBtn);
expect(copyLink).toHaveBeenCalledTimes(1);
await userEvent.click(viewInRoomBtn);
expect(viewInRoom).toHaveBeenCalledTimes(1);
});
});
@@ -1,49 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`EventTileThreadToolbar renders 1`] = `
<DocumentFragment>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="View in room"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
<div
aria-label="Copy link to thread"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</div>
</DocumentFragment>
`;
@@ -111,53 +111,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</div>
</div>
</div>
</div>
@@ -169,7 +122,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
<label
aria-label="Message bubbles"
class="_label_19upo_59"
for="radix-_r_9_"
for="radix-_r_1_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@@ -179,7 +132,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_9_"
id="radix-_r_1_"
name="layout"
title=""
type="radio"
@@ -252,53 +205,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</div>
</div>
</div>
</div>
@@ -310,7 +216,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
<label
aria-label="IRC (experimental)"
class="_label_19upo_59"
for="radix-_r_i_"
for="radix-_r_2_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@@ -320,7 +226,7 @@ exports[`<LayoutSwitcher /> should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_i_"
id="radix-_r_2_"
name="layout"
title=""
type="radio"
@@ -396,53 +302,6 @@ exports[`<LayoutSwitcher /> should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</div>
</div>
</div>
</div>
@@ -462,9 +321,9 @@ exports[`<LayoutSwitcher /> should render 1`] = `
class="_container_udcm8_10"
>
<input
aria-describedby="radix-_r_s_"
aria-describedby="radix-_r_4_"
class="_input_udcm8_24"
id="radix-_r_r_"
id="radix-_r_3_"
name="compactLayout"
role="switch"
title=""
@@ -480,13 +339,13 @@ exports[`<LayoutSwitcher /> should render 1`] = `
>
<label
class="_label_19upo_59"
for="radix-_r_r_"
for="radix-_r_3_"
>
Show compact text and messages
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_s_"
id="radix-_r_4_"
>
Modern layout must be selected to use this feature.
</span>
@@ -254,53 +254,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</div>
</div>
</div>
</div>
@@ -312,7 +265,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
<label
aria-label="Message bubbles"
class="_label_19upo_59"
for="radix-_r_c_"
for="radix-_r_4_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@@ -322,7 +275,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_c_"
id="radix-_r_4_"
name="layout"
title=""
type="radio"
@@ -395,53 +348,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</div>
</div>
</div>
</div>
@@ -453,7 +359,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
<label
aria-label="IRC (experimental)"
class="_label_19upo_59"
for="radix-_r_l_"
for="radix-_r_5_"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
@@ -463,7 +369,7 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
>
<input
class="_input_1ug7n_18"
id="radix-_r_l_"
id="radix-_r_5_"
name="layout"
title=""
type="radio"
@@ -539,53 +445,6 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</div>
</div>
</div>
</div>
@@ -605,9 +464,9 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
class="_container_udcm8_10"
>
<input
aria-describedby="radix-_r_v_"
aria-describedby="radix-_r_7_"
class="_input_udcm8_24"
id="radix-_r_u_"
id="radix-_r_6_"
name="compactLayout"
role="switch"
title=""
@@ -623,13 +482,13 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
>
<label
class="_label_19upo_59"
for="radix-_r_u_"
for="radix-_r_6_"
>
Show compact text and messages
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_v_"
id="radix-_r_7_"
>
Modern layout must be selected to use this feature.
</span>