/* 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 { RoomListItemViewModel } from "./RoomListItemViewModel"; import { SdkContextClass } from "../../contexts/SDKContext"; import { hasCreateRoomRights } from "./utils"; import { keepIfSame } from "../../utils/keepIfSame"; 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(); // This map is intentionally additive (never cleared except on space changes) to avoid a race condition: // a list update can refresh roomsResult and roomsMap before the view re-renders, so the view may still // request a view model for a room that was removed from the latest list. Keeping old entries prevents a crash. 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()]; const roomIds = roomsResult.rooms.map((room) => room.roomId); const sections = [{ id: "all", roomIds }]; 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, }, // Until we implement sections, this view model only supports the flat list mode isFlatList: true, sections, 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); this.updateRoomListData(); }; /** * Add rooms from the RoomsResult to the roomsMap for quick lookup. * This does not clear the roomsMap. * This maintains a quick lookup for room objects. */ private updateRoomsMap(roomsResult: RoomsResult): void { 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) { let room = this.roomsMap.get(roomId); if (!room) { // Maybe the roomsMap is out of date due to a recent roomsResult change that hasn't been applied yet (race condition) this.updateRoomsMap(this.roomsResult); 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; } /** * Not implemented - this view model does not support sections. * Flat list mode is forced so this method is never be called. * @throw Error if called */ public getSectionHeaderViewModel(): never { throw new Error("Sections are not supported in this room list"); } /** * 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); const newSpaceId = this.roomsResult.spaceId; // Detect space change if (oldSpaceId !== newSpaceId) { // Clear view models when the space changes // We only want to do this on space changes, not on regular list updates, to preserve view models when possible // The view models are disposed when scrolling out of view (handled by updateVisibleRooms) this.clearViewModels(); // Clear roomsMap to prevent stale room data - it will be repopulated with the new roomsResult this.roomsMap.clear(); this.updateRoomsMap(this.roomsResult); // 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; } this.updateRoomsMap(this.roomsResult); // 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 sections = [{ id: "all", roomIds }]; // Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list const previousFilterKeys = this.snapshot.current.roomListState.filterKeys; const newFilterKeys = this.roomsResult.filterKeys?.map((k) => String(k)); const roomListState: RoomListViewState = { activeRoomIndex, spaceId: this.roomsResult.spaceId, filterKeys: keepIfSame(previousFilterKeys, newFilterKeys), }; 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, activeFilterId, roomListState, sections, }); } 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, }); } }; }