Room list: persist section state (expanded/collapsed) (#34351)

* Add new setting to store section state

* Store section state in device settings

* Wire up section header view model and new section functions

* Update room list vm tests

* Add e2e test on section state persistence

* Use more intuitive syntax for SectionExpansionState

* Formatting

* Fix chats section staying expanded

* Add CHAT section case in e2e test
This commit is contained in:
Florian Duros
2026-07-29 13:56:40 +00:00
committed by GitHub
parent 1683525c5b
commit 7f8c4cef80
8 changed files with 267 additions and 29 deletions
+14 -1
View File
@@ -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<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<ReorderableSection[]>;
"RoomList.SectionExpansionState": IBaseSetting<SectionExpansionState>;
"RoomList.showSections": IBaseSetting<boolean>;
}
@@ -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,
@@ -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<void> {
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.
@@ -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<RoomNotificationState>();
/**
* Tracks the expanded/collapsed state per space.
* Key is spaceId. Defaults to expanded if not set.
*/
private readonly expandedBySpace = new Map<string, boolean>();
/**
* 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<void> => {
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 });
}
@@ -741,6 +741,9 @@ export class RoomListViewModel
roomIdOverride: string | null = null,
scrollToSectionTag: string | undefined = undefined,
): Promise<void> {
// 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 };
}