Skip to content

Commit 47f8012

Browse files
authored
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
1 parent c4c32b8 commit 47f8012

9 files changed

Lines changed: 178 additions & 34 deletions

File tree

apps/web/src/stores/room-list-v3/RoomListStoreV3.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { DefaultTagID } from "./skip-list/tag";
4040
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
4141
import { TagFilter } from "./skip-list/filters/TagFilter";
4242
import { filterBoolean } from "../../utils/arrays";
43-
import { CHATS_TAG, createSection, deleteSection, editSection } from "./section";
43+
import { CHATS_TAG, createSection, deleteSection, editSection, getOrderedCustomSections } from "./section";
4444

4545
/**
4646
* These are the filters passed to the room skip list.
@@ -522,7 +522,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
522522
* Load the custom sections from the settings store and update the sorted tags.
523523
*/
524524
private loadCustomSections(): void {
525-
const orderedCustomSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
525+
const orderedCustomSections = getOrderedCustomSections();
526526
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
527527
}
528528
}

apps/web/src/stores/room-list-v3/section.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
1414
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
1515
import { DefaultTagID, type TagID } from "./skip-list/tag";
1616

17-
type Tag = string;
18-
1917
/**
2018
* A synthetic tag used to represent the "Chats" section, which contains
2119
* every room that does not belong to any other explicit tag section.
@@ -27,12 +25,14 @@ export const CHATS_TAG = "chats";
2725
*/
2826
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
2927

