From 97035fda32f02b50ed8835cc6c3f714290131494 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 15 Apr 2026 16:03:10 +0200 Subject: [PATCH] feat: add custom section to room list store v3 --- .../stores/room-list-v3/RoomListStoreV3.ts | 41 +++++++++- .../room-list-v3/RoomListStoreV3-test.ts | 81 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 71c5a1a9cb..c6625daf3f 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -40,6 +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 { createSection } from "./section"; /** * These are the filters passed to the room skip list. @@ -59,6 +60,8 @@ export enum RoomListStoreV3Event { ListsUpdate = "lists_update", // The event which is called when the room list is loaded. ListsLoaded = "lists_loaded", + /** Fired when a new section is created in the room list. */ + SectionCreated = "section_created", } // The result object for returning rooms from the store @@ -89,6 +92,8 @@ export const CHATS_TAG = "chats"; export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate; export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; +export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated; + /** * This store allows for fast retrieval of the room list in a sorted and filtered manner. * This is the third such implementation hence the "V3". @@ -108,7 +113,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { /** * Defines the display order of sections. */ - private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]; + private sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]; private readonly msc3946ProcessDynamicPredecessor: boolean; @@ -125,6 +130,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.onActiveSpaceChanged(); }); SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => this.onActiveSpaceChanged()); + SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => this.onOrderedCustomSectionsChange()); } /** @@ -196,6 +202,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { protected async onReady(): Promise { if (this.roomSkipList?.initialized || !this.matrixClient) return; + this.loadCustomSections(); + const sorter = this.getPreferredSorter(this.matrixClient.getSafeUserId()); this.roomSkipList = new RoomSkipList(sorter, this.getSkipListFilters()); @@ -463,6 +471,37 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { }; }); } + + /** + * Handle changes to the order of custom sections. + * Reloads the custom sections, updates the skip list filters to reflect the new order and emits an update. + * Emit {@link LISTS_UPDATE_EVENT}. + */ + private onOrderedCustomSectionsChange(): void { + this.loadCustomSections(); + if (!this.roomSkipList) return; + this.roomSkipList.useNewFilters(this.getSkipListFilters()); + this.scheduleEmit(); + } + + /** + * Create a new section. + * Emits {@link SECTION_CREATED_EVENT} and {@link LISTS_UPDATE_EVENT} if the section was successfully created. + */ + public async createSection(): Promise { + const sectionIsCreated = await createSection(); + if (!sectionIsCreated) return; + this.emit(SECTION_CREATED_EVENT); + this.scheduleEmit(); + } + + /** + * Load the custom sections from the settings store and update the sorted tags. + */ + private loadCustomSections(): void { + const orderedCustomSections = SettingsStore.getValue("RoomList.OrderedCustomSections"); + this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority]; + } } export default class RoomListStoreV3 { diff --git a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index 1944efe7e6..11ba95d864 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -14,9 +14,11 @@ import type { RoomNotificationState } from "../../../../src/stores/notifications import { CHATS_TAG, LISTS_UPDATE_EVENT, + SECTION_CREATED_EVENT, RoomListStoreV3Class, type Section, } from "../../../../src/stores/room-list-v3/RoomListStoreV3"; +import * as sectionModule from "../../../../src/stores/room-list-v3/section"; import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient"; import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils"; @@ -830,6 +832,7 @@ describe("RoomListStoreV3", () => { function enableSections(): void { jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { if (setting === "feature_room_list_sections") return true; + if (setting === "RoomList.OrderedCustomSections") return []; return false; }); } @@ -1007,6 +1010,84 @@ describe("RoomListStoreV3", () => { const favSection = findSection(sections, DefaultTagID.Favourite)!; expect(favSection.rooms).toContain(rooms[3]); }); + + describe("createSection", () => { + it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => { + enableSections(); + getClientAndRooms(); + jest.spyOn(sectionModule, "createSection").mockResolvedValue(true); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const sectionCreatedListener = jest.fn(); + const listsUpdateListener = jest.fn(); + store.on(SECTION_CREATED_EVENT, sectionCreatedListener); + store.on(LISTS_UPDATE_EVENT, listsUpdateListener); + + await store.createSection(); + + expect(sectionCreatedListener).toHaveBeenCalled(); + expect(listsUpdateListener).toHaveBeenCalled(); + }); + + it("does not emit when section creation is cancelled", async () => { + enableSections(); + getClientAndRooms(); + jest.spyOn(sectionModule, "createSection").mockResolvedValue(false); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const sectionCreatedListener = jest.fn(); + store.on(SECTION_CREATED_EVENT, sectionCreatedListener); + + await store.createSection(); + + expect(sectionCreatedListener).not.toHaveBeenCalled(); + }); + }); + + it("updates sections when RoomList.OrderedCustomSections setting changes", async () => { + enableSections(); + const { rooms } = getClientAndRooms(); + + let settingsWatcher: (settingName: string) => void = () => {}; + jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, _roomId, callback) => { + if (settingName === "RoomList.OrderedCustomSections") settingsWatcher = callback as () => void; + return "watcher-id"; + }); + + const customTag = "element.io.section.custom"; + + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { + if (setting === "feature_room_list_sections") return true; + if (setting === "RoomList.OrderedCustomSections") return []; + return false; + }); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + // Initial state: 3 sections (Favourite, Chats, LowPriority) + expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(3); + + // Mark a room with the custom tag and update the settings + rooms[0].tags = { [customTag]: { order: 0 } }; + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { + if (setting === "feature_room_list_sections") return true; + if (setting === "RoomList.OrderedCustomSections") return [customTag]; + return false; + }); + + // Trigger the settings watcher + settingsWatcher("RoomList.OrderedCustomSections"); + + // Now there should be 4 sections (Favourite, custom, Chats, LowPriority) + expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(4); + const customSection = findSection(store.getSortedRoomsInActiveSpace().sections, customTag)!; + expect(customSection.rooms).toContain(rooms[0]); + }); }); describe("Muted rooms", () => {