feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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 {
|
||||
SyncState,
|
||||
MatrixError,
|
||||
ClientEvent,
|
||||
type MatrixClient,
|
||||
type Room,
|
||||
type MatrixEvent,
|
||||
EventStatus,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { RoomStatusBarState } from "@element-hq/web-shared-components";
|
||||
import { type MockedObject } from "jest-mock";
|
||||
|
||||
import { mkEvent, mkRoom, stubClient } from "../../test-utils";
|
||||
import { RoomStatusBarViewModel } from "../../../src/viewmodels/room/RoomStatusBar";
|
||||
import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom";
|
||||
|
||||
const userId = "@example:example.org";
|
||||
|
||||
function mkEventWithError(error: MatrixError): MatrixEvent {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
user: userId,
|
||||
type: "org.example.test",
|
||||
content: {},
|
||||
status: EventStatus.NOT_SENT,
|
||||
});
|
||||
event.error = error;
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("RoomStatusBarViewModel", () => {
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let vm: RoomStatusBarViewModel;
|
||||
let room: MockedObject<Room>;
|
||||
let roomEmitFn!: () => void;
|
||||
beforeEach(() => {
|
||||
client = stubClient() as MockedObject<MatrixClient>;
|
||||
room = mkRoom(client, "!example");
|
||||
jest.spyOn(room, "on").mockImplementationOnce((_event, fn) => {
|
||||
roomEmitFn = fn as any;
|
||||
return room;
|
||||
});
|
||||
vm = new RoomStatusBarViewModel({
|
||||
room,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should not be visible by default", () => {
|
||||
expect(vm.getSnapshot()).toEqual({ state: null });
|
||||
});
|
||||
|
||||
it("should resolve state to ConnectionLost on failed sync", () => {
|
||||
client.getSyncState.mockReturnValue(SyncState.Error);
|
||||
client.emit(ClientEvent.Sync, SyncState.Error, null);
|
||||
expect(vm.getSnapshot()).toEqual({ state: RoomStatusBarState.ConnectionLost });
|
||||
});
|
||||
|
||||
// Because we expect LoggedInView to pop a toast
|
||||
it("should resolve state to nothing if sync error is M_RESOURCE_LIMIT_EXCEEDED", () => {
|
||||
client.getSyncState.mockReturnValue(SyncState.Error);
|
||||
client.getSyncStateData.mockReturnValue({ error: new MatrixError({ errcode: "M_RESOURCE_LIMIT_EXCEEDED" }) });
|
||||
client.emit(ClientEvent.Sync, SyncState.Error, null);
|
||||
expect(vm.getSnapshot()).toEqual({ state: null });
|
||||
});
|
||||
|
||||
it("should resolve state to NeedsConsent if a pending event has a M_CONSENT_NOT_GIVEN error", () => {
|
||||
room.getPendingEvents.mockReturnValue([
|
||||
mkEventWithError(new MatrixError({ errcode: "M_CONSENT_NOT_GIVEN", consent_uri: "https://example.org" })),
|
||||
]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.NeedsConsent,
|
||||
consentUri: "https://example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to UnsentMessages once onTermsAndConditionsClicked is called", () => {
|
||||
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_CONSENT_NOT_GIVEN" }))]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.NeedsConsent,
|
||||
});
|
||||
vm.onTermsAndConditionsClicked();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to ResourceLimited if a pending event has a M_RESOURCE_LIMIT_EXCEEDED error", () => {
|
||||
room.getPendingEvents.mockReturnValue([
|
||||
mkEventWithError(
|
||||
new MatrixError({
|
||||
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
|
||||
limit_type: "hs_disabled",
|
||||
admin_contact: "https://example.org",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.ResourceLimited,
|
||||
adminContactHref: "https://example.org",
|
||||
resourceLimit: "hs_disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to UnsentMessages if there are any other events", () => {
|
||||
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_UNKNOWN" }))]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to isResending=true once onResendAllClick is called", async () => {
|
||||
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_UNKNOWN" }))]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: false,
|
||||
});
|
||||
const promise = vm.onResendAllClick();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: true,
|
||||
});
|
||||
room.getPendingEvents.mockReturnValue([]);
|
||||
await promise;
|
||||
expect(client.resendEvent).toHaveBeenCalledTimes(1);
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Local rooms", () => {
|
||||
it("should resolve state to LocalRoomFailed if room fails to be created", () => {
|
||||
const localRoom = new LocalRoom("!example", client, userId);
|
||||
localRoom.state = LocalRoomState.ERROR;
|
||||
vm = new RoomStatusBarViewModel({
|
||||
room: localRoom,
|
||||
});
|
||||
expect(vm.getSnapshot()).toEqual({ state: RoomStatusBarState.LocalRoomFailed });
|
||||
});
|
||||
it("should resolve state to nothing for any other state for localroom", () => {
|
||||
const localRoom = new LocalRoom("!example", client, userId);
|
||||
localRoom.state = LocalRoomState.NEW;
|
||||
vm = new RoomStatusBarViewModel({
|
||||
room: localRoom,
|
||||
});
|
||||
expect(vm.getSnapshot()).toEqual({ state: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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 { type MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type MockedObject } from "jest-mock";
|
||||
import { createRef } from "react";
|
||||
|
||||
import { mkRoom, stubClient } from "../../test-utils";
|
||||
import { WidgetPipViewModel } from "../../../src/viewmodels/room/WidgetPipViewModel";
|
||||
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { CallStore, CallStoreEvent } from "../../../src/stores/CallStore";
|
||||
import { type Call } from "../../../src/models/Call";
|
||||
|
||||
const userId = "@example:example.org";
|
||||
const widgetId = "test-widget-id";
|
||||
|
||||
type BackClickEvent = Parameters<WidgetPipViewModel["onBackClick"]>[0];
|
||||
|
||||
const createBackClickEvent = (): BackClickEvent =>
|
||||
({
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn(),
|
||||
}) as unknown as BackClickEvent;
|
||||
|
||||
describe("WidgetPipViewModel", () => {
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let vm: WidgetPipViewModel;
|
||||
let room: MockedObject<Room>;
|
||||
let widget: IApp;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient() as MockedObject<MatrixClient>;
|
||||
room = mkRoom(client, "!example");
|
||||
widget = {
|
||||
id: widgetId,
|
||||
roomId: room.roomId,
|
||||
creatorUserId: userId,
|
||||
type: "m.custom",
|
||||
name: "Test Widget",
|
||||
data: {},
|
||||
} as unknown as IApp;
|
||||
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
|
||||
|
||||
vm = new WidgetPipViewModel({
|
||||
room,
|
||||
widgetId,
|
||||
onStartMoving: () => {},
|
||||
movePersistedElement: createRef(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.dispose();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("updates room name", () => {
|
||||
room.name = "New Room Name";
|
||||
room.emit(RoomEvent.Name, room);
|
||||
expect(vm.getSnapshot().roomName).toBe("New Room Name");
|
||||
});
|
||||
|
||||
it("updates onBackClick if call changes", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
|
||||
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
dispatchSpy.mockClear();
|
||||
|
||||
const call = { widget: { id: widgetId } } as unknown as Call;
|
||||
CallStore.instance.emit(CallStoreEvent.Call, call, room.roomId);
|
||||
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
view_call: true,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates onBackClick if viewingRoom changes", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
|
||||
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer").mockImplementation(() => {});
|
||||
|
||||
vm.setViewingRoom(true);
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(moveSpy).toHaveBeenCalledWith(room, widget, "center");
|
||||
|
||||
moveSpy.mockClear();
|
||||
vm.setViewingRoom(false);
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
expect(moveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 React from "react";
|
||||
import { MatrixWidgetType } from "matrix-widget-api";
|
||||
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import {
|
||||
WidgetContextMenuViewModel,
|
||||
type WidgetContextMenuViewModelProps,
|
||||
} from "../../../../src/viewmodels/room/right-panel/WidgetContextMenuViewModel";
|
||||
import { stubClient } from "../../../test-utils";
|
||||
import WidgetUtils from "../../../../src/utils/WidgetUtils";
|
||||
import { type IApp } from "../../../../src/utils/WidgetUtils-types";
|
||||
import { WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import * as livestream from "../../../../src/Livestream";
|
||||
import Modal from "../../../../src/Modal";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||
import * as widgetStore from "../../../../src/stores/WidgetStore";
|
||||
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import { type WidgetMessaging } from "../../../../src/stores/widgets/WidgetMessaging";
|
||||
|
||||
describe("WidgetContextMenuViewModel", () => {
|
||||
const widgetId = "w1";
|
||||
const eventId = "e1";
|
||||
const roomId = "r1";
|
||||
const userId = "@user-id:server";
|
||||
|
||||
const app: IApp = {
|
||||
id: widgetId,
|
||||
eventId,
|
||||
roomId,
|
||||
type: MatrixWidgetType.Custom,
|
||||
url: "https://example.com",
|
||||
name: "Example 1",
|
||||
creatorUserId: userId,
|
||||
avatar_url: undefined,
|
||||
};
|
||||
|
||||
let client: MatrixClient;
|
||||
const defaultProps: WidgetContextMenuViewModelProps = {
|
||||
menuDisplayed: true,
|
||||
room: undefined,
|
||||
roomId,
|
||||
cli: stubClient(),
|
||||
app,
|
||||
showUnpin: true,
|
||||
userWidget: true,
|
||||
trigger: <></>,
|
||||
onEditClick: jest.fn(),
|
||||
onDeleteClick: jest.fn(),
|
||||
onFinished: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
|
||||
jest.spyOn(WidgetUtils, "isManagedByManager").mockReturnValue(true);
|
||||
jest.spyOn(WidgetUtils, "editWidget").mockReturnValue();
|
||||
const mockMessaging = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
stop: () => {},
|
||||
widgetApi: {
|
||||
hasCapability: jest.fn(),
|
||||
},
|
||||
} as unknown as WidgetMessaging;
|
||||
jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue(mockMessaging);
|
||||
client = stubClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return the snapshot", () => {
|
||||
const vm = new WidgetContextMenuViewModel(defaultProps);
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
showStreamAudioStreamButton: false, // because widget type is custom and not jitsi
|
||||
showEditButton: true, // because default mock return true on canUserModifyWidgets and isManagedByManager
|
||||
showRevokeButton: false,
|
||||
showDeleteButton: true,
|
||||
showSnapshotButton: false, // because no default value for sdkconfig "enableWidgetScreenshots"
|
||||
showMoveButtons: [false, false],
|
||||
canModify: true,
|
||||
isMenuOpened: true,
|
||||
trigger: <></>,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call edit widget no custom edit function passed and room exist", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
onEditClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onEditClick();
|
||||
expect(WidgetUtils.editWidget).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call custom onEditClick if passed as props and room exist", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onEditClick();
|
||||
|
||||
expect(props.onEditClick).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should just call finish if no custom onEditClick is passed as props and does not room exist", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
onEditClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onEditClick();
|
||||
|
||||
expect(WidgetUtils.editWidget).not.toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should move widget position when onmovebutton is called", () => {
|
||||
jest.spyOn(WidgetLayoutStore.instance, "moveWithinContainer").mockReturnValue();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onMoveButton(1);
|
||||
|
||||
expect(WidgetLayoutStore.instance.moveWithinContainer).toHaveBeenCalledWith(props.room, "top", props.app, 1);
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error when onmovebutton is called and no room is given", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
expect(() => vm.onMoveButton(1)).toThrow();
|
||||
});
|
||||
|
||||
it("should startJitsiAudioLivestream when onStreamAudioClick button is clicked", async () => {
|
||||
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(jest.fn());
|
||||
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onStreamAudioClick();
|
||||
await expect(livestream.startJitsiAudioLivestream).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show modal when startJitsiAudioLivestream is on error and onStreamAudioClick button is clicked", async () => {
|
||||
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(() => {
|
||||
console.log("failllllled");
|
||||
throw new Error("Failed");
|
||||
});
|
||||
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({
|
||||
finished: Promise.resolve([true, true, false]),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
await vm.onStreamAudioClick();
|
||||
expect(Modal.createDialog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw when no room is given and onStreamAudioClick button is clicked", async () => {
|
||||
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(jest.fn());
|
||||
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
await vm.onStreamAudioClick();
|
||||
// nothing happened
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call custom delete function when it is given in props", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onDeleteClick();
|
||||
expect(props.onDeleteClick).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display modal when no custom function is provided and a room is given", () => {
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({
|
||||
finished: Promise.resolve([true, true, false]),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
onDeleteClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
vm.onDeleteClick();
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should do nothing when onDeleteClick and no custom function and no room is provided", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
onDeleteClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
vm.onDeleteClick();
|
||||
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set new level for allowedwidget when onrevoke button is clicked", () => {
|
||||
const current = { [eventId]: true };
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(current);
|
||||
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(SettingLevel.DEFAULT);
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue();
|
||||
jest.spyOn(widgetStore, "isAppWidget").mockReturnValue(true);
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
vm.onRevokeClick();
|
||||
|
||||
expect(SettingsStore.setValue).toHaveBeenCalledWith(
|
||||
"allowedWidgets",
|
||||
props.roomId,
|
||||
SettingLevel.DEFAULT,
|
||||
current,
|
||||
);
|
||||
|
||||
const current2 = { [eventId]: false };
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(current2);
|
||||
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(SettingLevel.DEFAULT);
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue();
|
||||
jest.spyOn(widgetStore, "isAppWidget").mockReturnValue(false);
|
||||
|
||||
vm.onRevokeClick();
|
||||
|
||||
expect(SettingsStore.setValue).toHaveBeenCalledWith(
|
||||
"allowedWidgets",
|
||||
props.roomId,
|
||||
SettingLevel.DEFAULT,
|
||||
current2,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error when first supported level is not set", () => {
|
||||
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(null);
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
onDeleteClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
expect(() => vm.onRevokeClick()).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user