* fix: section being empty in flat list mode When switching space (or removing a section later), if the Chat section is collpased and the room list is in flat list mode in the other space, the room list is empty. The fix forces the section to be in expanded state if in flat list mode * fix: store section expanded state by space
922 lines
39 KiB
TypeScript
922 lines
39 KiB
TypeScript
/*
|
|
* Copyright 2025 New Vector Ltd.
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
* Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
|
import { mocked } from "jest-mock";
|
|
import { waitFor } from "jest-matrix-react";
|
|
|
|
import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils";
|
|
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
|
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
|
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
|
|
import dispatcher from "../../../src/dispatcher/dispatcher";
|
|
import { Action } from "../../../src/dispatcher/actions";
|
|
import { SdkContextClass } from "../../../src/contexts/SDKContext";
|
|
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
|
import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListViewModel";
|
|
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
|
|
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
|
|
|
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
|
hasCreateRoomRights: jest.fn().mockReturnValue(false),
|
|
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
|
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
|
|
}));
|
|
|
|
describe("RoomListViewModel", () => {
|
|
let matrixClient: MatrixClient;
|
|
let room1: Room;
|
|
let room2: Room;
|
|
let room3: Room;
|
|
let viewModel: RoomListViewModel;
|
|
|
|
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",
|
|
sections: [{ tag: CHATS_TAG, 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 RoomListViewModel({ client: matrixClient });
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
expect(snapshot.sections[0].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",
|
|
sections: [{ tag: CHATS_TAG, rooms: [] }],
|
|
});
|
|
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
expect(viewModel.getSnapshot().sections).toEqual([]);
|
|
expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
|
|
});
|
|
|
|
it("should set canCreateRoom based on user rights", () => {
|
|
mocked(hasCreateRoomRights).mockReturnValue(true);
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
expect(viewModel.getSnapshot().canCreateRoom).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Room list updates", () => {
|
|
it("should update room list when ListsUpdate event fires", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient);
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3, newRoom] }],
|
|
});
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
expect(viewModel.getSnapshot().sections[0].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 RoomListViewModel({ client: matrixClient });
|
|
|
|
expect(viewModel.getSnapshot().isLoadingRooms).toBe(true);
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsLoaded);
|
|
|
|
expect(viewModel.getSnapshot().isLoadingRooms).toBe(false);
|
|
});
|
|
|
|
// This test ensures that the room list item vms are preserved when the room list is changing
|
|
it("should keep existing view model when ListsUpdate event fires", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Create view model for room1
|
|
const room1VM = viewModel.getRoomItemViewModel("!room1:server");
|
|
expect(room1VM).toBeDefined();
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
// View model should be still valid
|
|
expect(room1VM!.isDisposed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Space switching", () => {
|
|
it("should update room list when space changes", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const spaceRoomList = [room1, room2];
|
|
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "!space:server",
|
|
sections: [{ tag: CHATS_TAG, 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().sections[0].roomIds).toEqual(["!room1:server", "!room2:server"]);
|
|
});
|
|
|
|
it("should clear view models when space changes", () => {
|
|
viewModel = new RoomListViewModel({ 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",
|
|
sections: [{ tag: CHATS_TAG, rooms: [room3] }],
|
|
});
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
expect(disposeSpy1).toHaveBeenCalled();
|
|
expect(disposeSpy2).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should clear roomsMap when space changes and repopulate with new rooms", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const newSpaceRoom = mkStubRoom("!spaceroom:server", "Space Room", matrixClient);
|
|
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "!space:server",
|
|
sections: [{ tag: CHATS_TAG, rooms: [newSpaceRoom] }],
|
|
});
|
|
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
// New space room should be accessible
|
|
expect(() => viewModel.getRoomItemViewModel("!spaceroom:server")).not.toThrow();
|
|
// Old rooms from the home space should not be accessible
|
|
expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Active room tracking", () => {
|
|
it("should update active room index when room is selected", async () => {
|
|
viewModel = new RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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",
|
|
sections: [{ tag: CHATS_TAG, 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().sections[0].roomIds[1]).toBe("!room2:server");
|
|
});
|
|
|
|
it("should not apply sticky behavior when user changes rooms", async () => {
|
|
viewModel = new RoomListViewModel({ 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 RoomListViewModel({ client: matrixClient });
|
|
|
|
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
|
|
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [{ tag: CHATS_TAG, rooms: [room1] }],
|
|
filterKeys: [FilterEnum.UnreadFilter],
|
|
});
|
|
|
|
viewModel.onToggleFilter("unread");
|
|
|
|
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
|
|
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual(["!room1:server"]);
|
|
});
|
|
|
|
it("should toggle filter off", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Turn filter on
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [{ tag: CHATS_TAG, rooms: [room1] }],
|
|
filterKeys: [FilterEnum.UnreadFilter],
|
|
});
|
|
viewModel.onToggleFilter("unread");
|
|
|
|
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
|
|
|
|
// Turn filter off
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3] }],
|
|
});
|
|
viewModel.onToggleFilter("unread");
|
|
|
|
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
|
|
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([
|
|
"!room1:server",
|
|
"!room2:server",
|
|
"!room3:server",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("Room item view models", () => {
|
|
it("should create room item view model on demand", () => {
|
|
viewModel = new RoomListViewModel({ 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 RoomListViewModel({ client: matrixClient });
|
|
|
|
const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server");
|
|
const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server");
|
|
|
|
expect(itemViewModel1).toBe(itemViewModel2);
|
|
});
|
|
|
|
it("should return undefined for non-existent room", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
expect(viewModel.getRoomItemViewModel("!nonexistent:server")).toBeUndefined();
|
|
});
|
|
|
|
it("should not throw when requesting view model for a room removed from the list but still in roomsMap", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Normal list update removes room2 from the list
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [{ tag: CHATS_TAG, rooms: [room1, room3] }],
|
|
});
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
expect(() => viewModel.getRoomItemViewModel("!room2:server")).not.toThrow();
|
|
});
|
|
|
|
it("should return undefined for a room from old space after space change", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const spaceRoom = mkStubRoom("!newroom:server", "New Room", matrixClient);
|
|
|
|
// Space change: new space only has spaceRoom
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "!space:server",
|
|
sections: [{ tag: CHATS_TAG, rooms: [spaceRoom] }],
|
|
});
|
|
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined();
|
|
});
|
|
|
|
it("should recover when roomsMap is stale but roomsResult has the room", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Manually clear roomsMap to simulate stale cache, but keep roomsResult intact
|
|
(viewModel as any).roomsMap.clear();
|
|
|
|
// getRoomItemViewModel should retry by re-populating roomsMap from roomsResult
|
|
expect(() => viewModel.getRoomItemViewModel("!room1:server")).not.toThrow();
|
|
});
|
|
|
|
it("should dispose view models for rooms no longer visible", () => {
|
|
viewModel = new RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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 RoomListViewModel({ 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();
|
|
});
|
|
|
|
describe("Sections (feature_room_list_sections)", () => {
|
|
let favRoom1: Room;
|
|
let favRoom2: Room;
|
|
let lowPriorityRoom: Room;
|
|
let regularRoom1: Room;
|
|
let regularRoom2: Room;
|
|
|
|
beforeEach(() => {
|
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
|
|
if (setting === "feature_room_list_sections") return true;
|
|
return false;
|
|
});
|
|
|
|
favRoom1 = mkStubRoom("!fav1:server", "Fav 1", matrixClient);
|
|
favRoom2 = mkStubRoom("!fav2:server", "Fav 2", matrixClient);
|
|
lowPriorityRoom = mkStubRoom("!low1:server", "Low 1", matrixClient);
|
|
regularRoom1 = mkStubRoom("!reg1:server", "Reg 1", matrixClient);
|
|
regularRoom2 = mkStubRoom("!reg2:server", "Reg 2", matrixClient);
|
|
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2] },
|
|
{ tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should initialize with multiple sections", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
expect(snapshot.sections).toHaveLength(3);
|
|
expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite);
|
|
expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server"]);
|
|
expect(snapshot.sections[1].id).toBe(CHATS_TAG);
|
|
expect(snapshot.sections[1].roomIds).toEqual(["!reg1:server", "!reg2:server"]);
|
|
expect(snapshot.sections[2].id).toBe(DefaultTagID.LowPriority);
|
|
expect(snapshot.sections[2].roomIds).toEqual(["!low1:server"]);
|
|
});
|
|
|
|
it("should not be a flat list when multiple sections exist", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
expect(viewModel.getSnapshot().isFlatList).toBe(false);
|
|
});
|
|
|
|
it("should be a flat list when only chats section has rooms", () => {
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [] },
|
|
{ tag: CHATS_TAG, rooms: [regularRoom1] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [] },
|
|
],
|
|
});
|
|
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
expect(viewModel.getSnapshot().isFlatList).toBe(true);
|
|
expect(viewModel.getSnapshot().sections).toHaveLength(1);
|
|
expect(viewModel.getSnapshot().sections[0].id).toBe(CHATS_TAG);
|
|
});
|
|
|
|
it("should exclude favourite and low_priority from filter list", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
expect(snapshot.filterIds).not.toContain("favourite");
|
|
expect(snapshot.filterIds).not.toContain("low_priority");
|
|
// Other filters should still be present
|
|
expect(snapshot.filterIds).toContain("unread");
|
|
expect(snapshot.filterIds).toContain("people");
|
|
});
|
|
|
|
it("should omit empty sections from snapshot", () => {
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [] },
|
|
{ tag: CHATS_TAG, rooms: [regularRoom1] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [] },
|
|
],
|
|
});
|
|
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
expect(snapshot.sections).toHaveLength(1);
|
|
expect(snapshot.sections[0].id).toBe(CHATS_TAG);
|
|
});
|
|
|
|
it("should create section header view models on demand", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const headerVM = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
expect(headerVM).toBeDefined();
|
|
expect(headerVM.getSnapshot().id).toBe(DefaultTagID.Favourite);
|
|
expect(headerVM.getSnapshot().isExpanded).toBe(true);
|
|
});
|
|
|
|
it("should reuse section header view models", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const headerVM1 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
const headerVM2 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
expect(headerVM1).toBe(headerVM2);
|
|
});
|
|
|
|
it("should hide room IDs when a section is collapsed", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Collapse the favourite section
|
|
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
favHeader.onClick();
|
|
expect(favHeader.isExpanded).toBe(false);
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
|
|
expect(favSection).toBeDefined();
|
|
// Collapsed sections have an empty roomIds list
|
|
expect(favSection!.roomIds).toEqual([]);
|
|
|
|
// Other sections remain unaffected
|
|
const chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
|
|
expect(chatsSection!.roomIds).toEqual(["!reg1:server", "!reg2:server"]);
|
|
});
|
|
|
|
it("should compute activeRoomIndex relative to visible rooms when a section is collapsed", async () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Collapse the favourite section (which has 2 rooms: fav1, fav2)
|
|
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
favHeader.onClick();
|
|
expect(favHeader.isExpanded).toBe(false);
|
|
|
|
// Select regularRoom1, which is the first room in the chats section
|
|
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!reg1:server");
|
|
dispatcher.dispatch({
|
|
action: Action.ActiveRoomChanged,
|
|
newRoomId: "!reg1:server",
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const snapshot = viewModel.getSnapshot();
|
|
// The favourite section is collapsed so its 2 rooms are not visible.
|
|
// regularRoom1 should be at index 0 in the visible list, not index 2.
|
|
expect(snapshot.roomListState.activeRoomIndex).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("should restore room IDs when a section is re-expanded", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
|
|
// Collapse then re-expand
|
|
favHeader.onClick();
|
|
favHeader.onClick();
|
|
expect(favHeader.isExpanded).toBe(true);
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
|
|
expect(favSection!.roomIds).toEqual(["!fav1:server", "!fav2:server"]);
|
|
});
|
|
|
|
it("should update sections when room list changes", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
const newFav = mkStubRoom("!fav3:server", "Fav 3", matrixClient);
|
|
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2, newFav] },
|
|
{ tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
|
|
],
|
|
});
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server", "!fav3:server"]);
|
|
});
|
|
|
|
it("should preserve section collapse state across list updates", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Collapse favourites
|
|
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
favHeader.onClick();
|
|
|
|
// Trigger a list update
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
|
|
expect(favSection!.roomIds).toEqual([]);
|
|
});
|
|
|
|
it("should track section collapse state per space", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Collapse favourites in the home space
|
|
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
|
favHeader.onClick();
|
|
|
|
// Switch to a different space with its own rooms
|
|
const spaceFav = mkStubRoom("!spacefav:server", "Space Fav", matrixClient);
|
|
const spaceReg = mkStubRoom("!spacereg:server", "Space Reg", matrixClient);
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "!space:server",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [spaceFav] },
|
|
{ tag: CHATS_TAG, rooms: [spaceReg] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [] },
|
|
],
|
|
});
|
|
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null);
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
let snapshot = viewModel.getSnapshot();
|
|
// Favourites should be expanded in the new space (per-space state)
|
|
let favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
|
|
expect(favSection).toBeDefined();
|
|
expect(favSection!.roomIds).toEqual(["!spacefav:server"]);
|
|
|
|
// Other sections should also be expanded
|
|
let chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
|
|
expect(chatsSection!.roomIds).toEqual(["!spacereg:server"]);
|
|
|
|
// Switch back to home space
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2] },
|
|
{ tag: CHATS_TAG, rooms: [regularRoom1] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [] },
|
|
],
|
|
});
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
snapshot = viewModel.getSnapshot();
|
|
// Favourites should still be collapsed in the home space
|
|
favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
|
|
expect(favSection).toBeDefined();
|
|
expect(favSection!.roomIds).toEqual([]);
|
|
|
|
// Chats should be expanded
|
|
chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG);
|
|
expect(chatsSection!.roomIds).toEqual(["!reg1:server"]);
|
|
});
|
|
|
|
it("should apply filters across all sections", () => {
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Only favRoom1 is unread
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [favRoom1] },
|
|
{ tag: CHATS_TAG, rooms: [] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [] },
|
|
],
|
|
filterKeys: [FilterEnum.UnreadFilter],
|
|
});
|
|
|
|
viewModel.onToggleFilter("unread");
|
|
|
|
const snapshot = viewModel.getSnapshot();
|
|
expect(snapshot.activeFilterId).toBe("unread");
|
|
// Only the favourite section should remain (chats and low priority are empty)
|
|
expect(snapshot.sections).toHaveLength(1);
|
|
expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite);
|
|
expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server"]);
|
|
});
|
|
|
|
it("should apply sticky room within the correct section", async () => {
|
|
stubClient();
|
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
|
|
|
// Select favRoom1 (index 0 globally, index 0 in favourites section)
|
|
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!fav1:server");
|
|
dispatcher.dispatch({
|
|
action: Action.ActiveRoomChanged,
|
|
newRoomId: "!fav1:server",
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0);
|
|
|
|
// Room list update moves favRoom1 to second position within favourites
|
|
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
|
spaceId: "home",
|
|
sections: [
|
|
{ tag: DefaultTagID.Favourite, rooms: [favRoom2, favRoom1] },
|
|
{ tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] },
|
|
{ tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] },
|
|
],
|
|
});
|
|
|
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
|
|
|
// Sticky room should keep favRoom1 at index 0 within the favourites section
|
|
const snapshot = viewModel.getSnapshot();
|
|
expect(snapshot.sections[0].roomIds[0]).toBe("!fav1:server");
|
|
expect(snapshot.roomListState.activeRoomIndex).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
});
|