Room list: add robustness to custom section loading (#33475)

* refactor: move all access to custom sections settings into `section.ts`

* fix: add robustness when getting the order list of custom sections

* fix: add robustness when getting the custom section data

* fix: ignore malformed section but don't erase them

* fix: remove useless await operator

* test: add more tests
This commit is contained in:
Florian Duros
2026-05-13 15:50:33 +00:00
committed by GitHub
parent c4c32b8334
commit 47f8012691
9 changed files with 178 additions and 34 deletions
@@ -40,7 +40,7 @@ import { DefaultTagID } from "./skip-list/tag";
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
import { TagFilter } from "./skip-list/filters/TagFilter";
import { filterBoolean } from "../../utils/arrays";
import { CHATS_TAG, createSection, deleteSection, editSection } from "./section";
import { CHATS_TAG, createSection, deleteSection, editSection, getOrderedCustomSections } from "./section";
/**
* These are the filters passed to the room skip list.
@@ -522,7 +522,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
* Load the custom sections from the settings store and update the sorted tags.
*/
private loadCustomSections(): void {
const orderedCustomSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
const orderedCustomSections = getOrderedCustomSections();
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
}
}
+58 -13
View File
@@ -14,8 +14,6 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
import { DefaultTagID, type TagID } from "./skip-list/tag";
type Tag = string;
/**
* A synthetic tag used to represent the "Chats" section, which contains
* every room that does not belong to any other explicit tag section.
@@ -27,12 +25,14 @@ export const CHATS_TAG = "chats";
*/
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
type CustomTag = `${typeof CUSTOM_SECTION_TAG_PREFIX}${string}`;
/**
* Checks if a given tag is a custom section tag.
* @param tag - The tag to check.
* @returns True if the tag is a custom section tag, false otherwise.
*/
export function isCustomSectionTag(tag: string): boolean {
export function isCustomSectionTag(tag: string): tag is CustomTag {
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
}
@@ -58,18 +58,56 @@ export function isSectionTag(tagId: TagID): boolean {
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
*/
type CustomSection = {
tag: Tag;
tag: CustomTag;
name: string;
};
/**
* Type guard to check if a value is a valid CustomSection object.
*/
function isValidCustomSection(value: unknown): value is CustomSection {
return (
typeof value === "object" &&
value !== null &&
isCustomSectionTag((value as Record<string, unknown>).tag as string) &&
typeof (value as Record<string, unknown>).name === "string"
);
}
/**
* The custom sections data is stored as a record in the settings, where the key is the section tag and the value is the section data (name and tag).
*/
export type CustomSectionsData = Record<Tag, CustomSection>;
export type CustomSectionsData = Record<CustomTag, CustomSection>;
/**
* Ordered list of custom section tags.
*/
export type OrderedCustomSections = Tag[];
export type OrderedCustomSections = CustomTag[];
/**
* Retrieves the custom sections data from the settings.
* Invalid or malformed entries are dropped and the cleaned data is persisted back to settings.
*/
export function getCustomSectionData(): CustomSectionsData {
const raw = SettingsStore.getValue("RoomList.CustomSectionData");
// Data are malformed
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
// Filter out invalid entries
return Object.fromEntries(
Object.entries(raw).filter(([key, value]) => isValidCustomSection(value) && value.tag === key),
) as CustomSectionsData;
}
/**
* 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.
*/
export function getOrderedCustomSections(): OrderedCustomSections {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag) => tag in sectionData);
}
/**
* Creates a new custom section by showing a dialog to the user to enter the section name.
@@ -83,16 +121,16 @@ export async function createSection(): Promise<string | undefined> {
const [shouldCreateSection, sectionName] = await modal.finished;
if (!shouldCreateSection || !sectionName) return undefined;
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
const tag: CustomTag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
const newSection: CustomSection = { tag, name: sectionName };
// Save the new section data
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
const sectionData = getCustomSectionData();
sectionData[tag] = newSection;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
// Add the new section to the ordered list of sections
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
const orderedSections = getOrderedCustomSections();
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return tag;
@@ -103,7 +141,11 @@ export async function createSection(): Promise<string | undefined> {
* @param tag - The tag of the section to edit.
*/
export async function editSection(tag: string): Promise<void> {
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
if (!isCustomSectionTag(tag)) {
logger.info("Unknown section tag, cannot edit section", tag);
return;
}
const sectionData = getCustomSectionData();
const section = sectionData[tag];
if (!section) {
logger.info("Unknown section tag, cannot edit section", tag);
@@ -127,7 +169,11 @@ export async function editSection(tag: string): Promise<void> {
* @param isEmpty - Whether the section is empty (has no rooms). If the section is not empty, the confirmation dialog will show a warning message.
*/
export async function deleteSection(tag: string, isEmpty: boolean): Promise<void> {
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData");
if (!isCustomSectionTag(tag)) {
logger.info("Unknown section tag, cannot delete section", tag);
return;
}
const sectionData = getCustomSectionData();
if (!sectionData[tag]) {
logger.info("Unknown section tag, cannot delete section", tag);
return;
@@ -138,8 +184,7 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise<void
if (!shouldRemoveSection) return;
// Remove the section from the ordered list of sections
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
const newOrderedSections = orderedSections.filter((sectionTag) => sectionTag !== tag);
const newOrderedSections = getOrderedCustomSections().filter((sectionTag) => sectionTag !== tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections);
// Remove the section data
@@ -39,8 +39,8 @@ import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"
import PosthogTrackers from "../../PosthogTrackers";
import { type Call, CallEvent } from "../../models/Call";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { getCustomSectionData, isDefaultSectionTag } from "../../stores/room-list-v3/section";
import { _t } from "../../languageHandler";
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
interface RoomItemProps {
room: Room;
@@ -424,7 +424,7 @@ export class RoomListItemViewModel
* Order follows the canonical section order from RoomListStoreV3.
*/
private static buildSections(roomTags: Room["tags"]): Section[] {
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
const customSectionData = getCustomSectionData();
return (
RoomListStoreV3.instance.orderedSectionTags
@@ -17,7 +17,7 @@ import { NotificationStateEvents } from "../../stores/notifications/Notification
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
interface RoomListSectionHeaderViewModelProps {
tag: string;
@@ -139,8 +139,7 @@ export class RoomListSectionHeaderViewModel
* Handle changes to custom section data.
*/
private onCustomSectionDataChange(): void {
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
const sectionData = customSectionData[this.props.tag];
const sectionData = isCustomSectionTag(this.props.tag) ? getCustomSectionData()[this.props.tag] : undefined;
if (sectionData) {
this.snapshot.merge({ title: sectionData.name });
}
@@ -36,10 +36,10 @@ import { hasCreateRoomRights } from "./utils";
import { keepIfSame } from "../../utils/keepIfSame";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
import { getCustomSectionData, isCustomSectionTag, CHATS_TAG } from "../../stores/room-list-v3/section";
import SettingsStore from "../../settings/SettingsStore";
import { tagRoom } from "../../utils/room/tagRoom";
import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom";
import { CHATS_TAG } from "../../stores/room-list-v3/section";
/**
* Tracks the position of the active room within a specific section.
@@ -287,8 +287,7 @@ export class RoomListViewModel
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
const title = TAG_TO_TITLE_MAP[tag] || customSections[tag]?.name || tag;
const title = TAG_TO_TITLE_MAP[tag] || (isCustomSectionTag(tag) && getCustomSectionData()[tag]?.name) || tag;
const viewModel = new RoomListSectionHeaderViewModel({
tag,
title,
@@ -692,11 +691,13 @@ function computeSections(
roomsResult: RoomsResult,
isSectionExpanded: (tag: string) => boolean,
): { sections: Section[]; isFlatList: boolean } {
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
const customSections = getCustomSectionData();
const sections = roomsResult.sections
// Only include sections that have rooms or are custom sections (which may be empty but should still be shown)
.filter((section) => section.rooms.length > 0 || customSections[section.tag])
.filter(
(section) => section.rooms.length > 0 || (isCustomSectionTag(section.tag) && customSections[section.tag]),
)
// Remove roomIds for sections that are currently collapsed according to their section header view model
.map((section) => ({
...section,