element-web/apps/web/test/viewmodels/room/EventTileActionBarViewModel-test.ts
rbondesson 4315038346
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
2026-04-01 12:27:03 +00:00

717 lines
27 KiB
TypeScript

/*
* 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);
});
});
});