mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-06 04:36:21 +02:00
Add RoomListViewViewModel
Add view model for the room list view. Manages room list state, filtering, keyboard navigation, and child view models.
This commit is contained in:
parent
9dc03dbf03
commit
4ae79cfa13
450
src/components/viewmodels/roomlist/RoomListViewViewModel.ts
Normal file
450
src/components/viewmodels/roomlist/RoomListViewViewModel.ts
Normal file
@ -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<FilterKey, FilterId> = 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<RoomListSnapshot, RoomListViewViewModelProps>
|
||||
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<string, RoomListItemViewModel>();
|
||||
private roomsMap = new Map<string, Room>();
|
||||
|
||||
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<ViewRoomPayload>({
|
||||
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<void> {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user