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

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -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();
});
});