diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts index b92722381e..88e15ed9bc 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { rejectToast } from "@element-hq/element-web-playwright-common"; +import { rejectToast, rejectToastIfExists } from "@element-hq/element-web-playwright-common"; import { expect, test } from "../../../element-web-test"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; @@ -229,6 +229,63 @@ test.describe("Room list sections", () => { }); }); + test.describe("Section collapse state persistence", () => { + test.beforeEach(async ({ app }) => { + // A favourite room (so we get a Favourites section) and a regular room in Chats, + // giving us two independent sections whose expansion state we can assert. + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + await app.client.createRoom({ name: "regular room" }); + }); + + test("persists the collapsed/expanded state across reloads", async ({ page }) => { + const roomList = getRoomList(page); + const favouritesHeader = getSectionHeader(page, "Favourites"); + const chatsHeader = getSectionHeader(page, "Chats"); + const favRoom = roomList.getByRole("row", { name: "Open room favourite room" }); + const regularRoom = roomList.getByRole("row", { name: "Open room regular room" }); + + // Collapse both the Favourites and Chats sections + await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true"); + await favouritesHeader.click(); + await expect(favouritesHeader).toHaveAttribute("aria-expanded", "false"); + await expect(favRoom).not.toBeVisible(); + + await expect(chatsHeader).toHaveAttribute("aria-expanded", "true"); + await chatsHeader.click(); + await expect(chatsHeader).toHaveAttribute("aria-expanded", "false"); + await expect(regularRoom).not.toBeVisible(); + + // Reload the page: the collapsed state is persisted at the device level and should survive + await page.reload(); + await rejectToastIfExists(page, "Verify this device"); + await rejectToastIfExists(page, "Notifications"); + + // Both sections are still collapsed and their rooms stay hidden + await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "false"); + await expect(getRoomList(page).getByRole("row", { name: "Open room favourite room" })).not.toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "false"); + await expect(getRoomList(page).getByRole("row", { name: "Open room regular room" })).not.toBeVisible(); + + // Expand them again and reload: the expanded state is likewise persisted + await getSectionHeader(page, "Favourites").click(); + await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "true"); + await getSectionHeader(page, "Chats").click(); + await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "true"); + + await page.reload(); + await rejectToastIfExists(page, "Verify this device"); + await rejectToastIfExists(page, "Notifications"); + + await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "true"); + await expect(getRoomList(page).getByRole("row", { name: "Open room favourite room" })).toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "true"); + await expect(getRoomList(page).getByRole("row", { name: "Open room regular room" })).toBeVisible(); + }); + }); + test.describe("Rooms placement in sections", () => { test("should move a room between sections when tags change", async ({ page, app }) => { await app.client.createRoom({ name: "my room" }); diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 9b6314d34e..b7848a1975 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -53,7 +53,11 @@ import InviteRulesConfigController from "./controllers/InviteRulesConfigControll import { type ComputedInviteConfig } from "../@types/invite-rules.ts"; import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts"; import RequiresSettingsController from "./controllers/RequiresSettingsController.ts"; -import { type ReorderableSection, type CustomSectionsData } from "../stores/room-list-v3/section.ts"; +import { + type ReorderableSection, + type CustomSectionsData, + type SectionExpansionState, +} from "../stores/room-list-v3/section.ts"; import { type NotificationSound } from "../Notifier.ts"; import VideoRoomsBetaImage from "../../res/img/betas/video_rooms.png"; @@ -364,6 +368,7 @@ export interface Settings { "Developer.elementCallUrl": IBaseSetting; "RoomList.CustomSectionData": IBaseSetting; "RoomList.OrderedCustomSections": IBaseSetting; + "RoomList.SectionExpansionState": IBaseSetting; "RoomList.showSections": IBaseSetting; } @@ -1349,6 +1354,14 @@ export const SETTINGS: Settings = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: [], }, + /** + * Managed by the {@link RoomListSectionHeaderViewModel} + * Store the expanded/collapsed state of the room list sections, per space and per section tag + */ + "RoomList.SectionExpansionState": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: {}, + }, [UIFeature.RoomHistorySettings]: { supportedLevels: LEVELS_UI_FEATURE, default: true, diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 793b54cd6f..e8b8681b42 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -132,6 +132,36 @@ export function getCustomSectionData(): CustomSectionsData { ) satisfies CustomSectionsData; } +/** + * Persisted expanded/collapsed state of the room list sections, stored per space then per section tag. + */ +export type SectionExpansionState = { [spaceId: string]: { [sectionTag: string]: boolean } }; + +/** + * Returns whether the section with the given tag is expanded in the given space. + * Defaults to expanded when no state has been persisted. + * @param spaceId - The id of the space. + * @param tag - The tag of the section. + */ +export function isSectionExpanded(spaceId: string, tag: string): boolean { + return SettingsStore.getValue("RoomList.SectionExpansionState")[spaceId]?.[tag] ?? true; +} + +/** + * Persists the expanded/collapsed state of a section for a given space at the device level. + * @param spaceId - The id of the space. + * @param tag - The tag of the section. + * @param expanded - Whether the section is expanded. + */ +export async function setSectionExpanded(spaceId: string, tag: string, expanded: boolean): Promise { + const state = SettingsStore.getValue("RoomList.SectionExpansionState"); + const newState: SectionExpansionState = { + ...state, + [spaceId]: { ...state[spaceId], [tag]: expanded }, + }; + await SettingsStore.setValue("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, newState); +} + /** * Retrieves the ordered list of custom section tags from the settings. * If the settings contain tags that are not present in the custom section data, they will be filtered out and the settings will be updated to remove the unknown tags. diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index a563543412..abde61571a 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -24,6 +24,8 @@ import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag, + isSectionExpanded, + setSectionExpanded, } from "../../stores/room-list-v3/section"; import PosthogTrackers from "../../PosthogTrackers"; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; @@ -49,12 +51,6 @@ 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(); - /** * The calls of the rooms currently in this section that we are listening to, used to aggregate the call decoration. */ @@ -65,7 +61,7 @@ export class RoomListSectionHeaderViewModel super(props, { id: props.tag, title: props.title, - isExpanded: true, + isExpanded: isSectionExpanded(props.spaceId, props.tag), isUnread: false, displaySectionMenu: !isDefaultSection, canBeReordered: !isDefaultSection || props.tag === CHATS_TAG, @@ -79,9 +75,10 @@ export class RoomListSectionHeaderViewModel this.disposables.trackListener(CallStore.instance, CallStoreEvent.Call, this.onCallChanged); } - public onClick = (): void => { + public onClick = async (): Promise => { const isExpanded = !this.snapshot.current.isExpanded; - this.expandedBySpace.set(this.props.spaceId, isExpanded); + // We don't wait to persist the expanded state to storage, as it is not critical and we want the UI to update immediately + void setSectionExpanded(this.props.spaceId, this.props.tag, isExpanded); this.snapshot.merge({ isExpanded }); this.props.onToggleExpanded(isExpanded); }; @@ -98,7 +95,8 @@ export class RoomListSectionHeaderViewModel * This will not trigger the onToggleExpanded callback. */ public set isExpanded(value: boolean) { - this.expandedBySpace.set(this.props.spaceId, value); + // We don't wait to persist the expanded state to storage, as it is not critical and we want the UI to update immediately + void setSectionExpanded(this.props.spaceId, this.props.tag, value); this.snapshot.merge({ isExpanded: value }); const kind = value ? "Expand" : "Collapse"; @@ -111,7 +109,7 @@ export class RoomListSectionHeaderViewModel */ public setSpace(spaceId: string): void { this.props.spaceId = spaceId; - const isExpanded = this.expandedBySpace.get(this.props.spaceId) ?? true; + const isExpanded = isSectionExpanded(this.props.spaceId, this.props.tag); this.snapshot.merge({ isExpanded }); } diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 53c8d22e79..d17d88c3a1 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -741,6 +741,9 @@ export class RoomListViewModel roomIdOverride: string | null = null, scrollToSectionTag: string | undefined = undefined, ): Promise { + // Store is still loading rooms - don't update the list yet, we'll get another update when loading finishes + if (RoomListStoreV3.instance.isLoadingRooms) return; + // 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 ?? this.props.roomViewStore.getRoomId(); @@ -770,12 +773,6 @@ 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) @@ -958,19 +955,21 @@ function computeSections( ): { sections: Section[]; isFlatList: boolean } { const customSections = getCustomSectionData(); - const sections = roomsResult.sections + const filtered = roomsResult.sections // Only include sections that have rooms, or custom sections that were created in the current space. .filter( (section) => section.rooms.length > 0 || (isCustomSectionTag(section.tag) && customSections[section.tag]?.spaceId === roomsResult.spaceId), - ) - // Remove roomIds for sections that are currently collapsed according to their section header view model - .map((section) => ({ - ...section, - rooms: isSectionExpanded(section.tag) ? section.rooms : [], - })); - const isFlatList = sections.length === 0 || (sections.length === 1 && sections[0].tag === CHATS_TAG); + ); + const isFlatList = filtered.length === 0 || (filtered.length === 1 && filtered[0].tag === CHATS_TAG); + + const sections = filtered.map((section) => ({ + ...section, + // A flat list has no section header to toggle, so always render its rooms. + // Otherwise, remove roomIds for sections that are currently collapsed. + rooms: isFlatList || isSectionExpanded(section.tag) ? section.rooms : [], + })); return { sections, isFlatList }; } diff --git a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts index 39b63edadf..33acb3ef7d 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts @@ -16,11 +16,14 @@ import { getCustomSectionData, getOrderedCustomSections, isDefaultSectionTag, + isSectionExpanded, + setSectionExpanded, CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX, isSectionTag, reorderSection, } from "../../../../src/stores/room-list-v3/section"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog"; import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog"; import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; @@ -131,6 +134,63 @@ describe("section", () => { }); }); + describe("isSectionExpanded", () => { + const spaceId = "!space:server"; + const tag = "element.io.section.abc"; + + it.each([ + { value: {}, result: true }, + { value: { "!other:server": { [tag]: false } }, result: true }, + { value: { [spaceId]: { "other.tag": false } }, result: true }, + { value: { [spaceId]: { [tag]: false } }, result: false }, + ])("returns the persisted state=$result when value=$value", ({ value, result }) => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(value); + expect(isSectionExpanded(spaceId, tag)).toBe(result); + }); + }); + + describe("setSectionExpanded", () => { + const spaceId = "!space:server"; + const tag = "element.io.section.abc"; + + it("persists the state at the device level", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue({}); + const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + + await setSectionExpanded(spaceId, tag, false); + + expect(setValueSpy).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, { + [spaceId]: { [tag]: false }, + }); + }); + + it("merges with existing state for other spaces and tags", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue({ + "!other:server": { "other.tag": false }, + [spaceId]: { "existing.tag": true }, + }); + const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + + await setSectionExpanded(spaceId, tag, false); + + expect(setValueSpy).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, { + "!other:server": { "other.tag": false }, + [spaceId]: { "existing.tag": true, [tag]: false }, + }); + }); + + it("overwrites the previous state for the same space and tag", async () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [spaceId]: { [tag]: false } }); + const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + + await setSectionExpanded(spaceId, tag, true); + + expect(setValueSpy).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, { + [spaceId]: { [tag]: true }, + }); + }); + }); + describe("createSection", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockReturnValue(null); diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts index 0b1c541a2f..76fcf14e59 100644 --- a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts @@ -16,23 +16,34 @@ import { CallStore } from "../../../src/stores/CallStore"; import { type Call } from "../../../src/models/Call"; import { createTestClient, mkRoom } from "../../test-utils"; import SettingsStore from "../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; -import { CHATS_TAG } from "../../../src/stores/room-list-v3/section"; +import { CHATS_TAG, type SectionExpansionState } from "../../../src/stores/room-list-v3/section"; describe("RoomListSectionHeaderViewModel", () => { let onToggleExpanded: jest.Mock; let matrixClient: MatrixClient; + // In-memory backing store shared between the getValue/setValue mocks so that + // persisted expansion state round-trips within a test. + let sectionExpansionState: SectionExpansionState; beforeEach(() => { onToggleExpanded = jest.fn(); matrixClient = createTestClient(); + sectionExpansionState = {}; jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("watcher-id"); jest.spyOn(SettingsStore, "unwatchSetting").mockReturnValue(undefined); jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { if (setting === "RoomList.OrderedCustomSections") return []; + if (setting === "RoomList.SectionExpansionState") return sectionExpansionState; return null; }); + jest.spyOn(SettingsStore, "setValue").mockImplementation(async (setting, _roomId, _level, value) => { + if (setting === "RoomList.SectionExpansionState") { + sectionExpansionState = value as SectionExpansionState; + } + }); }); afterEach(() => { @@ -100,6 +111,52 @@ describe("RoomListSectionHeaderViewModel", () => { expect(vm.isExpanded).toBe(false); }); + it("should initialize expanded state from the persisted setting", () => { + sectionExpansionState = { "!space:server": { "m.favourite": false } }; + + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + spaceId: "!space:server", + onToggleExpanded, + }); + + expect(vm.getSnapshot().isExpanded).toBe(false); + }); + + it("should persist the expanded state at the device level on click", () => { + const setValue = jest.spyOn(SettingsStore, "setValue"); + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + spaceId: "!space:server", + onToggleExpanded, + }); + + vm.onClick(); + + expect(setValue).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, { + "!space:server": { "m.favourite": false }, + }); + expect(sectionExpansionState).toEqual({ "!space:server": { "m.favourite": false } }); + }); + + it("should persist the expanded state at the device level when set via the setter", () => { + const setValue = jest.spyOn(SettingsStore, "setValue"); + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + spaceId: "!space:server", + onToggleExpanded, + }); + + vm.isExpanded = false; + + expect(setValue).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, { + "!space:server": { "m.favourite": false }, + }); + }); + describe("displaySectionMenu", () => { it.each([ [DefaultTagID.Favourite, false], diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.ts index 1310182434..39ba3cd00a 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.ts @@ -29,7 +29,11 @@ import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; import SettingsStore from "../../../src/settings/SettingsStore"; import { tagRoom } from "../../../src/utils/room/tagRoom"; import { getSectionTagForRoom } from "../../../src/utils/room/getSectionTagForRoom"; -import { CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX } from "../../../src/stores/room-list-v3/section"; +import { + CHATS_TAG, + CUSTOM_SECTION_TAG_PREFIX, + type SectionExpansionState, +} from "../../../src/stores/room-list-v3/section"; import { MetaSpace } from "../../../src/stores/spaces"; import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; import { type RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState"; @@ -55,9 +59,27 @@ describe("RoomListViewModel", () => { let room2: Room; let room3: Room; let viewModel: RoomListViewModel; + // In-memory backing store for the persisted section expansion setting, reset each test so + // collapse state does not leak between tests and writes round-trip synchronously. + let sectionExpansionState: SectionExpansionState; beforeEach(() => { matrixClient = createTestClient(); + + sectionExpansionState = {}; + const realGetValue = SettingsStore.getValue.bind(SettingsStore); + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, roomId, excludeDefault) => { + if (setting === "RoomList.SectionExpansionState") return sectionExpansionState; + return realGetValue(setting, roomId, excludeDefault); + }); + const realSetValue = SettingsStore.setValue.bind(SettingsStore); + jest.spyOn(SettingsStore, "setValue").mockImplementation(async (setting, roomId, level, value) => { + if (setting === "RoomList.SectionExpansionState") { + sectionExpansionState = value as SectionExpansionState; + return; + } + return realSetValue(setting, roomId, level, value); + }); sdkContext = new TestSDKContext(); sdkContext._client = matrixClient; room1 = mkStubRoom("!room1:server", "Room 1", matrixClient); @@ -428,6 +450,7 @@ describe("RoomListViewModel", () => { if (setting === "RoomList.showSections") return showSections; if (setting === "RoomList.CustomSectionData") return {}; if (setting === "RoomList.OrderedCustomSections") return []; + if (setting === "RoomList.SectionExpansionState") return {}; return undefined as any; }); } @@ -465,6 +488,7 @@ describe("RoomListViewModel", () => { if (setting === "RoomList.showSections") return showSections; if (setting === "RoomList.CustomSectionData") return {}; if (setting === "RoomList.OrderedCustomSections") return []; + if (setting === "RoomList.SectionExpansionState") return {}; return undefined as any; }); jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {