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
@@ -948,7 +948,10 @@ describe("RoomView", () => {
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible();
});
await userEvent.hover(getByText("search term"));
const searchResultTile = getByText("search term").closest(".mx_EventTile");
expect(searchResultTile).not.toBeNull();
await userEvent.hover(searchResultTile!);
await userEvent.click(await findByLabelText("Edit"));
await waitFor(() => {
@@ -1014,7 +1017,10 @@ describe("RoomView", () => {
});
const prom = untilDispatch(Action.ViewRoom, defaultDispatcher);
await userEvent.hover(getByText("search term"));
const searchResultTile = getByText("search term").closest(".mx_EventTile");
expect(searchResultTile).not.toBeNull();
await userEvent.hover(searchResultTile!);
await userEvent.click(await findByLabelText("Edit"));
await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
@@ -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>
@@ -35,6 +35,11 @@ describe("useMediaVisible", () => {
withClientContextRenderOptions(matrixClient),
);
}
function renderWithoutEvent() {
return renderHook(() => useMediaVisible(), withClientContextRenderOptions(matrixClient));
}
beforeEach(() => {
matrixClient = createTestClient();
room = mkStubRoom(ROOM_ID, undefined, matrixClient);
@@ -57,6 +62,14 @@ describe("useMediaVisible", () => {
expect(visible).toEqual(true);
});
it("should use the global rule when no event is provided", () => {
mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
expect(renderWithoutEvent().result.current[0]).toEqual(false);
mediaPreviewConfig.media_previews = MediaPreviewValue.On;
expect(renderWithoutEvent().result.current[0]).toEqual(true);
});
it("should hide media when media previews are Off", () => {
mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
const [visible] = render().result.current;
@@ -0,0 +1,82 @@
/*
* 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 { ActionBarAction } from "@element-hq/web-shared-components";
import { EditHistoryActionBarViewModel } from "../../../src/viewmodels/message-body/EditHistoryActionBarViewModel";
describe("EditHistoryActionBarViewModel", () => {
it("builds a label snapshot with remove and view source actions", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: true,
showViewSource: true,
});
expect(vm.getSnapshot()).toMatchObject({
actions: [ActionBarAction.Remove, ActionBarAction.ViewSource],
presentation: "label",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
});
it("omits actions that are disabled by props", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: false,
});
expect(vm.getSnapshot().actions).toEqual([]);
});
it("updates the snapshot when props change", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: true,
});
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.ViewSource]);
vm.setProps({
canRemove: true,
showViewSource: false,
});
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.Remove]);
});
it("forwards remove clicks to props", () => {
const onRemoveClick = jest.fn();
const vm = new EditHistoryActionBarViewModel({
canRemove: true,
showViewSource: false,
onRemoveClick,
});
const anchor = document.createElement("button");
vm.onRemoveClick(anchor);
expect(onRemoveClick).toHaveBeenCalledWith(anchor);
});
it("forwards view source clicks to props", () => {
const onViewSourceClick = jest.fn();
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: true,
onViewSourceClick,
});
const anchor = document.createElement("button");
vm.onViewSourceClick(anchor);
expect(onViewSourceClick).toHaveBeenCalledWith(anchor);
});
});
@@ -0,0 +1,716 @@
/*
* 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 EventEmitter from "events";
import { waitFor } from "@testing-library/dom";
import { mocked } from "jest-mock";
import {
EventStatus,
EventTimeline,
EventType,
M_BEACON_INFO,
MatrixEvent,
MatrixEventEvent,
MsgType,
RelationType,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import { ActionBarAction } from "@element-hq/web-shared-components";
import {
EventTileActionBarViewModel,
type EventTileActionBarViewModelProps,
} from "../../../src/viewmodels/room/EventTileActionBarViewModel";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import Resend from "../../../src/Resend";
import PinningUtils from "../../../src/utils/PinningUtils";
import PosthogTrackers from "../../../src/PosthogTrackers";
import Modal from "../../../src/Modal";
import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog";
import SettingsStore from "../../../src/settings/SettingsStore";
import { ModuleApi } from "../../../src/modules/Api";
import { canCancel, canEditContent, editEvent, isContentActionable } from "../../../src/utils/EventUtils";
import { shouldDisplayReply } from "../../../src/utils/Reply";
import { MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { getMediaVisibility, setMediaVisibility } from "../../../src/utils/media/mediaVisibility";
import { createTestClient } from "../../test-utils";
jest.mock("../../../src/dispatcher/dispatcher", () => ({
__esModule: true,
default: {
dispatch: jest.fn(),
register: jest.fn().mockReturnValue("dispatcher-ref"),
unregister: jest.fn(),
},
}));
jest.mock("../../../src/Resend", () => ({
__esModule: true,
default: {
resend: jest.fn(),
removeFromQueue: jest.fn(),
},
}));
jest.mock("../../../src/PosthogTrackers", () => ({
__esModule: true,
default: {
trackPinUnpinMessage: jest.fn(),
},
}));
jest.mock("../../../src/Modal", () => ({
__esModule: true,
default: {
createDialog: jest.fn(),
},
}));
jest.mock("../../../src/languageHandler", () => ({
_t: (key: string) => {
switch (key) {
case "timeline|download_failed":
return "Download failed";
case "timeline|download_failed_description":
return "Failed to download file";
case "common|image":
return "Image";
default:
return key;
}
},
_td: (key: string) => key,
}));
jest.mock("../../../src/utils/EventUtils", () => ({
canCancel: jest.fn(),
canEditContent: jest.fn(),
editEvent: jest.fn(),
isContentActionable: jest.fn(),
}));
jest.mock("../../../src/utils/PinningUtils", () => ({
__esModule: true,
default: {
canPin: jest.fn(),
canUnpin: jest.fn(),
isPinned: jest.fn(),
pinOrUnpinEvent: jest.fn(),
},
}));
jest.mock("../../../src/utils/Reply", () => ({
shouldDisplayReply: jest.fn(),
}));
jest.mock("../../../src/utils/media/mediaVisibility", () => ({
getMediaVisibility: jest.fn(),
setMediaVisibility: jest.fn(),
}));
const mockDownload = jest.fn();
jest.mock("../../../src/utils/FileDownloader", () => ({
FileDownloader: jest.fn().mockImplementation(() => ({
download: mockDownload,
})),
}));
describe("EventTileActionBarViewModel", () => {
const userId = "@alice:example.org";
const roomId = "!room:example.org";
const rootEvent = new MatrixEvent({
type: EventType.RoomMessage,
room_id: roomId,
sender: "@root:example.org",
event_id: "$root",
content: { msgtype: MsgType.Text, body: "Root" },
});
let client: ReturnType<typeof createTestClient>;
let roomState: EventEmitter;
let room: {
getLiveTimeline: jest.Mock;
};
let getHintsForMessageSpy: jest.SpyInstance;
const createMessageEvent = (overrides: Partial<ConstructorParameters<typeof MatrixEvent>[0]> = {}): MatrixEvent =>
new MatrixEvent({
type: EventType.RoomMessage,
room_id: roomId,
sender: userId,
event_id: "$event",
content: { msgtype: MsgType.Text, body: "Hello" },
...overrides,
});
const createVm = (props: Partial<EventTileActionBarViewModelProps> = {}): EventTileActionBarViewModel => {
const mxEvent = props.mxEvent ?? createMessageEvent();
return new EventTileActionBarViewModel({
mxEvent,
timelineRenderingType: TimelineRenderingType.Room,
canSendMessages: true,
canReact: true,
...props,
});
};
const createPendingPromise = <T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
beforeEach(() => {
jest.clearAllMocks();
client = createTestClient();
roomState = new EventEmitter();
room = {
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest
.fn()
.mockImplementation((dir) => (dir === EventTimeline.FORWARDS ? roomState : undefined)),
}),
};
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
jest.spyOn(client, "getRoom").mockReturnValue(room as never);
jest.spyOn(client, "decryptEventIfNeeded");
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((name, scope) => `${name}:${scope ?? "global"}`);
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(() => {});
mocked(canCancel).mockImplementation((status) => status === EventStatus.QUEUED);
mocked(canEditContent).mockReturnValue(true);
mocked(isContentActionable).mockReturnValue(true);
mocked(shouldDisplayReply).mockReturnValue(true);
mocked(getMediaVisibility).mockReturnValue(true);
mocked(setMediaVisibility).mockResolvedValue(undefined);
mocked(PinningUtils.canPin).mockReturnValue(false);
mocked(PinningUtils.canUnpin).mockReturnValue(false);
mocked(PinningUtils.isPinned).mockReturnValue(false);
mocked(PinningUtils.pinOrUnpinEvent).mockResolvedValue(undefined);
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(false);
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(false);
mockDownload.mockResolvedValue(undefined);
getHintsForMessageSpy = jest.spyOn(ModuleApi.instance.customComponents, "getHintsForMessage");
getHintsForMessageSpy.mockReturnValue(null);
});
afterEach(() => {
getHintsForMessageSpy.mockRestore();
jest.restoreAllMocks();
});
it("builds the snapshot for an actionable message", async () => {
const vm = createVm({ isQuoteExpanded: true });
await waitFor(() =>
expect(vm.getSnapshot()).toMatchObject({
actions: [
ActionBarAction.React,
ActionBarAction.Reply,
ActionBarAction.ReplyInThread,
ActionBarAction.Edit,
ActionBarAction.Expand,
ActionBarAction.Options,
],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: true,
isThreadReplyAllowed: true,
}),
);
});
it("reacts to media download permission hints and room state updates", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(true);
getHintsForMessageSpy.mockReturnValue({
allowDownloadingMedia: jest.fn().mockResolvedValue(true),
} as never);
const vm = createVm({
mxEvent: createMessageEvent({
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
}),
});
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Hide);
await waitFor(() => expect(vm.getSnapshot().actions).toContain(ActionBarAction.Download));
mocked(PinningUtils.isPinned).mockReturnValue(true);
roomState.emit(
RoomStateEvent.Events,
new MatrixEvent({
type: EventType.RoomPinnedEvents,
room_id: roomId,
sender: userId,
content: { pinned: ["$event"] },
}),
);
expect(vm.getSnapshot().isPinned).toBe(true);
mocked(getMediaVisibility).mockReturnValue(false);
roomState.emit(
RoomStateEvent.Events,
new MatrixEvent({
type: EventType.RoomJoinRules,
room_id: roomId,
sender: userId,
content: { join_rule: "public" },
}),
);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Hide);
});
it("ignores stale download permission results after setProps changes the event", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const permissionA = createPendingPromise<boolean>();
const permissionB = createPendingPromise<boolean>();
const eventA = createMessageEvent({
event_id: "$eventA",
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
});
const eventB = createMessageEvent({
event_id: "$eventB",
content: { msgtype: MsgType.Image, body: "Image B", url: "mxc://example.org/b" },
});
getHintsForMessageSpy.mockImplementation((event) => {
if (event === eventA) {
return {
allowDownloadingMedia: jest.fn().mockReturnValue(permissionA.promise),
} as never;
}
if (event === eventB) {
return {
allowDownloadingMedia: jest.fn().mockReturnValue(permissionB.promise),
} as never;
}
return null;
});
const vm = createVm({ mxEvent: eventA });
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
vm.setProps({ mxEvent: eventB });
permissionA.resolve(true);
await Promise.resolve();
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
permissionB.resolve(false);
await Promise.resolve();
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
});
it("refreshes on event status changes and removes listeners on dispose", () => {
const mxEvent = createMessageEvent();
const offSpy = jest.spyOn(mxEvent, "off");
const roomStateOffSpy = jest.spyOn(roomState, "off");
const vm = createVm({ mxEvent });
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Cancel);
mxEvent.setStatus(EventStatus.QUEUED);
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Cancel);
expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent);
vm.dispose();
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Status, expect.any(Function));
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function));
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.BeforeRedaction, expect.any(Function));
expect(roomStateOffSpy).toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function));
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mediaPreviewConfig:!room:example.org");
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("showMediaEventIds:global");
});
it("routes resend and cancel actions to the actionable failed event variant", () => {
const mxEvent = createMessageEvent();
const localRedactionEvent = createMessageEvent({ event_id: "$redaction" });
const replacingEvent = createMessageEvent({ event_id: "$replacement" });
localRedactionEvent.setStatus(EventStatus.SENT);
replacingEvent.setStatus(EventStatus.QUEUED);
jest.spyOn(mxEvent, "localRedactionEvent").mockReturnValue(localRedactionEvent);
jest.spyOn(mxEvent, "replacingEvent").mockReturnValue(replacingEvent);
const vm = createVm({ mxEvent });
vm.onResendClick(null);
vm.onCancelClick(null);
expect(Resend.resend).toHaveBeenCalledWith(client, localRedactionEvent);
expect(Resend.removeFromQueue).toHaveBeenCalledWith(client, replacingEvent);
});
it("downloads a cached blob and shows an error dialog on failure", async () => {
const blob = new Blob(["downloaded"]);
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const vm = createVm({
mxEvent: createMessageEvent({
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
}),
});
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = blob;
await vm.onDownloadClick(null);
await vm.onDownloadClick(null);
expect(mockDownload).toHaveBeenNthCalledWith(1, { blob, name: "Image" });
expect(mockDownload).toHaveBeenNthCalledWith(2, { blob, name: "Image" });
mockDownload.mockRejectedValueOnce(new Error("boom"));
await vm.onDownloadClick(null);
expect(Modal.createDialog).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({
title: "Download failed",
description: expect.stringContaining("boom"),
}),
);
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
});
it("ignores stale download completion after setProps changes the event", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const firstDownload = createPendingPromise<void>();
const eventA = createMessageEvent({
event_id: "$eventA",
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
});
const eventB = createMessageEvent({
event_id: "$eventB",
content: { msgtype: MsgType.Image, body: "Image B", url: "mxc://example.org/b" },
});
const vm = createVm({ mxEvent: eventA });
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = new Blob(["a"]);
mockDownload.mockReturnValueOnce(firstDownload.promise);
const firstDownloadCall = vm.onDownloadClick(null);
expect(vm.getSnapshot().isDownloadLoading).toBe(true);
vm.setProps({ mxEvent: eventB });
(vm as unknown as { downloadedBlob: Blob }).downloadedBlob = new Blob(["b"]);
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
const secondDownload = vm.onDownloadClick(null);
await secondDownload;
firstDownload.resolve();
await firstDownloadCall;
expect(mockDownload).toHaveBeenCalledTimes(2);
expect(mockDownload).toHaveBeenNthCalledWith(2, {
blob: expect.any(Blob),
name: "Image B",
});
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
});
it("ignores stale download permission results after dispose", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const permission = createPendingPromise<boolean>();
const event = createMessageEvent({
event_id: "$eventA",
content: { msgtype: MsgType.Image, body: "Image A", url: "mxc://example.org/a" },
});
getHintsForMessageSpy.mockReturnValue({
allowDownloadingMedia: jest.fn().mockReturnValue(permission.promise),
} as never);
const vm = createVm({ mxEvent: event });
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
vm.dispose();
permission.resolve(true);
await Promise.resolve();
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
});
it("dispatches reply and thread actions and forwards callbacks", async () => {
const onOptionsClick = jest.fn();
const onReactionsClick = jest.fn();
const onToggleThreadExpanded = jest.fn();
const threadReply = createMessageEvent({
sender: "@bob:example.org",
event_id: "$reply",
content: {
"msgtype": MsgType.Text,
"body": "Reply",
"m.relates_to": {
rel_type: RelationType.Thread,
event_id: rootEvent.getId(),
},
},
});
Object.defineProperty(threadReply, "isThreadRoot", { value: false });
jest.spyOn(threadReply, "getThread").mockReturnValue({ rootEvent } as never);
const vm = createVm({
mxEvent: threadReply,
isCard: true,
onOptionsClick,
onReactionsClick,
onToggleThreadExpanded,
});
mocked(PinningUtils.isPinned).mockReturnValue(false);
vm.onReplyClick(null);
vm.onReplyInThreadClick(null);
vm.onEditClick(null);
await vm.onPinClick(null);
vm.onHideClick(null);
vm.onOptionsClick(null);
vm.onReactionsClick(null);
vm.onToggleThreadExpanded(null);
expect(defaultDispatcher.dispatch).toHaveBeenNthCalledWith(1, {
action: "reply_to_event",
event: threadReply,
context: TimelineRenderingType.Room,
});
expect(defaultDispatcher.dispatch).toHaveBeenNthCalledWith(2, {
action: Action.ShowThread,
rootEvent,
initialEvent: threadReply,
scroll_into_view: true,
highlighted: true,
push: true,
});
expect(editEvent).toHaveBeenCalledWith(client, threadReply, TimelineRenderingType.Room, undefined);
expect(PinningUtils.pinOrUnpinEvent).toHaveBeenCalledWith(client, threadReply);
expect(PosthogTrackers.trackPinUnpinMessage).toHaveBeenCalledWith(expect.any(String), "Timeline");
expect(setMediaVisibility).toHaveBeenCalledWith(threadReply, false);
expect(onOptionsClick).toHaveBeenCalledWith(null);
expect(onReactionsClick).toHaveBeenCalledWith(null);
expect(onToggleThreadExpanded).toHaveBeenCalledWith(null);
});
describe("business logic parity", () => {
it.each([
{
name: "hides reply and react for non-actionable events",
actionable: false,
props: {},
expectedActions: [],
unexpectedActions: [ActionBarAction.Reply, ActionBarAction.React],
},
{
name: "hides reply when sending messages is disabled",
actionable: true,
props: { canSendMessages: false },
expectedActions: [ActionBarAction.React],
unexpectedActions: [ActionBarAction.Reply],
},
{
name: "hides react when reactions are disabled",
actionable: true,
props: { canReact: false },
expectedActions: [ActionBarAction.Reply],
unexpectedActions: [ActionBarAction.React],
},
{
name: "hides react in search results",
actionable: true,
props: { isSearch: true },
expectedActions: [ActionBarAction.Reply],
unexpectedActions: [ActionBarAction.React],
},
])("$name", ({ actionable, props, expectedActions, unexpectedActions }) => {
mocked(isContentActionable).mockReturnValue(actionable);
const vm = createVm(props);
expectedActions.forEach((action) => expect(vm.getSnapshot().actions).toContain(action));
unexpectedActions.forEach((action) => expect(vm.getSnapshot().actions).not.toContain(action));
});
it.each([
{
name: "shows expand collapse only when quote state is provided and reply should display",
quoteExpanded: true,
displayReply: true,
expected: true,
},
{
name: "hides expand collapse when quote state is missing",
quoteExpanded: undefined,
displayReply: true,
expected: false,
},
{
name: "hides expand collapse when reply should not display",
quoteExpanded: false,
displayReply: false,
expected: false,
},
])("$name", ({ quoteExpanded, displayReply, expected }) => {
mocked(shouldDisplayReply).mockReturnValue(displayReply);
const vm = createVm({ isQuoteExpanded: quoteExpanded });
expect(vm.getSnapshot().actions.includes(ActionBarAction.Expand)).toBe(expected);
});
it.each([
{
name: "allows reply in thread for normal room messages in room timeline",
timelineRenderingType: TimelineRenderingType.Room,
content: { msgtype: MsgType.Text, body: "Hello" },
relation: undefined,
type: EventType.RoomMessage,
expectedReplyInThread: true,
expectedAllowed: true,
},
{
name: "blocks reply in thread in thread timeline",
timelineRenderingType: TimelineRenderingType.Thread,
content: { msgtype: MsgType.Text, body: "Hello" },
relation: undefined,
type: EventType.RoomMessage,
expectedReplyInThread: false,
expectedAllowed: true,
},
{
name: "blocks reply in thread for verification requests",
timelineRenderingType: TimelineRenderingType.Room,
content: { msgtype: MsgType.KeyVerificationRequest, body: "verify" },
relation: undefined,
type: EventType.RoomMessage,
expectedReplyInThread: false,
expectedAllowed: true,
},
{
name: "blocks reply in thread for beacon info events",
timelineRenderingType: TimelineRenderingType.Room,
content: {},
relation: undefined,
type: M_BEACON_INFO.name,
expectedReplyInThread: false,
expectedAllowed: true,
},
{
name: "marks non-thread relations as not thread reply allowed",
timelineRenderingType: TimelineRenderingType.Room,
content: { msgtype: MsgType.Text, body: "Hello" },
relation: { rel_type: RelationType.Annotation },
type: EventType.RoomMessage,
expectedReplyInThread: true,
expectedAllowed: false,
},
])("$name", ({ timelineRenderingType, content, relation, type, expectedReplyInThread, expectedAllowed }) => {
const mxEvent = new MatrixEvent({
type,
room_id: roomId,
sender: userId,
event_id: "$scenario",
content,
});
jest.spyOn(mxEvent, "getRelation").mockReturnValue(relation as never);
const vm = createVm({ mxEvent, timelineRenderingType });
expect(vm.getSnapshot().actions.includes(ActionBarAction.ReplyInThread)).toBe(expectedReplyInThread);
expect(vm.getSnapshot().isThreadReplyAllowed).toBe(expectedAllowed);
});
it("shows thread action for deleted messages with a thread in the room timeline", () => {
const mxEvent = createMessageEvent();
mocked(isContentActionable).mockReturnValue(false);
jest.spyOn(mxEvent, "getThread").mockReturnValue({ rootEvent } as never);
const vm = createVm({ mxEvent, timelineRenderingType: TimelineRenderingType.Room });
expect(vm.getSnapshot().actions).toContain(ActionBarAction.ReplyInThread);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Reply);
});
it("matches media visibility rules for hide and download actions", async () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
jest.spyOn(MediaEventHelper, "canHide").mockReturnValue(true);
getHintsForMessageSpy.mockReturnValue({
allowDownloadingMedia: jest.fn().mockResolvedValue(false),
} as never);
const mxEvent = createMessageEvent({
content: { msgtype: MsgType.Image, body: "Image", file: { url: "mxc://example.org/file" } },
});
const vm = createVm({ mxEvent });
expect(vm.getSnapshot()).toMatchObject({
isDownloadEncrypted: true,
});
expect(vm.getSnapshot().actions).toContain(ActionBarAction.Hide);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
await waitFor(() => expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download));
});
it("recomputes parity-relevant flags and resets download state when the event changes", () => {
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true);
const vm = createVm({
mxEvent: createMessageEvent({
event_id: "$image",
content: { msgtype: MsgType.Image, body: "Image", url: "mxc://example.org/file" },
}),
});
(vm as unknown as { downloadedBlob?: Blob; isDownloadLoading: boolean }).downloadedBlob = new Blob(["x"]);
(vm as unknown as { downloadedBlob?: Blob; isDownloadLoading: boolean }).isDownloadLoading = true;
mocked(isContentActionable).mockReturnValue(false);
jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(false);
vm.setProps({
mxEvent: createMessageEvent({
event_id: "$text",
content: { msgtype: MsgType.Text, body: "Text" },
}),
});
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Download);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Hide);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Reply);
expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.React);
expect(vm.getSnapshot().isDownloadLoading).toBe(false);
});
});
});