From 7ff1fd259de70d3bddd69abc9ed0cf19bd614754 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 6 Mar 2025 18:13:25 +0530 Subject: [PATCH] Room List Store: Support filters by implementing just the favourite filter (#29433) * Implement the favourite filter * Make the room node capable of dealing with filters - Holds data to indicate which filters apply - Provides method to check if a given set of filters apply to this node - Provides a method to recalculate which filters apply * Wire up the filtering mechanism in skip list * Use filters in the store * Remove else * Use a set instead of map --- src/stores/room-list-v3/RoomListStoreV3.ts | 12 +- src/stores/room-list-v3/skip-list/RoomNode.ts | 28 +++++ .../room-list-v3/skip-list/RoomSkipList.ts | 60 ++++------ .../skip-list/filters/FavouriteFilter.ts | 20 ++++ .../room-list-v3/skip-list/filters/index.ts | 24 ++++ .../room-list-v3/skip-list/iterators.ts | 47 ++++++++ .../room-list-v3/RoomListStoreV3-test.ts | 103 ++++++++++++++++-- 7 files changed, 245 insertions(+), 49 deletions(-) create mode 100644 src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts create mode 100644 src/stores/room-list-v3/skip-list/filters/index.ts create mode 100644 src/stores/room-list-v3/skip-list/iterators.ts diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 36a05f8357..83e73e1b7f 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -11,6 +11,7 @@ import { EventType } from "matrix-js-sdk/src/matrix"; import type { EmptyObject, Room, RoomState } from "matrix-js-sdk/src/matrix"; import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; import type { ActionPayload } from "../../dispatcher/payloads"; +import type { FilterKey } from "./skip-list/filters"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import SettingsStore from "../../settings/SettingsStore"; import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; @@ -23,6 +24,7 @@ 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"; +import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter"; /** * This store allows for fast retrieval of the room list in a sorted and filtered manner. @@ -61,9 +63,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { /** * Get a list of sorted rooms that belong to the currently active space. + * If filterKeys is passed, only the rooms that match the given filters are + * returned. + + * @param filterKeys Optional array of filters that the rooms must match against. */ - public getSortedRoomsInActiveSpace(): Room[] { - if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace()); + public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)); else return []; } @@ -90,7 +96,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { protected async onReady(): Promise { if (this.roomSkipList?.initialized || !this.matrixClient) return; const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); - this.roomSkipList = new RoomSkipList(sorter); + this.roomSkipList = new RoomSkipList(sorter, [new FavouriteFilter()]); const rooms = this.getRooms(); await SpaceStore.instance.storeReadyPromise; this.roomSkipList.seed(rooms); diff --git a/src/stores/room-list-v3/skip-list/RoomNode.ts b/src/stores/room-list-v3/skip-list/RoomNode.ts index af792aa757..6fbcb65588 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 type { Filter, FilterKey } from "./filters"; import SpaceStore from "../../spaces/SpaceStore"; /** @@ -48,4 +49,31 @@ export class RoomNode { const activeSpace = SpaceStore.instance.activeSpace; this._isInActiveSpace = SpaceStore.instance.isRoomInSpace(activeSpace, this.room.roomId); } + + /** + * Aggregates all the filter keys that apply to this room. + * eg: if filterKeysSet.has(Filter.FavouriteFilter) is true, then this room is a favourite room. + */ + private filterKeysSet: Set = new Set(); + + /** + * Returns true if the associated room matches all the provided filters. + * Returns false otherwise. + * @param filterKeys An array of filter keys to check against. + */ + public doesRoomMatchFilters(filterKeys: FilterKey[]): boolean { + return !filterKeys.some((key) => !this.filterKeysSet.has(key)); + } + + /** + * Populates {@link RoomNode#filterKeysSet} by checking if the associated room + * satisfies the given filters. + * @param filters A list of filters + */ + public applyFilters(filters: Filter[]): void { + this.filterKeysSet = new Set(); + for (const filter of filters) { + if (filter.matches(this.room)) this.filterKeysSet.add(filter.key); + } + } } diff --git a/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/src/stores/room-list-v3/skip-list/RoomSkipList.ts index 323b4c8fcb..1653d8068d 100644 --- a/src/stores/room-list-v3/skip-list/RoomSkipList.ts +++ b/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -7,9 +7,11 @@ Please see LICENSE files in the repository root for full details. import type { Room } from "matrix-js-sdk/src/matrix"; import type { Sorter } from "./sorters"; +import type { Filter, FilterKey } from "./filters"; import { RoomNode } from "./RoomNode"; import { shouldPromote } from "./utils"; import { Level } from "./Level"; +import { SortedRoomIterator, SortedSpaceFilteredIterator } from "./iterators"; /** * Implements a skip list that stores rooms using a given sorting algorithm. @@ -20,7 +22,10 @@ export class RoomSkipList implements Iterable { private roomNodeMap: Map = new Map(); public initialized: boolean = false; - public constructor(private sorter: Sorter) {} + public constructor( + private sorter: Sorter, + private filters: Filter[] = [], + ) {} private reset(): void { this.levels = [new Level(0)]; @@ -35,6 +40,7 @@ export class RoomSkipList implements Iterable { const sortedRoomNodes = this.sorter.sort(rooms).map((room) => new RoomNode(room)); let currentLevel = this.levels[0]; for (const node of sortedRoomNodes) { + node.applyFilters(this.filters); currentLevel.setNext(node); this.roomNodeMap.set(node.room.roomId, node); } @@ -95,6 +101,7 @@ export class RoomSkipList implements Iterable { const newNode = new RoomNode(room); newNode.checkIfRoomBelongsToActiveSpace(); + newNode.applyFilters(this.filters); this.roomNodeMap.set(room.roomId, newNode); /** @@ -173,8 +180,22 @@ export class RoomSkipList implements Iterable { return new SortedRoomIterator(this.levels[0].head!); } - public getRoomsInActiveSpace(): SortedSpaceFilteredIterator { - return new SortedSpaceFilteredIterator(this.levels[0].head!); + /** + * Returns an iterator that can be used to generate a list of sorted rooms that belong + * to the currently active space. Passing filterKeys will further filter the list such + * that only rooms that match the filters are returned. + * + * @example To get an array of rooms: + * Array.from(RLS.getRoomsInActiveSpace()); + * + * @example Use a for ... of loop to iterate over rooms: + * for(const room of RLS.getRoomsInActiveSpace()) { something(room); } + * + * @example Additional filtering: + * Array.from(RLS.getRoomsInActiveSpace([FilterKeys.Favourite])); + */ + public getRoomsInActiveSpace(filterKeys: FilterKey[] = []): SortedSpaceFilteredIterator { + return new SortedSpaceFilteredIterator(this.levels[0].head!, filterKeys); } /** @@ -184,36 +205,3 @@ export class RoomSkipList implements Iterable { return this.levels[0].size; } } - -class SortedRoomIterator implements Iterator { - public constructor(private current: RoomNode) {} - - public next(): IteratorResult { - const current = this.current; - if (!current) return { value: undefined, done: true }; - this.current = current.next[0]; - return { - value: current.room, - }; - } -} - -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/room-list-v3/skip-list/filters/FavouriteFilter.ts b/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts new file mode 100644 index 0000000000..6af657b81e --- /dev/null +++ b/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts @@ -0,0 +1,20 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { Room } from "matrix-js-sdk/src/matrix"; +import type { Filter } from "."; +import { FilterKey } from "."; +import { DefaultTagID } from "../../../room-list/models"; + +export class FavouriteFilter implements Filter { + public matches(room: Room): boolean { + return !!room.tags[DefaultTagID.Favourite]; + } + + public get key(): FilterKey.FavouriteFilter { + return FilterKey.FavouriteFilter; + } +} diff --git a/src/stores/room-list-v3/skip-list/filters/index.ts b/src/stores/room-list-v3/skip-list/filters/index.ts new file mode 100644 index 0000000000..0580c95764 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/filters/index.ts @@ -0,0 +1,24 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { Room } from "matrix-js-sdk/src/matrix"; + +export const enum FilterKey { + FavouriteFilter, +} + +export interface Filter { + /** + * Boolean return value indicates whether this room satisfies + * the filter condition. + */ + matches(room: Room): boolean; + + /** + * Used to identify this particular filter. + */ + key: FilterKey; +} diff --git a/src/stores/room-list-v3/skip-list/iterators.ts b/src/stores/room-list-v3/skip-list/iterators.ts new file mode 100644 index 0000000000..a992c79aef --- /dev/null +++ b/src/stores/room-list-v3/skip-list/iterators.ts @@ -0,0 +1,47 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { Room } from "matrix-js-sdk/src/matrix"; +import type { RoomNode } from "./RoomNode"; +import type { FilterKey } from "./filters"; + +export class SortedRoomIterator implements Iterator { + public constructor(private current: RoomNode) {} + + public next(): IteratorResult { + const current = this.current; + if (!current) return { value: undefined, done: true }; + this.current = current.next[0]; + return { + value: current.room, + }; + } +} + +export class SortedSpaceFilteredIterator implements Iterator { + public constructor( + private current: RoomNode, + private readonly filters: FilterKey[], + ) {} + + public [Symbol.iterator](): SortedSpaceFilteredIterator { + return this; + } + + public next(): IteratorResult { + let current = this.current; + while (current) { + if (current.isInActiveSpace && current.doesRoomMatchFilters(this.filters)) break; + current = current.next[0]; + } + if (!current) return { value: undefined, done: true }; + this.current = current.next[0]; + return { + value: current.room, + }; + } +} 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 c91606034e..7edd5b3ef9 100644 --- a/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import { EventType, KnownMembership, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; 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"; @@ -18,6 +19,8 @@ import { LISTS_UPDATE_EVENT } from "../../../../src/stores/room-list/RoomListSto import dispatcher from "../../../../src/dispatcher/dispatcher"; import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces"; +import { DefaultTagID } from "../../../../src/stores/room-list/models"; +import { FilterKey } from "../../../../src/stores/room-list-v3/skip-list/filters"; describe("RoomListStoreV3", () => { async function getRoomListStore() { @@ -273,19 +276,40 @@ describe("RoomListStoreV3", () => { }); }); + /** + * Create a space and add it to rooms + * @param rooms An array of rooms to which the new space is added. + * @param inSpaceIndices A list of indices from which rooms are added to the space. + */ + function createSpace(rooms: Room[], inSpaceIndices: number[], client: MatrixClient) { + const roomIds = inSpaceIndices.map((i) => rooms[i].roomId); + const spaceRoom = mkSpace(client, "!space1:matrix.org", [], roomIds); + rooms.push(spaceRoom); + return { spaceRoom, roomIds }; + } + + function setupMocks(spaceRoom: Room, roomIds: string[]) { + 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; + }); + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId); + } + + function getClientAndRooms() { + const client = stubClient(); + const rooms = getMockedRooms(client); + client.getVisibleRooms = jest.fn().mockReturnValue(rooms); + jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); + return { client, rooms }; + } + describe("Spaces", () => { it("Filtering by spaces work", async () => { - const client = stubClient(); - const rooms = getMockedRooms(client); - + const { client, rooms } = getClientAndRooms(); // 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); + const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client); // Mock the space store jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => { @@ -315,5 +339,64 @@ describe("RoomListStoreV3", () => { } }); }); + + describe("Filters", () => { + it("filters by both space and favourite", async () => { + const { client, rooms } = getClientAndRooms(); + // Let's choose 5 rooms to put in space + const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client); + + // Let's say that 8, 27 an 75 are favourite rooms + [8, 27, 75].forEach((i) => { + rooms[i].tags[DefaultTagID.Favourite] = {}; + }); + + setupMocks(spaceRoom, roomIds); + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + // Sorted, filtered rooms should be 8, 27 and 75 + const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]); + expect(result).toHaveLength(3); + for (const i of [8, 27, 75]) { + expect(result).toContain(rooms[i]); + } + }); + + it("filters are recalculated on room update", async () => { + const { client, rooms } = getClientAndRooms(); + // Let's choose 5 rooms to put in space + const { spaceRoom, roomIds } = createSpace(rooms, [6, 8, 13, 27, 75], client); + + // Let's say that 8, 27 an 75 are favourite rooms + [8, 27, 75].forEach((i) => { + rooms[i].tags[DefaultTagID.Favourite] = {}; + }); + + setupMocks(spaceRoom, roomIds); + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + // Let's say 27 got unfavourited + const fn = jest.fn(); + store.on(LISTS_UPDATE_EVENT, fn); + rooms[27].tags = {}; + dispatcher.dispatch( + { + action: "MatrixActions.Room.tags", + room: rooms[27], + }, + true, + ); + expect(fn).toHaveBeenCalled(); + + // Sorted, filtered rooms should be 27 and 75 + const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]); + expect(result).toHaveLength(2); + for (const i of [8, 75]) { + expect(result).toContain(rooms[i]); + } + }); + }); }); });