mirror of
https://github.com/vector-im/element-web.git
synced 2026-04-18 12:01:57 +02:00
* 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
717 lines
27 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|