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 17:50:33 +02: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,
@@ -833,6 +833,7 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
if (setting === "RoomList.CustomSectionData") return {};
return false;
});
}
@@ -1093,6 +1094,7 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
if (setting === "RoomList.CustomSectionData") return {};
return false;
});
@@ -1107,11 +1109,13 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [customTag];
if (setting === "RoomList.CustomSectionData")
return { [customTag]: { tag: customTag, name: "Custom" } };
return false;
});
// Trigger the settings watcher
settingsWatcher("RoomList.OrderedCustomSections");
await Promise.resolve(settingsWatcher("RoomList.OrderedCustomSections"));
// Now there should be 4 sections (Favourite, custom, Chats, LowPriority)
expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(4);
@@ -8,12 +8,14 @@
import Modal from "../../../../src/Modal";
import SettingsStore from "../../../../src/settings/SettingsStore";
import {
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
createSection,
editSection,
deleteSection,
getCustomSectionData,
getOrderedCustomSections,
isDefaultSectionTag,
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
isSectionTag,
} from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
@@ -25,6 +27,68 @@ describe("section", () => {
jest.restoreAllMocks();
});
describe("getCustomSectionData", () => {
const validTag = "element.io.section.valid";
const invalidTag = "element.io.section.invalid";
const validEntry = { tag: validTag, name: "Valid" };
beforeEach(() => {
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it.each([null, false, 42, "string", []] as const)("returns an empty object when the raw value is %p", (raw) => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(raw as any);
expect(getCustomSectionData()).toEqual({});
});
it("returns valid entries and drops invalid ones", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
[validTag]: validEntry,
[invalidTag]: { tag: "element.io.section.mismatch", name: "Bad" },
});
expect(getCustomSectionData()).toEqual({ [validTag]: validEntry });
});
it("drops entries that fail the isValidCustomSection check", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
"element.io.section.null-val": null,
"element.io.section.str-val": "not-an-object",
"element.io.section.bad-tag": { tag: "not-a-custom-tag", name: "Bad" },
"element.io.section.bad-name": { tag: "element.io.section.bad-name", name: 42 },
});
expect(getCustomSectionData()).toEqual({});
});
});
describe("getOrderedCustomSections", () => {
const tag = "element.io.section.abc";
beforeEach(() => {
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("returns an empty array when the raw value is not an array", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return "not-an-array";
return null;
});
const result = getOrderedCustomSections();
expect(result).toEqual([]);
});
it("removes unknown sections and saves the cleaned list", () => {
const knownTag = "element.io.section.known";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.CustomSectionData") return { [knownTag]: { tag: knownTag, name: "Known" } };
if (setting === "RoomList.OrderedCustomSections") return [knownTag, tag];
return null;
});
expect(getOrderedCustomSections()).toEqual([knownTag]);
});
});
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
@@ -69,6 +133,8 @@ describe("section", () => {
const existingTag = "element.io.section.existing";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [existingTag];
if (setting === "RoomList.CustomSectionData")
return { [existingTag]: { tag: existingTag, name: "Existing" } };
return null;
});
jest.spyOn(Modal, "createDialog").mockReturnValue({
@@ -79,15 +145,16 @@ describe("section", () => {
await createSection();
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = Object.values(customDataCall![3] as Record<string, { tag: string; name: string }>)[0];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toMatch(/^element\.io\.section\./);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
const savedOrder = orderedCall![3] as string[];
expect(savedOrder[0]).toBe(existingTag);
expect(savedOrder[1]).toMatch(/^element\.io\.section\./);
const newTag = savedOrder[1];
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = (customDataCall![3] as Record<string, { tag: string; name: string }>)[newTag];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toBe(newTag);
});
});
@@ -100,6 +167,12 @@ describe("section", () => {
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the tag is not a custom section tag", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await editSection("m.favourite");
expect(createDialogSpy).not.toHaveBeenCalled();
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");
@@ -157,13 +230,20 @@ describe("section", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.CustomSectionData") return { [tag]: { tag, name: "My Section" } };
if (setting === "RoomList.CustomSectionData")
return { [tag]: { tag, name: "My Section" }, [otherTag]: { tag: otherTag, name: "Other Section" } };
if (setting === "RoomList.OrderedCustomSections") return [otherTag, tag];
return null;
});
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the tag is not a custom section tag", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await deleteSection("m.favourite", false);
expect(createDialogSpy).not.toHaveBeenCalled();
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");
@@ -80,8 +80,10 @@ describe("RoomListItemViewModel", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showMessagePreview") return false;
if (setting === "RoomList.OrderedCustomSections") return [];
if (setting === "RoomList.CustomSectionData") return {};
return false;
});
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
@@ -154,6 +154,19 @@ describe("RoomListSectionHeaderViewModel", () => {
expect(vm.getSnapshot().title).toBe("My Section");
});
it("should not update title when tag is not a custom section tag", () => {
const vm = new RoomListSectionHeaderViewModel({
tag: "m.favourite",
title: "Favourites",
spaceId: "!space:server",
onToggleExpanded,
});
watchCallback();
expect(vm.getSnapshot().title).toBe("Favourites");
});
});
describe("editSection", () => {