mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,75 @@
/*
* 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 { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { waitFor } from "@testing-library/dom";
import { type Playback, PlaybackState } from "../../../src/audio/Playback";
import { AudioPlayerViewModel } from "../../../src/viewmodels/audio/AudioPlayerViewModel";
import { MockedPlayback } from "../../unit-tests/audio/MockedPlayback";
describe("AudioPlayerViewModel", () => {
let playback: Playback;
beforeEach(() => {
playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as Playback;
});
it("should return the snapshot", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
expect(vm.getSnapshot()).toMatchObject({
mediaName: "mediaName",
sizeBytes: 8000,
playbackState: "decoding",
durationSeconds: 50,
playedSeconds: 10,
percentComplete: 20,
error: false,
});
});
it("should toggle the playback state", async () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
await vm.togglePlay();
expect(playback.toggle).toHaveBeenCalled();
});
it("should move the playback on seekbar change", async () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
await vm.onSeekbarChange({ target: { value: "20" } } as ChangeEvent<HTMLInputElement>);
expect(playback.skipTo).toHaveBeenCalledWith(10); // 20% of 50 seconds
});
it("should has error=true when playback.prepare fails", async () => {
jest.spyOn(playback, "prepare").mockRejectedValue(new Error("Failed to prepare playback"));
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
await waitFor(() => expect(vm.getSnapshot().error).toBe(true));
});
it("should handle key down events", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
let event = new KeyboardEvent("keydown", { key: " " }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
vm.onKeyDown(event);
expect(playback.toggle).toHaveBeenCalled();
event = new KeyboardEvent("keydown", { key: "ArrowLeft" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
vm.onKeyDown(event);
expect(playback.skipTo).toHaveBeenCalledWith(10 - 5); // 5 seconds back
event = new KeyboardEvent("keydown", { key: "ArrowRight" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
vm.onKeyDown(event);
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
});
it("should update snapshot when setProps is called with new mediaName", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "oldName" });
expect(vm.getSnapshot().mediaName).toBe("oldName");
vm.setProps({ mediaName: "newName" });
expect(vm.getSnapshot().mediaName).toBe("newName");
});
});
@@ -0,0 +1,192 @@
/*
* 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 { waitFor } from "@testing-library/dom";
import { mocked } from "jest-mock";
import { RoomStateEvent, type MatrixClient, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { EncryptionEventState } from "@element-hq/web-shared-components";
import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
import { EncryptionEventViewModel } from "../../../src/viewmodels/event-tiles/EncryptionEventViewModel";
import { LocalRoom } from "../../../src/models/LocalRoom";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { mkEvent, stubClient } from "../../test-utils";
describe("EncryptionEventViewModel", () => {
const roomId = "!room:example.com";
const algorithm = "m.megolm.v1.aes-sha2";
let client: MatrixClient;
let event: MatrixEvent;
let room: Room;
beforeEach(() => {
jest.clearAllMocks();
client = stubClient();
room = client.getRoom(roomId)!;
mocked(client.getRoom).mockReturnValue(room);
event = mkEvent({
event: true,
room: roomId,
user: client.getUserId()!,
type: "m.room.encryption",
content: {
algorithm,
},
prev_content: {},
});
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
});
const setRoomEncrypted = (encrypted: boolean): void => {
const crypto = client.getCrypto()!;
mocked(crypto.isEncryptionEnabledInRoom).mockResolvedValue(encrypted);
};
const createVm = (
props: Partial<ConstructorParameters<typeof EncryptionEventViewModel>[0]> = {},
): EncryptionEventViewModel =>
new EncryptionEventViewModel({
mxEvent: event,
cli: client,
...props,
});
it("sets ENABLED for encrypted room", async () => {
setRoomEncrypted(true);
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
expect(vm.getSnapshot()).toMatchObject({
state: EncryptionEventState.ENABLED,
className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon",
encryptedStateEvents: false,
});
});
it("uses synchronous room encryption state for the initial snapshot", () => {
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true);
setRoomEncrypted(false);
const vm = createVm();
expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED);
});
it("sets ENABLED with encryptedStateEvents=true for encrypted state events", async () => {
setRoomEncrypted(true);
client.enableEncryptedStateEvents = true;
(event.getContent() as RoomEncryptionEventContent)["io.element.msc4362.encrypt_state_events"] = true;
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
expect(vm.getSnapshot().encryptedStateEvents).toBe(true);
});
it("sets CHANGED when previous algorithm is already megolm", async () => {
setRoomEncrypted(true);
event = mkEvent({
event: true,
room: roomId,
user: client.getUserId()!,
type: "m.room.encryption",
content: {
algorithm,
rotation_period_ms: 1,
},
prev_content: { algorithm },
});
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.CHANGED));
});
it("sets DISABLE_ATTEMPT for unknown algorithm in encrypted room", async () => {
setRoomEncrypted(true);
event = mkEvent({
event: true,
room: roomId,
user: client.getUserId()!,
type: "m.room.encryption",
content: { algorithm: "unknown" },
prev_content: {},
});
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.DISABLE_ATTEMPT));
});
it("sets UNSUPPORTED for unencrypted room", async () => {
setRoomEncrypted(false);
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED));
expect(vm.getSnapshot().className).toBe("mx_EventTileBubble mx_cryptoEvent");
});
it("sets ENABLED_DM with partner display name", async () => {
setRoomEncrypted(true);
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn().mockReturnValue("@alice:example.com"),
} as unknown as DMRoomMap);
mocked(room.getMember).mockReturnValue({
rawDisplayName: "Alice",
} as unknown as ReturnType<typeof room.getMember>);
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED_DM));
expect(vm.getSnapshot().userName).toBe("Alice");
});
it("sets ENABLED_LOCAL for encrypted local room", async () => {
const localRoomId = "local+123";
const localRoom = new LocalRoom(localRoomId, client, client.getUserId()!);
jest.spyOn(localRoom, "isEncryptionEnabled").mockReturnValue(true);
mocked(client.getRoom).mockReturnValue(localRoom);
event = mkEvent({
event: true,
room: localRoomId,
user: client.getUserId()!,
type: "m.room.encryption",
content: { algorithm },
prev_content: {},
});
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED_LOCAL));
expect(localRoom.isEncryptionEnabled).toHaveBeenCalled();
});
it("recomputes snapshot on RoomStateEvent.Update", async () => {
setRoomEncrypted(false);
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED));
setRoomEncrypted(true);
room.emit(RoomStateEvent.Update, room.currentState);
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
});
it("does not emit updates when snapshot is unchanged", async () => {
setRoomEncrypted(true);
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
const listener = jest.fn();
const unsubscribe = vm.subscribe(listener);
room.emit(RoomStateEvent.Update, room.currentState);
await waitFor(() => expect(mocked(client.getCrypto()!.isEncryptionEnabledInRoom)).toHaveBeenCalledTimes(2));
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
});
@@ -0,0 +1,35 @@
/*
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 { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { TextualEventViewModel } from "../../../src/viewmodels/event-tiles/TextualEventViewModel";
import { stubClient } from "../../test-utils";
jest.mock("../../../src/TextForEvent.tsx", () => ({
textForEvent: jest.fn().mockReturnValue("Test Message"),
}));
describe("TextualEventViewModel", () => {
it("should update when the sentinel updates", () => {
const fakeEvent = new MatrixEvent({});
stubClient();
const vm = new TextualEventViewModel({
showHiddenEvents: false,
mxEvent: fakeEvent,
});
const cb = jest.fn();
vm.subscribe(cb);
fakeEvent.emit(MatrixEventEvent.SentinelUpdated);
expect(cb).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,101 @@
/*
* 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 { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
import { DecryptionFailureReason } from "@element-hq/web-shared-components";
import { DecryptionFailureBodyViewModel } from "../../../src/viewmodels/message-body/DecryptionFailureBodyViewModel";
describe("DecryptionFailureBodyViewModel", () => {
it("should return the snapshot", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: null,
verificationState: true,
});
expect(vm.getSnapshot()).toMatchObject({
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
isLocalDeviceVerified: true,
});
});
it("should return the snapshot with extra class names", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: null,
verificationState: true,
extraClassNames: ["custom-class"],
});
expect(vm.getSnapshot()).toMatchObject({
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
isLocalDeviceVerified: true,
extraClassNames: ["mx_DecryptionFailureBody", "mx_EventTile_content", "custom-class"],
});
});
it.each([
{
code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
},
{
code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
},
{
code: DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_USER_NOT_JOINED,
},
{
code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
reason: DecryptionFailureReason.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
},
{
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
reason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
},
{
code: DecryptionFailureCode.UNKNOWN_ERROR,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.UNKNOWN_SENDER_DEVICE,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.UNSIGNED_SENDER_DEVICE,
reason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE,
},
])("should return the snapshot with code converted to reason (%s)", ({ code, reason }) => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: code,
});
expect(vm.getSnapshot().decryptionFailureReason).toBe(reason);
});
it("should update snapshot when setProps is called with new verificationState", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: DecryptionFailureCode.UNKNOWN_ERROR,
verificationState: false,
});
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(false);
vm.setVerificationState(true);
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(true);
});
});
@@ -0,0 +1,123 @@
/*
* 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 * as DateUtils from "../../../src/DateUtils";
import { MessageTimestampViewModel } from "../../../src/viewmodels/message-body/MessageTimestampViewModel";
jest.mock("../../../src/settings/SettingsStore");
describe("MessageTimestampViewModel", () => {
// Friday Dec 17 2021, 9:09am
const nowDate = new Date("2021-12-17T08:09:00.000Z");
const HOUR_MS = 3600000;
const DAY_MS = HOUR_MS * 24;
afterEach(() => {
jest.restoreAllMocks();
});
it("should return the snapshot", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
expect(vm.getSnapshot()).toMatchObject({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
});
});
it("should return the snapshot with tsReceivedAt", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
receivedTs: nowDate.getTime() + DAY_MS,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00",
});
});
it("should return the snapshot with extra class names", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
expect(vm.getSnapshot()).toMatchObject({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
className: "mx_MessageTimestamp",
});
});
it("should use formatRelativeTime when showRelative is true", () => {
jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT");
const formatRelativeTimeSpy = jest.spyOn(DateUtils, "formatRelativeTime").mockReturnValue("RELATIVE");
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
showRelative: true,
showTwelveHour: true,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "RELATIVE",
tsSentAt: "SENT_AT",
});
expect(formatRelativeTimeSpy).toHaveBeenCalledWith(expect.any(Date), true);
});
it("should use full date when showFullDate is true and respect showSeconds", () => {
const formatFullDateSpy = jest
.spyOn(DateUtils, "formatFullDate")
.mockImplementation((_date, _showTwelveHour, showSeconds) =>
showSeconds === false ? "FULL_NO_SECONDS" : "SENT_AT",
);
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
showFullDate: true,
showSeconds: false,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "FULL_NO_SECONDS",
tsSentAt: "SENT_AT",
});
expect(formatFullDateSpy).toHaveBeenCalled();
});
it("should use full time when showSeconds is true without full date", () => {
jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT");
const formatFullTimeSpy = jest.spyOn(DateUtils, "formatFullTime").mockReturnValue("FULL_TIME");
const formatTimeSpy = jest.spyOn(DateUtils, "formatTime").mockReturnValue("TIME");
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
showSeconds: true,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "FULL_TIME",
tsSentAt: "SENT_AT",
});
expect(formatFullTimeSpy).toHaveBeenCalled();
expect(formatTimeSpy).not.toHaveBeenCalled();
});
it("should include tooltip inhibition and href in the snapshot", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
inhibitTooltip: true,
href: "https://example.test",
});
expect(vm.getSnapshot()).toMatchObject({
inhibitTooltip: true,
href: "https://example.test",
});
});
});
@@ -0,0 +1,172 @@
/*
* 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 { type MatrixClient, type MatrixEvent, type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import {
ReactionsRowButtonTooltipViewModel,
type ReactionsRowButtonTooltipViewModelProps,
} from "../../../src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel";
import { stubClient, mkStubRoom, mkEvent } from "../../test-utils";
import { unicodeToShortcode } from "../../../src/HtmlUtils";
jest.mock("../../../src/HtmlUtils", () => ({
...jest.requireActual("../../../src/HtmlUtils"),
unicodeToShortcode: jest.fn(),
}));
const mockedUnicodeToShortcode = jest.mocked(unicodeToShortcode);
describe("ReactionsRowButtonTooltipViewModel", () => {
let client: MatrixClient;
let room: Room;
let mxEvent: MatrixEvent;
const createReactionEvent = (senderId: string, content?: Record<string, unknown>): MatrixEvent => {
return mkEvent({
event: true,
type: "m.reaction",
room: room.roomId,
user: senderId,
content: {
"m.relates_to": { rel_type: "m.annotation", event_id: mxEvent.getId(), key: "👍" },
...content,
},
});
};
const createProps = (
overrides?: Partial<ReactionsRowButtonTooltipViewModelProps>,
): ReactionsRowButtonTooltipViewModelProps => ({
client,
mxEvent,
content: "👍",
reactionEvents: [],
customReactionImagesEnabled: false,
...overrides,
});
beforeEach(() => {
client = stubClient();
room = mkStubRoom("!room:example.org", "Test Room", client);
jest.spyOn(client, "getRoom").mockReturnValue(room);
mxEvent = mkEvent({
event: true,
type: "m.room.message",
room: room.roomId,
user: "@sender:example.org",
content: { body: "Test message", msgtype: "m.text" },
});
mockedUnicodeToShortcode.mockImplementation((char: string) => {
if (char === "👍") return ":thumbsup:";
return "";
});
});
afterEach(() => {
jest.restoreAllMocks();
mockedUnicodeToShortcode.mockReset();
});
it("should return undefined snapshot when room is not found", () => {
jest.spyOn(client, "getRoom").mockReturnValue(null);
const vm = new ReactionsRowButtonTooltipViewModel(createProps());
const snapshot = vm.getSnapshot();
expect(snapshot.formattedSenders).toBeUndefined();
expect(snapshot.caption).toBeUndefined();
});
it("should return undefined snapshot when MatrixClient is unavailable", () => {
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ client: null }));
const snapshot = vm.getSnapshot();
expect(snapshot.formattedSenders).toBeUndefined();
expect(snapshot.caption).toBeUndefined();
});
it("should compute formattedSenders and caption from reaction events", () => {
const reactionEvent = createReactionEvent("@alice:example.org");
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] }));
const snapshot = vm.getSnapshot();
expect(snapshot.formattedSenders).toBe("Alice");
expect(snapshot.caption).toContain(":thumbsup:");
});
it("should fall back to sender ID when member is not found", () => {
const reactionEvent = createReactionEvent("@unknown:example.org");
jest.spyOn(room, "getMember").mockReturnValue(null);
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] }));
expect(vm.getSnapshot().formattedSenders).toBe("@unknown:example.org");
});
it("should use custom reaction shortcode when customReactionImagesEnabled is true", () => {
mockedUnicodeToShortcode.mockReturnValue("");
const reactionEvent = createReactionEvent("@alice:example.org", {
"com.beeper.reaction.shortcode": "custom_emoji",
});
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
const vm = new ReactionsRowButtonTooltipViewModel(
createProps({
content: "mxc://custom/emoji",
reactionEvents: [reactionEvent],
customReactionImagesEnabled: true,
}),
);
expect(vm.getSnapshot().caption).toContain("custom_emoji");
});
it("should not use custom reaction shortcode when customReactionImagesEnabled is false", () => {
mockedUnicodeToShortcode.mockReturnValue("");
const reactionEvent = createReactionEvent("@alice:example.org", {
"com.beeper.reaction.shortcode": "custom_emoji",
});
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
const vm = new ReactionsRowButtonTooltipViewModel(
createProps({
content: "mxc://custom/emoji",
reactionEvents: [reactionEvent],
customReactionImagesEnabled: false,
}),
);
expect(vm.getSnapshot().caption).toBeUndefined();
});
it("should update snapshot and notify subscribers when setProps is called", () => {
const aliceReaction = createReactionEvent("@alice:example.org");
const bobReaction = createReactionEvent("@bob:example.org");
jest.spyOn(room, "getMember").mockImplementation((userId) => {
const names: Record<string, string> = { "@alice:example.org": "Alice", "@bob:example.org": "Bob" };
return names[userId!] ? ({ name: names[userId!], userId } as RoomMember) : null;
});
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [aliceReaction] }));
expect(vm.getSnapshot().formattedSenders).toBe("Alice");
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setProps({ reactionEvents: [aliceReaction, bobReaction] });
expect(subscriber).toHaveBeenCalled();
expect(vm.getSnapshot().formattedSenders).toContain("Alice");
expect(vm.getSnapshot().formattedSenders).toContain("Bob");
});
});
@@ -0,0 +1,172 @@
/*
* 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 { DisambiguatedProfileViewModel } from "../../../src/viewmodels/profile/DisambiguatedProfileViewModel";
describe("DisambiguatedProfileViewModel", () => {
const member = {
userId: "@alice:example.org",
roomId: "!room:example.org",
rawDisplayName: "Alice",
disambiguate: true,
};
const nonDisambiguatedMember = {
...member,
disambiguate: false,
};
it("should return the snapshot from props", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
colored: true,
emphasizeDisplayName: true,
withTooltip: true,
});
expect(vm.getSnapshot()).toEqual({
displayName: "Alice",
colorClass: "mx_Username_color3",
className: undefined,
displayIdentifier: "@alice:example.org",
title: "Alice (@alice:example.org)",
emphasizeDisplayName: true,
});
});
it("should default member fields when member is null", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
expect(vm.getSnapshot()).toMatchObject({
displayName: "Fallback",
colorClass: undefined,
className: undefined,
displayIdentifier: undefined,
title: undefined,
emphasizeDisplayName: undefined,
});
});
it("should pass through className prop", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
className: "mx_DisambiguatedProfile",
});
expect(vm.getSnapshot().className).toBe("mx_DisambiguatedProfile");
});
it("should delegate onClick without emitting a snapshot update", () => {
const onClick = jest.fn();
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
onClick,
});
const prevSnapshot = vm.getSnapshot();
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.onClick?.({} as never);
expect(onClick).toHaveBeenCalledTimes(1);
expect(subscriber).not.toHaveBeenCalled();
expect(vm.getSnapshot()).toBe(prevSnapshot);
});
it("should keep onClick bound when extracted as a callback", () => {
const onClick = jest.fn();
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
onClick,
});
const clickHandler = vm.onClick;
expect(() => clickHandler?.({} as never)).not.toThrow();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should emit snapshot update when fallbackName changes", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setMember("Updated");
expect(subscriber).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().displayName).toBe("Updated");
});
it("should emit snapshot update when setMember is called even if fallbackName is unchanged", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setMember("Fallback");
expect(subscriber).toHaveBeenCalledTimes(1);
});
it("should compute tooltip title from constructor props when withTooltip is true", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
withTooltip: true,
});
expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)");
});
it("should compute tooltip title even when disambiguation is not needed", () => {
const vm = new DisambiguatedProfileViewModel({
member: nonDisambiguatedMember,
fallbackName: "Fallback",
withTooltip: true,
});
expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)");
});
it("should emit snapshot update when member changes via setMember", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setMember("Fallback", member);
expect(subscriber).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().displayName).toBe("Alice");
});
it("should emit snapshot update when setMember is called with unchanged member", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
});
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setMember("Fallback", member);
expect(subscriber).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,296 @@
/*
* 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/right-panel/WidgetContextMenuViewModel";
import { stubClient } from "../../test-utils";
import WidgetUtils from "../../../src/utils/WidgetUtils";
import { type IApp } from "../../../src/utils/WidgetUtils-types";
import { Container, 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,
Container.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();
});
});
@@ -0,0 +1,315 @@
/*
* 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 { mocked } from "jest-mock";
import { JoinRule, type MatrixClient, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { RoomListHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListHeaderViewModel";
import { MetaSpace, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../../src/stores/spaces";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SortingAlgorithm } from "../../../src/stores/room-list-v3/skip-list/sorters";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
import {
shouldShowSpaceSettings,
showCreateNewRoom,
showSpaceInvite,
showSpacePreferences,
showSpaceSettings,
} from "../../../src/utils/space";
import { createTestClient, mkSpace } from "../../test-utils";
import { createRoom, hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
import PosthogTrackers from "../../../src/PosthogTrackers";
jest.mock("../../../src/PosthogTrackers", () => ({
trackInteraction: jest.fn(),
}));
jest.mock("../../../src/utils/space", () => ({
shouldShowSpaceSettings: jest.fn(),
showCreateNewRoom: jest.fn(),
showSpaceInvite: jest.fn(),
showSpacePreferences: jest.fn(),
showSpaceSettings: jest.fn(),
}));
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
createRoom: jest.fn(),
hasCreateRoomRights: jest.fn(),
}));
describe("RoomListHeaderViewModel", () => {
let matrixClient: MatrixClient;
let mockSpace: Room;
let vm: RoomListHeaderViewModel;
beforeEach(() => {
matrixClient = createTestClient();
mockSpace = mkSpace(matrixClient, "!space:server");
mocked(hasCreateRoomRights).mockReturnValue(true);
mocked(shouldShowSpaceSettings).mockReturnValue(true);
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
if (settingName === "feature_video_rooms") return true;
if (settingName === "feature_element_call_video_rooms") return true;
return false;
});
});
afterEach(() => {
jest.restoreAllMocks();
vm.dispose();
});
describe("snapshot", () => {
it("should compute snapshot for Home space", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
const snapshot = vm.getSnapshot();
expect(snapshot.title).toBe("Home");
expect(snapshot.displayComposeMenu).toBe(true);
expect(snapshot.displaySpaceMenu).toBe(false);
expect(snapshot.canCreateRoom).toBe(true);
expect(snapshot.canCreateVideoRoom).toBe(true);
expect(snapshot.activeSortOption).toBe("recent");
});
it("should compute snapshot for active space", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
const snapshot = vm.getSnapshot();
expect(snapshot.title).toBe(mockSpace.roomId);
});
it("should hide video room option when feature is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "feature_video_rooms") return false;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canCreateVideoRoom).toBe(false);
});
it("should show alphabetical sort option when RoomList.preferredSorting is Alphabetic", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Alphabetic;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().activeSortOption).toBe("alphabetical");
});
it("should hide compose menu when user cannot create rooms", () => {
mocked(hasCreateRoomRights).mockReturnValue(false);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
const snapshot = vm.getSnapshot();
expect(snapshot.displayComposeMenu).toBe(false);
expect(snapshot.canCreateRoom).toBe(false);
});
it("should show invite option when space is public", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
jest.spyOn(mockSpace, "getJoinRule").mockReturnValue(JoinRule.Public);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canInviteInSpace).toBe(true);
});
it("should hide invite option when user cannot invite", () => {
mocked(mockSpace.canInvite).mockReturnValue(false);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canInviteInSpace).toBe(false);
});
it("should hide space settings when user cannot access them", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
mocked(shouldShowSpaceSettings).mockReturnValue(false);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().canAccessSpaceSettings).toBe(false);
});
it("should show message preview when RoomList.showMessagePreview is enabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.showMessagePreview") return true;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(true);
});
});
describe("event listeners", () => {
it.each([UPDATE_SELECTED_SPACE, UPDATE_HOME_BEHAVIOUR])(
"should update snapshot when %s event is emitted",
(event) => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
SpaceStore.instance.emit(event);
expect(vm.getSnapshot().title).toBe(mockSpace.roomId);
},
);
it("should update snapshot when space name changes", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
mockSpace.name = "new name";
mockSpace.emit(RoomEvent.Name, mockSpace);
expect(vm.getSnapshot().title).toBe("new name");
});
});
describe("actions", () => {
beforeEach(() => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
});
it("should fire CreateChat action when createChatRoom is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createChatRoom(new Event("click"));
expect(fireSpy).toHaveBeenCalledWith(Action.CreateChat);
});
it("should call createRoom with active space when in a space", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createRoom(new Event("click"));
expect(createRoom).toHaveBeenCalledWith(mockSpace);
});
it("should show create video room dialog for space when createVideoRoom is called", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "feature_element_call_video_rooms") return false;
return false;
});
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createVideoRoom();
expect(showCreateNewRoom).toHaveBeenCalledWith(mockSpace, RoomType.ElementVideo);
});
it("should use UnstableCall type when element_call_video_rooms is enabled", () => {
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.createVideoRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CreateRoom,
type: RoomType.UnstableCall,
});
});
it("should dispatch ViewRoom action when openSpaceHome is called", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.openSpaceHome();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "!space:server",
metricsTrigger: undefined,
});
});
it("should show space invite dialog when inviteInSpace is called", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.inviteInSpace();
expect(showSpaceInvite).toHaveBeenCalledWith(mockSpace);
});
it("should show space preferences dialog when openSpacePreferences is called", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.openSpacePreferences();
expect(showSpacePreferences).toHaveBeenCalledWith(mockSpace);
});
it("should show space settings dialog when openSpaceSettings is called", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.openSpaceSettings();
expect(showSpaceSettings).toHaveBeenCalledWith(mockSpace);
});
it.each([
["recent" as const, SortingAlgorithm.Recency],
["alphabetical" as const, SortingAlgorithm.Alphabetic],
["unread-first" as const, SortingAlgorithm.Unread],
])("should resort when sort is called with '%s'", (option, expectedAlgorithm) => {
const resortSpy = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(jest.fn());
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.sort(option);
expect(resortSpy).toHaveBeenCalledWith(expectedAlgorithm);
});
it("should track analytics on resort", () => {
jest.spyOn(RoomListStoreV3.instance, "activeSortAlgorithm", "get").mockReturnValue(
SortingAlgorithm.Alphabetic,
);
PosthogTrackers.trackRoomListSortingAlgorithmChange = jest.fn();
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(jest.fn());
vm.sort("unread-first");
expect(PosthogTrackers.trackRoomListSortingAlgorithmChange).toHaveBeenCalledWith(
SortingAlgorithm.Alphabetic,
SortingAlgorithm.Unread,
);
});
it("should toggle message preview from enabled to disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.showMessagePreview") return true;
return false;
});
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockImplementation(jest.fn());
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(true);
vm.toggleMessagePreview();
expect(setValueSpy).toHaveBeenCalledWith("RoomList.showMessagePreview", null, expect.anything(), false);
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(false);
});
});
});
@@ -0,0 +1,439 @@
/*
* 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 { type MatrixClient, type MatrixEvent, Room, RoomEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { createTestClient, flushPromises } from "../../test-utils";
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/room-list/MessagePreviewStore";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import SettingsStore from "../../../src/settings/SettingsStore";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../src/stores/room-list/models";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { CallStore } from "../../../src/stores/CallStore";
import type { Call } from "../../../src/models/Call";
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
}));
jest.mock("../../../src/stores/CallStore", () => ({
__esModule: true,
CallStore: {
instance: {
getCall: jest.fn(),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
},
CallStoreEvent: {
ConnectedCalls: "connected_calls",
},
}));
describe("RoomListItemViewModel", () => {
let matrixClient: MatrixClient;
let room: Room;
let notificationState: RoomNotificationState;
let viewModel: RoomListItemViewModel;
beforeEach(() => {
matrixClient = createTestClient();
room = new Room("!room:server", matrixClient, matrixClient.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
// Set room name
room.name = "Test Room";
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
const dmRoomMap = {
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showMessagePreview") return false;
return false;
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
});
afterEach(() => {
viewModel?.dispose();
jest.restoreAllMocks();
});
describe("Initialization", () => {
it("should initialize with room data", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Wait for async initialization
await flushPromises();
const snapshot = viewModel.getSnapshot();
expect(snapshot.id).toBe("!room:server");
expect(snapshot.name).toBe("Test Room");
});
it("should load message preview when enabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Hello world!",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Wait for async message preview load
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Hello world!");
});
it("should not load message preview when disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
});
});
describe("Notification state", () => {
it("should reflect notification state", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(5);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
const snapshot = viewModel.getSnapshot();
expect(snapshot.notification.hasAnyNotificationOrActivity).toBe(true);
expect(snapshot.notification.count).toBe(5);
});
it("should update when notification state changes", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(0);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
notificationState.emit(NotificationStateEvents.Update);
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(3);
});
it("should show bold text when has notifications", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isBold).toBe(true);
});
it("should show mention badge", async () => {
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.isMention).toBe(true);
});
it("should show invitation state", async () => {
jest.spyOn(notificationState, "invited", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.invited).toBe(true);
});
});
describe("Message preview", () => {
it("should update message preview when store emits update", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Initial message",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Initial message");
// Update preview
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Updated message",
} as MessagePreview);
MessagePreviewStore.instance.emit(UPDATE_EVENT);
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
});
it("should show/hide preview when setting changes", async () => {
let showPreview = false;
let watchCallback: any;
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => {
watchCallback = callback;
return "watcher-id";
});
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Test message",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
// Enable previews
showPreview = true;
watchCallback(null, "device", true);
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Test message");
});
});
describe("Room tags", () => {
it("should reflect favorite tag", async () => {
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(true);
});
it("should reflect low priority tag", async () => {
room.tags = { [DefaultTagID.LowPriority]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isLowPriority).toBe(true);
});
it("should update when room tags change", async () => {
room.tags = {};
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(false);
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
const tagEvent = {
getContent: () => ({ tags: { [DefaultTagID.Favourite]: { order: 0 } } }),
} as MatrixEvent;
room.emit(RoomEvent.Tags, tagEvent, room);
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(true);
});
});
describe("Call state", () => {
it("should show voice call indicator", async () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()!, {}]]),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should show video call indicator", async () => {
const mockCall = {
callType: CallType.Video,
participants: new Map([[matrixClient.getUserId()!, {}]]),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBe("video");
});
it("should not show call indicator when no participants", async () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
});
});
describe("Room name updates", () => {
it("should update when room name changes", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().name).toBe("Test Room");
room.name = "Updated Room";
room.emit(RoomEvent.Name, room);
await flushPromises();
expect(viewModel.getSnapshot().name).toBe("Updated Room");
});
});
describe("DM detection", () => {
it("should detect DM rooms", async () => {
const dmRoomMap = DMRoomMap.shared();
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue("@user:server");
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
// DM rooms should not show copy room link option
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(false);
});
it("should detect non-DM rooms", async () => {
const dmRoomMap = DMRoomMap.shared();
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue(undefined);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(true);
});
});
describe("Actions", () => {
it("should dispatch view room action on openRoom", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onOpenRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "!room:server",
metricsTrigger: "RoomList",
});
});
it("should return room object", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().room).toBe(room);
});
it("should dispatch view_invite action when onInvite is called", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onInvite();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "view_invite",
roomId: "!room:server",
});
});
it("should dispatch copy_room action when onCopyRoomLink is called", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onCopyRoomLink();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "copy_room",
room_id: "!room:server",
});
});
it("should dispatch leave_room action when onLeaveRoom is called for normal room", () => {
room.tags = {};
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onLeaveRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "leave_room",
room_id: "!room:server",
});
});
it("should dispatch forget_room action when onLeaveRoom is called for archived room", () => {
room.tags = { [DefaultTagID.Archived]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onLeaveRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "forget_room",
room_id: "!room:server",
});
});
});
describe("Cleanup", () => {
it("should unsubscribe from all events on dispose", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const offSpy = jest.spyOn(notificationState, "off");
viewModel.dispose();
expect(offSpy).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,114 @@
/*
* 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 { mocked } from "jest-mock";
import { RoomListSearchViewModel } from "../../../src/viewmodels/room-list/RoomListSearchViewModel";
import { MetaSpace } from "../../../src/stores/spaces";
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../src/LegacyCallHandler";
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
jest.mock("../../../src/PosthogTrackers", () => ({
trackInteraction: jest.fn(),
}));
describe("RoomListSearchViewModel", () => {
beforeEach(() => {
mocked(shouldShowComponent).mockReturnValue(true);
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("snapshot", () => {
it("should show explore button in Home space when UIComponent.ExploreRooms is enabled", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayExploreButton).toBe(true);
});
it("should hide explore button when not in Home space", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.VideoRooms });
expect(vm.getSnapshot().displayExploreButton).toBe(false);
});
it("should hide explore button when UIComponent.ExploreRooms is disabled", () => {
mocked(shouldShowComponent).mockReturnValue(false);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayExploreButton).toBe(false);
});
it("should show dial button when PSTN protocol is supported", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayDialButton).toBe(true);
});
it("should hide dial button when PSTN protocol is not supported", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayDialButton).toBe(false);
});
});
describe("actions", () => {
it("should fire OpenSpotlight action when onSearchClick is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
vm.onSearchClick();
expect(fireSpy).toHaveBeenCalledWith(Action.OpenSpotlight);
});
it("should fire OpenDialPad action when onDialPadClick is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
vm.onDialPadClick();
expect(fireSpy).toHaveBeenCalledWith(Action.OpenDialPad);
});
it("should fire ViewRoomDirectory action and track interaction when onExploreClick is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
const mockEvent = {} as React.MouseEvent<HTMLButtonElement>;
vm.onExploreClick(mockEvent);
expect(fireSpy).toHaveBeenCalledWith(Action.ViewRoomDirectory);
});
});
it("should update snapshot when PSTN protocol support changes", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayDialButton).toBe(false);
// Simulate PSTN protocol support change
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
LegacyCallHandler.instance.emit(LegacyCallHandlerEvent.ProtocolSupport);
expect(vm.getSnapshot().displayDialButton).toBe(true);
vm.dispose();
});
});
@@ -0,0 +1,546 @@
/*
* 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 { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { FilterKey } from "../../../src/stores/room-list-v3/skip-list/filters";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { RoomListViewViewModel } from "../../../src/viewmodels/room-list/RoomListViewViewModel";
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
}));
describe("RoomListViewViewModel", () => {
let matrixClient: MatrixClient;
let room1: Room;
let room2: Room;
let room3: Room;
let viewModel: RoomListViewViewModel;
beforeEach(() => {
matrixClient = createTestClient();
room1 = mkStubRoom("!room1:server", "Room 1", matrixClient);
room2 = mkStubRoom("!room2:server", "Room 2", matrixClient);
room3 = mkStubRoom("!room3:server", "Room 3", matrixClient);
// Setup DMRoomMap
const dmRoomMap = {
getUserIdForRoomId: jest.fn().mockReturnValue(null),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3],
});
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
mocked(hasCreateRoomRights).mockReturnValue(false);
});
afterEach(() => {
viewModel?.dispose();
jest.restoreAllMocks();
});
describe("Initialization", () => {
it("should initialize with correct snapshot", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const snapshot = viewModel.getSnapshot();
expect(snapshot.roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
expect(snapshot.isRoomListEmpty).toBe(false);
expect(snapshot.isLoadingRooms).toBe(false);
expect(snapshot.roomListState.spaceId).toBe("home");
expect(snapshot.filterIds.length).toBeGreaterThan(0);
expect(snapshot.activeFilterId).toBeUndefined();
});
it("should initialize with empty room list", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [],
});
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().roomIds).toEqual([]);
expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
});
it("should set canCreateRoom based on user rights", () => {
mocked(hasCreateRoomRights).mockReturnValue(true);
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().canCreateRoom).toBe(true);
});
});
describe("Room list updates", () => {
it("should update room list when ListsUpdate event fires", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3, newRoom],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(viewModel.getSnapshot().roomIds).toEqual([
"!room1:server",
"!room2:server",
"!room3:server",
"!room4:server",
]);
});
it("should update loading state when ListsLoaded event fires", () => {
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true);
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().isLoadingRooms).toBe(true);
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsLoaded);
expect(viewModel.getSnapshot().isLoadingRooms).toBe(false);
});
});
describe("Space switching", () => {
it("should update room list when space changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const spaceRoomList = [room1, room2];
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: spaceRoomList,
});
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue("!room1:server");
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(viewModel.getSnapshot().roomListState.spaceId).toBe("!space:server");
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server"]);
});
it("should clear view models when space changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Get view models for visible rooms
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy2 = jest.spyOn(vm2, "dispose");
// Change space
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: [room3],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy2).toHaveBeenCalled();
});
});
describe("Active room tracking", () => {
it("should update active room index when room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
oldRoomId: "!room1:server",
newRoomId: "!room2:server",
});
// Use setTimeout to allow the dispatcher callback to run
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
});
it("should return undefined active room index when no room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
oldRoomId: "!room1:server",
newRoomId: null,
});
// Use setTimeout to allow the dispatcher callback to run
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBeUndefined();
});
});
describe("Sticky room behavior", () => {
it("should keep selected room at same index when room list updates", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Select room at index 1
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
newRoomId: "!room2:server",
});
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
// Simulate room list update that would move room2 to front
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room2, room1, room3], // room2 moved to front
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
// Active room should still be at index 1 (sticky behavior)
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
expect(viewModel.getSnapshot().roomIds[1]).toBe("!room2:server");
});
it("should not apply sticky behavior when user changes rooms", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Select room at index 1
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
newRoomId: "!room2:server",
});
await flushPromises();
// User switches to room3
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room3:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
oldRoomId: "!room2:server",
newRoomId: "!room3:server",
});
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(2);
});
});
describe("Filters", () => {
it("should toggle filter on", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1],
filterKeys: [FilterKey.UnreadFilter],
});
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server"]);
});
it("should toggle filter off", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Turn filter on
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1],
filterKeys: [FilterKey.UnreadFilter],
});
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
// Turn filter off
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3],
});
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
});
it("should clear view models when filter changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Get view models
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const disposeSpy = jest.spyOn(vm1, "dispose");
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room2],
filterKeys: [FilterKey.UnreadFilter],
});
viewModel.onToggleFilter("unread");
expect(disposeSpy).toHaveBeenCalled();
});
});
describe("Room item view models", () => {
it("should create room item view model on demand", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const itemViewModel = viewModel.getRoomItemViewModel("!room1:server");
expect(itemViewModel).toBeDefined();
expect(itemViewModel.getSnapshot().room).toBe(room1);
});
it("should reuse existing room item view model", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server");
const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server");
expect(itemViewModel1).toBe(itemViewModel2);
});
it("should throw error when requesting view model for non-existent room", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(() => {
viewModel.getRoomItemViewModel("!nonexistent:server");
}).toThrow();
});
it("should dispose view models for rooms no longer visible", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const vm3 = viewModel.getRoomItemViewModel("!room3:server");
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy3 = jest.spyOn(vm3, "dispose");
// Update to show only middle room (index 1)
viewModel.updateVisibleRooms(1, 2);
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy3).toHaveBeenCalled();
// vm2 should still exist
const vm2Again = viewModel.getRoomItemViewModel("!room2:server");
expect(vm2Again).toBe(vm2);
});
});
describe("Room creation", () => {
it("should dispatch CreateChat action when createChatRoom is called", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "fire");
viewModel.createChatRoom();
expect(dispatchSpy).toHaveBeenCalledWith(Action.CreateChat);
});
it("should dispatch CreateRoom action without parent space", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.createRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CreateRoom,
});
});
it("should dispatch CreateRoom action with parent space", () => {
const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom);
viewModel = new RoomListViewViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.createRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CreateRoom,
parent_space: spaceRoom,
});
});
});
describe("Keyboard navigation (ViewRoomDelta)", () => {
beforeEach(() => {
// stubClient sets up MatrixClientPeg which is needed when ViewRoom action is dispatched
stubClient();
});
it("should navigate to next room when delta is 1", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: "!room2:server",
}),
);
});
it("should navigate to previous room when delta is -1", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: "!room1:server",
}),
);
});
it("should wrap around to last room when navigating backwards from first room", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: "!room3:server",
}),
);
});
it("should not navigate when current room is not found", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatchSpy.mockClear();
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await flushPromises();
// Should not dispatch ViewRoom since current room wasn't found
expect(dispatchSpy).not.toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
}),
);
});
it("should not navigate when no room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatchSpy.mockClear();
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).not.toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
}),
);
});
});
describe("Cleanup", () => {
it("should dispose all room item view models on dispose", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy2 = jest.spyOn(vm2, "dispose");
viewModel.dispose();
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy2).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,78 @@
/*
* 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 { mocked } from "jest-mock";
import type { MatrixClient, Room, RoomState } from "matrix-js-sdk/src/matrix";
import { createTestClient, mkStubRoom } from "../../test-utils";
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { showCreateNewRoom } from "../../../src/utils/space";
import { hasCreateRoomRights, createRoom, hasAccessToNotificationMenu } from "../../../src/viewmodels/room-list/utils";
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
jest.mock("../../../src/utils/space", () => ({
showCreateNewRoom: jest.fn(),
}));
describe("utils", () => {
let matrixClient: MatrixClient;
let space: Room;
beforeEach(() => {
matrixClient = createTestClient();
space = mkStubRoom("spaceId", "spaceName", matrixClient);
});
describe("createRoom", () => {
it("should fire Action.CreateRoom when createRoom is called without a space", async () => {
const spy = jest.spyOn(defaultDispatcher, "fire");
await createRoom();
expect(spy).toHaveBeenCalledWith(Action.CreateRoom);
});
it("should call showCreateNewRoom when createRoom is called in a space", async () => {
await createRoom(space);
expect(showCreateNewRoom).toHaveBeenCalledWith(space);
});
});
describe("hasCreateRoomRights", () => {
it("should return false when UIComponent.CreateRooms is disabled", () => {
mocked(shouldShowComponent).mockReturnValue(false);
expect(hasCreateRoomRights(matrixClient, space)).toBe(false);
});
it("should return true when UIComponent.CreateRooms is enabled and no space", () => {
mocked(shouldShowComponent).mockReturnValue(true);
expect(hasCreateRoomRights(matrixClient)).toBe(true);
});
it("should return false in space when UIComponent.CreateRooms is enabled and the user doesn't have the rights", () => {
mocked(shouldShowComponent).mockReturnValue(true);
jest.spyOn(space.getLiveTimeline(), "getState").mockReturnValue({
maySendStateEvent: jest.fn().mockReturnValue(true),
} as unknown as RoomState);
expect(hasCreateRoomRights(matrixClient)).toBe(true);
});
});
it("hasAccessToNotificationMenu", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const room = mkStubRoom("roomId", "roomName", matrixClient);
const isGuest = false;
const isArchived = false;
expect(hasAccessToNotificationMenu(room, isGuest, isArchived)).toBe(true);
});
});
@@ -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 });
});
});
});