/* * 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 "node: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 and participant data", 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"); expect(viewModel.getSnapshot().callParticipants).toEqual( expect.arrayContaining([ expect.objectContaining({ userId: matrixClient.getUserId()!, displayName: matrixClient.getUserId()!, }), ]), ); }); 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 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()]]), 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"); // Simulate another participant joining while the call is already active mockCall.participants.set("@other:server" as unknown as RoomMember, new Set()); const participantsChangeCallback = mockCall.on.mock.calls[0][1]; participantsChangeCallback(mockCall.participants); expect(viewModel.getSnapshot().callParticipants).toEqual( expect.arrayContaining([ expect.objectContaining({ userId: "@other:server", displayName: "@other:server" }), ]), ); }); 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()]]), off: jest.fn(), on: jest.fn(), }; const secondCall = { callType: CallType.Video, participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set()]]), 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(); }); }); });