Files
ThreadNet-Web/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx
T
Florian DurosandGitHub 7ff6956014 Room list: add new settings to enable/disable the sections (#34151)
* Add new settings to enable/disable the sections

* Add `RoomList.showSections` settings to preference user settings

* Hide sections ui in room list header

- Hide the collapse/expand button and the *new section* entry in the
menu.
- Display the *start chat* button instead of the compose menu when the
only action available is to create a DM

* Hide sections ui in context menu of room item

* Listen to show section settings in room list item vm

* Listen to show sections setting in room list header vm

* Put all the rooms inside the chat section when sections are disabled

When the chat section is the only section, the room list is in "flat
list mode". Putting all the rooms inside the chat section.

The section filters (aka custom section, favourites etc) are not passed
to the skip list.

* Update preferences settings screenshot

* Add e2e test for RoomList.showSections toggle

* Hide "Chat moved" toast when a room is tagged when RoomList.showSections is disabled
2026-07-07 08:45:37 +00:00

711 lines
28 KiB
TypeScript

/*
* 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 EventEmitter from "events";
import {
type MatrixClient,
type MatrixEvent,
Room,
RoomEvent,
PendingEventOrdering,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { waitFor } from "jest-matrix-react";
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/message-preview";
import SettingsStore, { type CallbackFn } from "../../../src/settings/SettingsStore";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { CallStore } from "../../../src/stores/CallStore";
import { CallEvent, type Call } from "../../../src/models/Call";
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
import * as tagRoomModule from "../../../src/utils/room/tagRoom";
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
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;
if (setting === "RoomList.OrderedCustomSections") return [];
if (setting === "RoomList.CustomSectionData") return {};
return false;
});
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([]);
});
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(MessagePreviewStore.getPreviewChangedEventName(room));
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) => {
if (setting === "RoomList.showMessagePreview") 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()!, {}]]),
off: jest.fn(),
on: jest.fn(),
} 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()!, {}]]),
off: jest.fn(),
on: jest.fn(),
} 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(),
off: jest.fn(),
on: jest.fn(),
} 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();
});
it("should listen to call participant changes", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
// Get the callback registered for call state changes
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
// Simulate participant joining
mockCall.participants.set(matrixClient.getUserId()! as unknown as RoomMember, new Set());
// Get the callback registered for participant changes
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should not update the item when there is already an active call and participants join", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Trigger onCallStateChanged so the call is tracked and the participant listener is registered
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
// Record the snapshot version before the participant event fires
const snapshotBefore = viewModel.getSnapshot();
// Simulate another participant joining while the call is already active
mockCall.participants.set("@other:server" as unknown as RoomMember, new Set<string>());
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback(mockCall.participants);
// Snapshot should not have changed
expect(viewModel.getSnapshot()).toBe(snapshotBefore);
});
it("should react to participant changes when a call already exists at instantiation time", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
// Simulate participant joining
mockCall.participants.set(matrixClient.getUserId()! as unknown as RoomMember, new Set());
// Get the callback registered for participant changes
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should unsubscribe from old call participants when the call changes", () => {
const firstCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
const secondCall = {
callType: CallType.Video,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(firstCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Trigger onCallStateChanged to register the first call
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
const participantsCallback = firstCall.on.mock.calls[0][1];
expect(firstCall.on).toHaveBeenCalledWith("participants", participantsCallback);
// Now switch to a different call
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(secondCall as unknown as Call);
callStateCallback();
// The old call's listener must have been removed
expect(firstCall.off).toHaveBeenCalledWith("participants", participantsCallback);
// The new call must have a listener registered
expect(secondCall.on).toHaveBeenCalledWith("participants", expect.any(Function));
});
it("should listen to call type changes", async () => {
// Start with a voice call
let callType = CallType.Voice;
const mockCall = new (class extends EventEmitter {
get callType() {
return callType;
}
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");
// Now turn it into a video call
callType = CallType.Video;
mockCall.emit(CallEvent.CallTypeChanged, callType);
expect(viewModel.getSnapshot().notification.callType).toBe("video");
});
});
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",
});
});
it("should call createSection on RoomListStoreV3 when onCreateSection is called", async () => {
const createSectionSpy = jest
.spyOn(RoomListStoreV3.instance, "createSection")
.mockResolvedValue("element.io.section.work");
const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
viewModel.onCreateSection();
expect(createSectionSpy).toHaveBeenCalled();
await waitFor(() => expect(tagRoomSpy).toHaveBeenCalledWith(room, "element.io.section.work"));
});
it("should call tagRoom when onToggleSection is called", () => {
const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
viewModel.onToggleSection(DefaultTagID.Favourite);
expect(tagRoomSpy).toHaveBeenCalledWith(room, DefaultTagID.Favourite);
});
});
describe("Sections", () => {
const customTag = "element.io.section.custom1";
beforeEach(() => {
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
DefaultTagID.Favourite,
customTag,
CHATS_TAG,
DefaultTagID.LowPriority,
]);
});
it("should include sections from orderedSectionTags excluding CHATS_TAG, favourite, and low priority", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const sections = viewModel.getSnapshot().sections;
expect(sections.map((s) => s.tag)).toEqual([customTag]);
});
it("should mark the room current section as selected", () => {
room.tags = { [customTag]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const sections = viewModel.getSnapshot().sections;
expect(sections.find((s) => s.tag === customTag)?.isSelected).toBe(true);
});
it("should use custom section name from CustomSectionData", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.CustomSectionData")
return { [customTag]: { name: "My Custom Section", tag: customTag } };
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const section = viewModel.getSnapshot().sections.find((s) => s.tag === customTag);
expect(section?.name).toBe("My Custom Section");
});
it("should update sections when OrderedCustomSections setting changes", () => {
let watchCallback: CallbackFn<"RoomList.OrderedCustomSections"> = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.OrderedCustomSections") watchCallback = callback;
return "watcher-id";
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().sections).toHaveLength(1);
// Simulate reordering: custom section removed
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
DefaultTagID.Favourite,
CHATS_TAG,
DefaultTagID.LowPriority,
]);
watchCallback("RoomList.OrderedCustomSections", null, null as any, null, null);
expect(viewModel.getSnapshot().sections.map((s) => s.tag)).toEqual([]);
});
it("should set areSectionsEnabled to true when RoomList.showSections is enabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showSections") return true;
return false;
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().areSectionsEnabled).toBe(true);
});
it("should update areSectionsEnabled when RoomList.showSections setting changes", () => {
let watchCallback: CallbackFn<"RoomList.showSections"> = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.showSections") watchCallback = callback;
return "watcher-id";
});
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().areSectionsEnabled).toBe(false);
// Enable sections
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showSections") return true;
return false;
});
watchCallback("RoomList.showSections", null, null as any, null, null);
expect(viewModel.getSnapshot().areSectionsEnabled).toBe(true);
});
});
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();
});
});
});