28+
type CustomTag = `${typeof CUSTOM_SECTION_TAG_PREFIX}${string}`;
29+
3030
/**
3131
* Checks if a given tag is a custom section tag.
3232
* @param tag - The tag to check.
3333
* @returns True if the tag is a custom section tag, false otherwise.
3434
*/
35-
export function isCustomSectionTag(tag: string): boolean {
35+
export function isCustomSectionTag(tag: string): tag is CustomTag {
3636
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
3737
}
3838

@@ -58,18 +58,56 @@ export function isSectionTag(tagId: TagID): boolean {
5858
* 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.
5959
*/
6060
type CustomSection = {
61-
tag: Tag;
61+
tag: CustomTag;
6262
name: string;
6363
};
6464

65+
/**
66+
* Type guard to check if a value is a valid CustomSection object.
67+
*/
68+
function isValidCustomSection(value: unknown): value is CustomSection {
69+
return (
70+
typeof value === "object" &&
71+
value !== null &&
72+
isCustomSectionTag((value as Record<string, unknown>).tag as string) &&
73+
typeof (value as Record<string, unknown>).name === "string"
74+
);
75+
}
76+
6577
/**
6678
* 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).
6779
*/
68-
export type CustomSectionsData = Record<Tag, CustomSection>;
80+
export type CustomSectionsData = Record<CustomTag, CustomSection>;
6981
/**
7082
* Ordered list of custom section tags.
7183
*/
72-
export type OrderedCustomSections = Tag[];
84+
export type OrderedCustomSections = CustomTag[];
85+
86+
/**
87+
* Retrieves the custom sections data from the settings.
88+
* Invalid or malformed entries are dropped and the cleaned data is persisted back to settings.
89+
*/
90+
export function getCustomSectionData(): CustomSectionsData {
91+
const raw = SettingsStore.getValue("RoomList.CustomSectionData");
92+
// Data are malformed
93+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
94+
95+
// Filter out invalid entries
96+
return Object.fromEntries(
97+
Object.entries(raw).filter(([key, value]) => isValidCustomSection(value) && value.tag === key),
98+
) as CustomSectionsData;
99+
}
100+
101+
/**
102+
* Retrieves the ordered list of custom section tags from the settings.
103+
* 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.
104+
*/
105+
export function getOrderedCustomSections(): OrderedCustomSections {
106+
const sectionData = getCustomSectionData();
107+
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
108+
const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : [];
109+
return orderedSections.filter((tag) => tag in sectionData);
110+
}
73111

74112
/**
75113
* 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> {
83121
const [shouldCreateSection, sectionName] = await modal.finished;
84122
if (!shouldCreateSection || !sectionName) return undefined;
85123

86-
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
124+
const tag: CustomTag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
87125
const newSection: CustomSection = { tag, name: sectionName };
88126

89127
// Save the new section data
90-
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
128+
const sectionData = getCustomSectionData();
91129
sectionData[tag] = newSection;
92130
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
93131

94132
// Add the new section to the ordered list of sections
95-
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
133+
const orderedSections = getOrderedCustomSections();
96134
orderedSections.push(tag);
97135
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
98136
return tag;
@@ -103,7 +141,11 @@ export async function createSection(): Promise<string | undefined> {
103141
* @param tag - The tag of the section to edit.
104142
*/
105143
export async function editSection(tag: string): Promise<void> {
106-
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
144+
if (!isCustomSectionTag(tag)) {
145+
logger.info("Unknown section tag, cannot edit section", tag);
146+
return;
147+
}
148+
const sectionData = getCustomSectionData();
107149
const section = sectionData[tag];
108150
if (!section) {
109151
logger.info("Unknown section tag, cannot edit section", tag);
@@ -127,7 +169,11 @@ export async function editSection(tag: string): Promise<void> {
127169
* @param isEmpty - Whether the section is empty (has no rooms). If the section is not empty, the confirmation dialog will show a warning message.
128170
*/
129171
export async function deleteSection(tag: string, isEmpty: boolean): Promise<void> {
130-
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData");
172+
if (!isCustomSectionTag(tag)) {
173+
logger.info("Unknown section tag, cannot delete section", tag);
174+
return;
175+
}
176+
const sectionData = getCustomSectionData();
131177
if (!sectionData[tag]) {
132178
logger.info("Unknown section tag, cannot delete section", tag);
133179
return;
@@ -138,8 +184,7 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise<void
138184
if (!shouldRemoveSection) return;
139185

140186
// Remove the section from the ordered list of sections
141-
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
142-
const newOrderedSections = orderedSections.filter((sectionTag) => sectionTag !== tag);
187+
const newOrderedSections = getOrderedCustomSections().filter((sectionTag) => sectionTag !== tag);
143188
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections);
144189

145190
// Remove the section data

apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"
3939
import PosthogTrackers from "../../PosthogTrackers";
4040
import { type Call, CallEvent } from "../../models/Call";
4141
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
42+
import { getCustomSectionData, isDefaultSectionTag } from "../../stores/room-list-v3/section";
4243
import { _t } from "../../languageHandler";
43-
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
4444

4545
interface RoomItemProps {
4646
room: Room;
@@ -424,7 +424,7 @@ export class RoomListItemViewModel
424424
* Order follows the canonical section order from RoomListStoreV3.
425425
*/
426426
private static buildSections(roomTags: Room["tags"]): Section[] {
427-
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
427+
const customSectionData = getCustomSectionData();
428428

429429
return (
430430
RoomListStoreV3.instance.orderedSectionTags

apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { NotificationStateEvents } from "../../stores/notifications/Notification
1717
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
1818
import SettingsStore from "../../settings/SettingsStore";
1919
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
20-
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
20+
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
2121

2222
interface RoomListSectionHeaderViewModelProps {
2323
tag: string;
@@ -139,8 +139,7 @@ export class RoomListSectionHeaderViewModel
139139
* Handle changes to custom section data.
140140
*/
141141
private onCustomSectionDataChange(): void {
142-
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
143-
const sectionData = customSectionData[this.props.tag];
142+
const sectionData = isCustomSectionTag(this.props.tag) ? getCustomSectionData()[this.props.tag] : undefined;
144143
if (sectionData) {
145144
this.snapshot.merge({ title: sectionData.name });
146145
}

apps/web/src/viewmodels/room-list/RoomListViewModel.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ import { hasCreateRoomRights } from "./utils";
3636
import { keepIfSame } from "../../utils/keepIfSame";
3737
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
3838
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
39+
import { getCustomSectionData, isCustomSectionTag, CHATS_TAG } from "../../stores/room-list-v3/section";
3940
import SettingsStore from "../../settings/SettingsStore";
4041
import { tagRoom } from "../../utils/room/tagRoom";
4142
import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom";
42-
import { CHATS_TAG } from "../../stores/room-list-v3/section";
4343

4444
/**
4545
* Tracks the position of the active room within a specific section.
@@ -287,8 +287,7 @@ export class RoomListViewModel
287287
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
288288
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
289289

290-
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
291-
const title = TAG_TO_TITLE_MAP[tag] || customSections[tag]?.name || tag;
290+
const title = TAG_TO_TITLE_MAP[tag] || (isCustomSectionTag(tag) && getCustomSectionData()[tag]?.name) || tag;
292291
const viewModel = new RoomListSectionHeaderViewModel({
293292
tag,
294293
title,
@@ -692,11 +691,13 @@ function computeSections(
692691
roomsResult: RoomsResult,
693692
isSectionExpanded: (tag: string) => boolean,
694693
): { sections: Section[]; isFlatList: boolean } {
695-
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
694+
const customSections = getCustomSectionData();
696695

697696
const sections = roomsResult.sections
698697
// Only include sections that have rooms or are custom sections (which may be empty but should still be shown)
699-
.filter((section) => section.rooms.length > 0 || customSections[section.tag])
698+
.filter(
699+
(section) => section.rooms.length > 0 || (isCustomSectionTag(section.tag) && customSections[section.tag]),
700+
)
700701
// Remove roomIds for sections that are currently collapsed according to their section header view model
701702
.map((section) => ({
702703
...section,

apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,7 @@ describe("RoomListStoreV3", () => {
833833
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
834834
if (setting === "feature_room_list_sections") return true;
835835
if (setting === "RoomList.OrderedCustomSections") return [];
836+
if (setting === "RoomList.CustomSectionData") return {};
836837
return false;
837838
});
838839
}
@@ -1093,6 +1094,7 @@ describe("RoomListStoreV3", () => {
10931094
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
10941095
if (setting === "feature_room_list_sections") return true;
10951096
if (setting === "RoomList.OrderedCustomSections") return [];
1097+
if (setting === "RoomList.CustomSectionData") return {};
10961098
return false;
10971099
});
10981100

@@ -1107,11 +1109,13 @@ describe("RoomListStoreV3", () => {
11071109
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
11081110
if (setting === "feature_room_list_sections") return true;
11091111
if (setting === "RoomList.OrderedCustomSections") return [customTag];
1112+
if (setting === "RoomList.CustomSectionData")
1113+
return { [customTag]: { tag: customTag, name: "Custom" } };
11101114
return false;
11111115
});
11121116

11131117
// Trigger the settings watcher
1114-
settingsWatcher("RoomList.OrderedCustomSections");
1118+
await Promise.resolve(settingsWatcher("RoomList.OrderedCustomSections"));
11151119

11161120
// Now there should be 4 sections (Favourite, custom, Chats, LowPriority)
11171121
expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(4);

0 commit comments

Comments
 (0)