From 21e9d93e69e37b2696f4df885344dee5e4992840 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 4 Mar 2025 16:57:41 +0530 Subject: [PATCH] Room List Store: Filter rooms by active space (#29399) * Add method to await space store setup Otherwise, the room list store will get incorrect information about spaces and thus will produce an incorrect roomlist. * Implement a way to filter by active space Implement a way to filter by active space * Fix broken jest tests * Fix typo * Rename `isReady` to `storeReadyPromise` * Fix mock in test --- src/stores/room-list-v3/RoomListStoreV3.ts | 21 +++++++ src/stores/room-list-v3/skip-list/RoomNode.ts | 22 ++++++++ .../room-list-v3/skip-list/RoomSkipList.ts | 38 +++++++++++++ src/stores/spaces/SpaceStore.ts | 11 ++++ .../room-list-v3/RoomListStoreV3-test.ts | 55 ++++++++++++++++++- .../skip-list/RoomSkipList-test.ts | 8 +++ 6 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 0e70409761..36a05f8357 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -21,6 +21,8 @@ import { RecencySorter } from "./skip-list/sorters/RecencySorter"; import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership"; +import SpaceStore from "../spaces/SpaceStore"; +import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; /** * This store allows for fast retrieval of the room list in a sorted and filtered manner. @@ -34,6 +36,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { public constructor(dispatcher: MatrixDispatcher) { super(dispatcher); this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, () => { + this.onActiveSpaceChanged(); + }); + SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => this.onActiveSpaceChanged()); } /** @@ -53,6 +59,14 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { else return []; } + /** + * Get a list of sorted rooms that belong to the currently active space. + */ + public getSortedRoomsInActiveSpace(): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace()); + else return []; + } + /** * Re-sort the list of rooms by alphabetic order. */ @@ -78,6 +92,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); this.roomSkipList = new RoomSkipList(sorter); const rooms = this.getRooms(); + await SpaceStore.instance.storeReadyPromise; this.roomSkipList.seed(rooms); this.emit(LISTS_UPDATE_EVENT); } @@ -178,6 +193,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.roomSkipList.addRoom(room); this.emit(LISTS_UPDATE_EVENT); } + + private onActiveSpaceChanged(): void { + if (!this.roomSkipList) return; + this.roomSkipList.calculateActiveSpaceForNodes(); + this.emit(LISTS_UPDATE_EVENT); + } } export default class RoomListStoreV3 { diff --git a/src/stores/room-list-v3/skip-list/RoomNode.ts b/src/stores/room-list-v3/skip-list/RoomNode.ts index cbc2a3346f..af792aa757 100644 --- a/src/stores/room-list-v3/skip-list/RoomNode.ts +++ b/src/stores/room-list-v3/skip-list/RoomNode.ts @@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; +import SpaceStore from "../../spaces/SpaceStore"; /** * Room skip list stores room nodes. @@ -13,6 +14,8 @@ import type { Room } from "matrix-js-sdk/src/matrix"; * in different levels. */ export class RoomNode { + private _isInActiveSpace: boolean = false; + public constructor(public readonly room: Room) {} /** @@ -26,4 +29,23 @@ export class RoomNode { * eg: previous[i] gives the previous room node from this room node in level i. */ public previous: RoomNode[] = []; + + /** + * Whether the room associated with this room node belongs to + * the currently active space. + * @see {@link SpaceStoreClass#activeSpace} to understand what active + * space means. + */ + public get isInActiveSpace(): boolean { + return this._isInActiveSpace; + } + + /** + * Check if this room belongs to the active space and store the result + * in {@link RoomNode#isInActiveSpace}. + */ + public checkIfRoomBelongsToActiveSpace(): void { + const activeSpace = SpaceStore.instance.activeSpace; + this._isInActiveSpace = SpaceStore.instance.isRoomInSpace(activeSpace, this.room.roomId); + } } diff --git a/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/src/stores/room-list-v3/skip-list/RoomSkipList.ts index 260786594f..323b4c8fcb 100644 --- a/src/stores/room-list-v3/skip-list/RoomSkipList.ts +++ b/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -44,9 +44,22 @@ export class RoomSkipList implements Iterable { this.levels[currentLevel.level] = currentLevel; currentLevel = currentLevel.generateNextLevel(); } while (currentLevel.size > 1); + + // 3. Go through the list of rooms and mark nodes in active space + this.calculateActiveSpaceForNodes(); + this.initialized = true; } + /** + * Go through all the room nodes and check if they belong to the active space. + */ + public calculateActiveSpaceForNodes(): void { + for (const node of this.roomNodeMap.values()) { + node.checkIfRoomBelongsToActiveSpace(); + } + } + /** * Change the sorting algorithm used by the skip list. * This will reset the list and will rebuild from scratch. @@ -81,6 +94,7 @@ export class RoomSkipList implements Iterable { this.removeRoom(room); const newNode = new RoomNode(room); + newNode.checkIfRoomBelongsToActiveSpace(); this.roomNodeMap.set(room.roomId, newNode); /** @@ -159,6 +173,10 @@ export class RoomSkipList implements Iterable { return new SortedRoomIterator(this.levels[0].head!); } + public getRoomsInActiveSpace(): SortedSpaceFilteredIterator { + return new SortedSpaceFilteredIterator(this.levels[0].head!); + } + /** * The number of rooms currently in the skip list. */ @@ -179,3 +197,23 @@ class SortedRoomIterator implements Iterator { }; } } + +class SortedSpaceFilteredIterator implements Iterator { + public constructor(private current: RoomNode) {} + + public [Symbol.iterator](): SortedSpaceFilteredIterator { + return this; + } + + public next(): IteratorResult { + let current = this.current; + while (current && !current.isInActiveSpace) { + current = current.next[0]; + } + if (!current) return { value: undefined, done: true }; + this.current = current.next[0]; + return { + value: current.room, + }; + } +} diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 8e8b4cc273..690beaa0b7 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -21,6 +21,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; +import { defer } from "matrix-js-sdk/src/utils"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -152,6 +153,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private _enabledMetaSpaces: MetaSpace[] = []; /** Whether the feature flag is set for MSC3946 */ private _msc3946ProcessDynamicPredecessor: boolean = SettingsStore.getValue("feature_dynamic_room_predecessors"); + private _storeReadyDeferred = defer(); public constructor() { super(defaultDispatcher, {}); @@ -162,6 +164,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { SettingsStore.monitorSetting("feature_dynamic_room_predecessors", null); } + /** + * A promise that resolves when the space store is ready. + * This happens after an initial hierarchy of spaces and rooms has been computed. + */ + public get storeReadyPromise(): Promise { + return this._storeReadyDeferred.promise; + } + /** * Get the order of meta spaces to display in the space panel. * @@ -1201,6 +1211,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } else { this.switchSpaceIfNeeded(); } + this._storeReadyDeferred.resolve(); } private sendUserProperties(): void { diff --git a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index ad3ccdcfd9..c91606034e 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -11,11 +11,13 @@ import { logger } from "matrix-js-sdk/src/logger"; import { RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3"; import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient"; import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; -import { mkEvent, mkMessage, stubClient, upsertRoomStateEvents } from "../../../test-utils"; +import { mkEvent, mkMessage, mkSpace, stubClient, upsertRoomStateEvents } from "../../../test-utils"; import { getMockedRooms } from "./skip-list/getMockedRooms"; import { AlphabeticSorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter"; import { LISTS_UPDATE_EVENT } from "../../../../src/stores/room-list/RoomListStore"; import dispatcher from "../../../../src/dispatcher/dispatcher"; +import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; +import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -24,10 +26,16 @@ describe("RoomListStoreV3", () => { client.getVisibleRooms = jest.fn().mockReturnValue(rooms); jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); const store = new RoomListStoreV3Class(dispatcher); - store.start(); + await store.start(); return { client, rooms, store, dispatcher }; } + beforeEach(() => { + jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve()); + }); + it("Provides an unsorted list of rooms", async () => { const { store, rooms } = await getRoomListStore(); expect(store.getRooms()).toEqual(rooms); @@ -264,5 +272,48 @@ describe("RoomListStoreV3", () => { expect(fn).not.toHaveBeenCalled(); }); }); + + describe("Spaces", () => { + it("Filtering by spaces work", async () => { + const client = stubClient(); + const rooms = getMockedRooms(client); + + // Let's choose 5 rooms to put in space + const indexes = [6, 8, 13, 27, 75]; + const roomIds = indexes.map((i) => rooms[i].roomId); + const spaceRoom = mkSpace(client, "!space1:matrix.org", [], roomIds); + rooms.push(spaceRoom); + + client.getVisibleRooms = jest.fn().mockReturnValue(rooms); + jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); + + // Mock the space store + jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => { + if (space === MetaSpace.Home && !roomIds.includes(id)) return true; + if (space === spaceRoom.roomId && roomIds.includes(id)) return true; + return false; + }); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + + // The rooms which belong to the space should not be shown + const result = store.getSortedRoomsInActiveSpace().map((r) => r.roomId); + for (const id of roomIds) { + expect(result).not.toContain(id); + } + + // Lets switch to the space + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId); + SpaceStore.instance.emit(UPDATE_SELECTED_SPACE); + expect(fn).toHaveBeenCalled(); + const result2 = store.getSortedRoomsInActiveSpace().map((r) => r.roomId); + for (const id of roomIds) { + expect(result2).toContain(id); + } + }); + }); }); }); diff --git a/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts b/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts index b644aa30e9..1d091350b1 100644 --- a/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts +++ b/test/unit-tests/stores/room-list-v3/skip-list/RoomSkipList-test.ts @@ -14,6 +14,8 @@ import { RoomSkipList } from "../../../../../src/stores/room-list-v3/skip-list/R import { RecencySorter } from "../../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; import { AlphabeticSorter } from "../../../../../src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter"; import { getMockedRooms } from "./getMockedRooms"; +import SpaceStore from "../../../../../src/stores/spaces/SpaceStore"; +import { MetaSpace } from "../../../../../src/stores/spaces"; describe("RoomSkipList", () => { function generateSkipList(roomCount?: number): { @@ -30,6 +32,12 @@ describe("RoomSkipList", () => { return { skipList, rooms, totalRooms: rooms.length, sorter }; } + beforeEach(() => { + jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space) => space === MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => MetaSpace.Home); + jest.spyOn(SpaceStore.instance, "storeReadyPromise", "get").mockImplementation(() => Promise.resolve()); + }); + it("Rooms are in sorted order after initial seed", () => { const { skipList, totalRooms } = generateSkipList(); expect(skipList.size).toEqual(totalRooms);