diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index 08f7335ab8..a861c0894a 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -19,6 +19,10 @@ import { type RoomNotificationState } from "../../stores/notifications/RoomNotif interface RoomListSectionHeaderViewModelProps { tag: string; title: string; + /** + * The ID of the current space. + */ + spaceId: string; onToggleExpanded: (isExpanded: boolean) => void; } @@ -31,12 +35,19 @@ export class RoomListSectionHeaderViewModel */ private roomNotificationStates = new Set(); + /** + * Tracks the expanded/collapsed state per space. + * Key is spaceId. Defaults to expanded if not set. + */ + private readonly expandedBySpace = new Map(); + public constructor(props: RoomListSectionHeaderViewModelProps) { super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false }); } public onClick = (): void => { const isExpanded = !this.snapshot.current.isExpanded; + this.expandedBySpace.set(this.props.spaceId, isExpanded); this.snapshot.merge({ isExpanded }); this.props.onToggleExpanded(isExpanded); }; @@ -48,6 +59,25 @@ export class RoomListSectionHeaderViewModel return this.snapshot.current.isExpanded; } + /** + * Set whether the section is expanded for the current space. + * This will not trigger the onToggleExpanded callback. + */ + public set isExpanded(value: boolean) { + this.expandedBySpace.set(this.props.spaceId, value); + this.snapshot.merge({ isExpanded: value }); + } + + /** + * Switch to a different space, restoring the expanded state for that space. + * Defaults to expanded if no state has been saved for the space. + */ + public setSpace(spaceId: string): void { + this.props.spaceId = spaceId; + const isExpanded = this.expandedBySpace.get(this.props.spaceId) ?? true; + this.snapshot.merge({ isExpanded }); + } + /** * Update the rooms tracked by this section header for unread state computation. * Only subscribes to new rooms and unsubscribes from rooms no longer in the section. diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index b81bd8725a..0dc476e09c 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -258,6 +258,7 @@ export class RoomListViewModel const viewModel = new RoomListSectionHeaderViewModel({ tag, title, + spaceId: this.roomsResult.spaceId, onToggleExpanded: () => this.updateRoomListData(), }); this.roomSectionHeaderViewModels.set(tag, viewModel); @@ -367,6 +368,11 @@ export class RoomListViewModel this.updateRoomsMap(this.roomsResult); + // Restore the expanded/collapsed state for the new space + for (const viewModel of this.roomSectionHeaderViewModels.values()) { + viewModel.setSpace(newSpaceId); + } + // Space changed - get the last selected room for the new space to prevent flicker const lastSelectedRoom = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpaceId); @@ -501,6 +507,12 @@ export class RoomListViewModel this.roomsResult, (tag) => this.roomSectionHeaderViewModels.get(tag)?.isExpanded ?? true, ); + // If it's a flat list, we need to make sure the single section is expanded and has all rooms, otherwise the room list will be empty + if (isFlatList) { + const chatSections = this.roomSectionHeaderViewModels.get(CHATS_TAG); + if (chatSections) chatSections.isExpanded = true; + chatSections?.setRooms(this.roomsResult.sections.flatMap((section) => section.rooms)); + } this.sections = sections; // Calculate the active room index from the computed sections (which exclude collapsed sections' rooms) diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts index 0297ee24f3..702cf412c0 100644 --- a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts @@ -30,6 +30,7 @@ describe("RoomListSectionHeaderViewModel", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", + spaceId: "!space:server", onToggleExpanded, }); @@ -43,6 +44,7 @@ describe("RoomListSectionHeaderViewModel", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", + spaceId: "!space:server", onToggleExpanded, }); expect(vm.isExpanded).toBe(true); @@ -58,6 +60,33 @@ describe("RoomListSectionHeaderViewModel", () => { expect(onToggleExpanded).toHaveBeenCalledWith(true); }); + it("should track expanded state per space", () => { + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + spaceId: "!space:server", + onToggleExpanded, + }); + + // Default space: collapse + vm.onClick(); + expect(vm.isExpanded).toBe(false); + + // Switch to a different space: should default to expanded + vm.setSpace("!space2:server"); + expect(vm.isExpanded).toBe(true); + + // Collapse in the new space + vm.onClick(); + expect(vm.isExpanded).toBe(false); + vm.onClick(); + expect(vm.isExpanded).toBe(true); + + // Switch to the other space: should still be collapsed + vm.setSpace("!space:server"); + expect(vm.isExpanded).toBe(false); + }); + describe("unread status", () => { let room: Room; let notificationState: RoomNotificationState; @@ -72,6 +101,7 @@ describe("RoomListSectionHeaderViewModel", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", + spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); @@ -85,6 +115,7 @@ describe("RoomListSectionHeaderViewModel", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", + spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); @@ -107,6 +138,7 @@ describe("RoomListSectionHeaderViewModel", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", + spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); @@ -127,6 +159,7 @@ describe("RoomListSectionHeaderViewModel", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", + spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); @@ -145,6 +178,7 @@ describe("RoomListSectionHeaderViewModel", () => { const vm = new RoomListSectionHeaderViewModel({ tag: "m.favourite", title: "Favourites", + spaceId: "!space:server", onToggleExpanded, }); vm.setRooms([room]); diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index c2ced861f0..b9172b680b 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -806,10 +806,10 @@ describe("RoomListViewModel", () => { expect(favSection!.roomIds).toEqual([]); }); - it("should preserve section collapse state across space changes", () => { + it("should track section collapse state per space", () => { viewModel = new RoomListViewModel({ client: matrixClient }); - // Collapse favourites + // Collapse favourites in the home space const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); favHeader.onClick(); @@ -828,15 +828,37 @@ describe("RoomListViewModel", () => { RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); - const snapshot = viewModel.getSnapshot(); - // Favourites should still be collapsed even after the space change - const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite); + 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([]); - // Other sections should remain expanded - const chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG); - expect(chatsSection!.roomIds).toEqual(["!spacereg:server"]); + // 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", () => {