diff --git a/src/components/viewmodels/roomlist/RoomListViewViewModel.ts b/src/components/viewmodels/roomlist/RoomListViewViewModel.ts new file mode 100644 index 0000000000..25f3cde1dc --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListViewViewModel.ts @@ -0,0 +1,450 @@ +/* +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 { + BaseViewModel, + type RoomListSnapshot, + type FilterId, + type RoomListViewActions, + type RoomListViewState, +} from "@element-hq/web-shared-components"; +import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; + +import { Action } from "../../../dispatcher/actions"; +import dispatcher from "../../../dispatcher/dispatcher"; +import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; +import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import SpaceStore from "../../../stores/spaces/SpaceStore"; +import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3"; +import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; +import { hasCreateRoomRights } from "./utils"; +import { RoomListItemViewModel } from "./RoomListItemViewModel"; +import { SdkContextClass } from "../../../contexts/SDKContext"; + +interface RoomListViewViewModelProps { + client: MatrixClient; +} + +const filterKeyToIdMap: Map = new Map([ + [FilterKey.UnreadFilter, "unread"], + [FilterKey.PeopleFilter, "people"], + [FilterKey.RoomsFilter, "rooms"], + [FilterKey.FavouriteFilter, "favourite"], + [FilterKey.MentionsFilter, "mentions"], + [FilterKey.InvitesFilter, "invites"], + [FilterKey.LowPriorityFilter, "low_priority"], +]); + +export class RoomListViewViewModel + extends BaseViewModel + implements RoomListViewActions +{ + // State tracking + private activeFilter: FilterKey | undefined = undefined; + private roomsResult: RoomsResult; + private lastActiveRoomIndex: number | undefined = undefined; + + // Child view model management + private roomItemViewModels = new Map(); + private roomsMap = new Map(); + + public constructor(props: RoomListViewViewModelProps) { + const activeSpace = SpaceStore.instance.activeSpaceRoom; + + // Get initial rooms + const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined); + const canCreateRoom = hasCreateRoomRights(props.client, activeSpace); + const filterIds = [...filterKeyToIdMap.values()]; + + super(props, { + // Initial view state - start with empty, will populate in async init + isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms, + isRoomListEmpty: roomsResult.rooms.length === 0, + filterIds, + activeFilterId: undefined, + roomListState: { + activeRoomIndex: undefined, + spaceId: roomsResult.spaceId, + filterKeys: undefined, + }, + roomIds: roomsResult.rooms.map((room) => room.roomId), + canCreateRoom, + }); + + this.roomsResult = roomsResult; + + // Build initial roomsMap from roomsResult + this.updateRoomsMap(roomsResult); + + // Subscribe to room list updates + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.ListsUpdate as any, + this.onListsUpdate, + ); + + // Subscribe to room list loaded + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.ListsLoaded as any, + this.onListsLoaded, + ); + + // Subscribe to active room changes to update selected room + const dispatcherRef = dispatcher.register(this.onDispatch); + this.disposables.track(() => { + dispatcher.unregister(dispatcherRef); + }); + + // Track cleanup of all child view models + this.disposables.track(() => { + for (const viewModel of this.roomItemViewModels.values()) { + viewModel.dispose(); + } + this.roomItemViewModels.clear(); + }); + } + + public onToggleFilter = (filterId: FilterId): void => { + // Find the FilterKey by matching the filter ID + let filterKey: FilterKey | undefined = undefined; + for (const [key, id] of filterKeyToIdMap.entries()) { + if (id === filterId) { + filterKey = key; + break; + } + } + + if (filterKey === undefined) return; + + // Toggle the filter - if it's already active, deactivate it + const newFilter = this.activeFilter === filterKey ? undefined : filterKey; + this.activeFilter = newFilter; + + // Update rooms result with new filter + const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined; + this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys); + + // Update roomsMap immediately before clearing VMs + this.updateRoomsMap(this.roomsResult); + + // Clear view models since room list changed + this.clearViewModels(); + + this.updateRoomListData(); + }; + + /** + * Rebuild roomsMap when roomsResult changes. + * This maintains a quick lookup for room objects. + */ + private updateRoomsMap(roomsResult: RoomsResult): void { + this.roomsMap.clear(); + for (const room of roomsResult.rooms) { + this.roomsMap.set(room.roomId, room); + } + } + + /** + * Clear all child view models. + * Called when the room list structure changes (space change, filter change, etc.) + */ + private clearViewModels(): void { + for (const viewModel of this.roomItemViewModels.values()) { + viewModel.dispose(); + } + this.roomItemViewModels.clear(); + } + + /** + * Get the ordered list of room IDs. + */ + public get roomIds(): string[] { + return this.roomsResult.rooms.map((room) => room.roomId); + } + + /** + * Get a RoomListItemViewModel for a specific room. + * Creates a RoomListItemViewModel if needed, which manages per-room subscriptions. + * The view should call this only for visible rooms from the roomIds list. + * @throws Error if room is not found in roomsMap (indicates a programming error) + */ + public getRoomItemViewModel(roomId: string): RoomListItemViewModel { + // Check if we have a view model for this room + let viewModel = this.roomItemViewModels.get(roomId); + + if (!viewModel) { + const room = this.roomsMap.get(roomId); + if (!room) { + throw new Error(`Room ${roomId} not found in roomsMap`); + } + + // Create new view model + viewModel = new RoomListItemViewModel({ + room, + client: this.props.client, + }); + + this.roomItemViewModels.set(roomId, viewModel); + } + + // Return the view model - the view will call useViewModel() on it + return viewModel; + } + + /** + * Update which rooms are currently visible. + * Called by the view when scroll position changes. + * Disposes of view models for rooms no longer visible. + */ + public updateVisibleRooms(startIndex: number, endIndex: number): void { + const allRoomIds = this.roomIds; + const newVisibleIds = allRoomIds.slice(startIndex, Math.min(endIndex, allRoomIds.length)); + + const newVisibleSet = new Set(newVisibleIds); + + // Dispose view models for rooms no longer visible + for (const [roomId, viewModel] of this.roomItemViewModels.entries()) { + if (!newVisibleSet.has(roomId)) { + viewModel.dispose(); + this.roomItemViewModels.delete(roomId); + } + } + } + + private onDispatch = (payload: any): void => { + if (payload.action === Action.ActiveRoomChanged) { + // When the active room changes, update the room list data to reflect the new selected room + // Pass isRoomChange=true so sticky logic doesn't prevent the index from updating + this.updateRoomListData(true); + } else if (payload.action === Action.ViewRoomDelta) { + // Handle keyboard navigation shortcuts (Alt+ArrowUp/Down) + // This was previously handled by useRoomListNavigation hook + this.handleViewRoomDelta(payload as ViewRoomDeltaPayload); + } + }; + + /** + * Handle keyboard navigation shortcuts (Alt+ArrowUp/Down) to move between rooms. + * Supports both regular navigation and unread-only navigation. + * Migrated from useRoomListNavigation hook. + */ + private handleViewRoomDelta(payload: ViewRoomDeltaPayload): void { + const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!currentRoomId) return; + + const { delta, unread } = payload; + const rooms = this.roomsResult.rooms; + + const filteredRooms = unread + ? // Filter the rooms to only include unread ones and the active room + rooms.filter((room) => { + const state = RoomNotificationStateStore.instance.getRoomState(room); + return room.roomId === currentRoomId || state.isUnread; + }) + : rooms; + + const currentIndex = filteredRooms.findIndex((room) => room.roomId === currentRoomId); + if (currentIndex === -1) return; + + // Get the next/previous new room according to the delta + // Use slice to loop on the list + // If delta is -1 at the start of the list, it will go to the end + // If delta is 1 at the end of the list, it will go to the start + const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length); + if (!newRoom) return; + + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: newRoom.roomId, + show_room_tile: true, // to make sure the room gets scrolled into view + metricsTrigger: "WebKeyboardShortcut", + metricsViaKeyboard: true, + }); + } + + /** + * Handle room list updates from RoomListStoreV3. + * + * This event fires when: + * - Room order changes (new messages, manual reordering) + * - Active space changes + * - Filters are applied + * - Rooms are added/removed + * + * Space changes are detected by comparing old vs new spaceId. + * This matches the old hook pattern where space changes were handled + * indirectly through room list updates. + */ + private onListsUpdate = (): void => { + const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined; + const oldSpaceId = this.roomsResult.spaceId; + + // Refresh room data from store + this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys); + this.updateRoomsMap(this.roomsResult); + + const newSpaceId = this.roomsResult.spaceId; + + // Clear view models since room list structure changed + this.clearViewModels(); + + // Detect space change + if (oldSpaceId !== newSpaceId) { + // Space changed - get the last selected room for the new space to prevent flicker + const lastSelectedRoom = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpaceId); + + this.updateRoomListData(true, lastSelectedRoom); + return; + } + + // Normal room list update (not a space change) + this.updateRoomListData(); + }; + + private onListsLoaded = (): void => { + // Room lists have finished loading + this.snapshot.merge({ + isLoadingRooms: false, + }); + }; + + /** + * Calculate the active room index based on the currently viewed room. + * Returns undefined if no room is selected or if the selected room is not in the current list. + * + * @param roomId - The room ID to find the index for (can be null/undefined) + */ + private getActiveRoomIndex(roomId: string | null | undefined): number | undefined { + if (!roomId) { + return undefined; + } + + const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId); + return index >= 0 ? index : undefined; + } + + /** + * Apply sticky room logic to keep the active room at the same index position. + * When the room list updates, this prevents the selected room from jumping around in the UI. + * + * @param isRoomChange - Whether this update is due to a room change (not a list update) + * @param roomId - The room ID to apply sticky logic for (can be null/undefined) + * @returns The modified rooms array with sticky positioning applied + */ + private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] { + const rooms = this.roomsResult.rooms; + + if (!roomId) { + return rooms; + } + + const newIndex = rooms.findIndex((room) => room.roomId === roomId); + const oldIndex = this.lastActiveRoomIndex; + + // When opening another room, the index should obviously change + if (isRoomChange) { + return rooms; + } + + // If oldIndex is undefined, then there was no active room before + // Similarly, if newIndex is -1, the active room is not in the current list + if (newIndex === -1 || oldIndex === undefined) { + return rooms; + } + + // If the index hasn't changed, we have nothing to do + if (newIndex === oldIndex) { + return rooms; + } + + // If the old index falls out of the bounds of the rooms array + // (usually because rooms were removed), we can no longer place + // the active room in the same old index + if (oldIndex > rooms.length - 1) { + return rooms; + } + + // Making the active room sticky is as simple as removing it from + // its new index and placing it in the old index + const newRooms = [...rooms]; + const [stickyRoom] = newRooms.splice(newIndex, 1); + newRooms.splice(oldIndex, 0, stickyRoom); + + return newRooms; + } + + private async updateRoomListData( + isRoomChange: boolean = false, + roomIdOverride: string | null = null, + ): Promise { + // Determine the room ID to use for calculations + // Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore + const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId(); + + // Apply sticky room logic to keep selected room at same position + const stickyRooms = this.applyStickyRoom(isRoomChange, roomId); + + // Update roomsResult with sticky rooms + this.roomsResult = { + ...this.roomsResult, + rooms: stickyRooms, + }; + + // Rebuild roomsMap with the reordered rooms + this.updateRoomsMap(this.roomsResult); + + // Calculate the active room index after applying sticky logic + const activeRoomIndex = this.getActiveRoomIndex(roomId); + + // Track the current active room index for future sticky calculations + this.lastActiveRoomIndex = activeRoomIndex; + + // Build the complete state atomically to ensure consistency + // roomIds and roomListState must always be in sync + const roomIds = this.roomIds; + const roomListState: RoomListViewState = { + activeRoomIndex, + spaceId: this.roomsResult.spaceId, + filterKeys: this.roomsResult.filterKeys?.map((k) => String(k)), + }; + + const filterIds = [...filterKeyToIdMap.values()]; + const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined; + const isRoomListEmpty = roomIds.length === 0; + const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms; + + // Single atomic snapshot update + this.snapshot.merge({ + isLoadingRooms, + isRoomListEmpty, + filterIds, + activeFilterId, + roomListState, + roomIds, + }); + } + + public createChatRoom = (): void => { + dispatcher.fire(Action.CreateChat); + }; + + public createRoom = (): void => { + const activeSpace = SpaceStore.instance.activeSpaceRoom; + if (activeSpace) { + dispatcher.dispatch({ + action: Action.CreateRoom, + parent_space: activeSpace, + }); + } else { + dispatcher.dispatch({ + action: Action.CreateRoom, + }); + } + }; +} diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListViewViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListViewViewModel-test.tsx new file mode 100644 index 0000000000..ee3faabc65 --- /dev/null +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListViewViewModel-test.tsx @@ -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 { RoomListViewViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewViewModel"; +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 { hasCreateRoomRights } from "../../../../../src/components/viewmodels/roomlist/utils"; +import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; +import DMRoomMap from "../../../../../src/utils/DMRoomMap"; + +jest.mock("../../../../../src/components/viewmodels/roomlist/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(); + }); + }); +});