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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user