From 498942e73339655362ccc1d668ede741ca1bde24 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 24 Feb 2025 15:42:58 +0530 Subject: [PATCH 01/10] Implement enough of the new store to get a list of rooms --- src/@types/global.d.ts | 2 + src/stores/room-list-v3/RoomListStoreV3.ts | 69 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/stores/room-list-v3/RoomListStoreV3.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 1df84ad344b..154a6504c67 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -47,6 +47,7 @@ import { type DeepReadonly } from "./common"; import type MatrixChat from "../components/structures/MatrixChat"; import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; import { type ModuleApiType } from "../modules/Api.ts"; +import type RoomListStoreV3 from "../stores/room-list-v3/RoomListStoreV3.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -99,6 +100,7 @@ declare global { mxToastStore: ToastStore; mxDeviceListener: DeviceListener; mxRoomListStore: RoomListStore; + mxRoomListStoreV3: RoomListStoreV3; mxRoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; mxIntegrationManagers: typeof IntegrationManagers; diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts new file mode 100644 index 00000000000..10791dd9f46 --- /dev/null +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -0,0 +1,69 @@ +/* +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 { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; +import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; +import type { ActionPayload } from "../../dispatcher/payloads"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import SettingsStore from "../../settings/SettingsStore"; +import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore"; +import { RoomSkipList } from "./skip-list/RoomSkipList"; +import { RecencySorter } from "./skip-list/sorters/RecencySorter"; + +export class RoomListStoreV3Class extends AsyncStoreWithClient { + private roomSkipList?: RoomSkipList; + private readonly msc3946ProcessDynamicPredecessor: boolean; + + public constructor(dispatcher: MatrixDispatcher) { + super(dispatcher); + this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); + } + + public getSortedRooms(): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); + else return []; + } + + protected async onReady(): Promise { + if (this.roomSkipList?.initialized || !this.matrixClient) return; + const sorter = new RecencySorter(this.matrixClient.getSafeUserId() ?? ""); + this.roomSkipList = new RoomSkipList(sorter); + const rooms = this.fetchRoomsFromSdk(); + if (!rooms) return; + this.roomSkipList.seed(rooms); + this.emit(LISTS_UPDATE_EVENT); + } + + protected async onAction(payload: ActionPayload): Promise { + return; + } + + private fetchRoomsFromSdk(): Room[] | null { + if (!this.matrixClient) return null; + let rooms = this.matrixClient.getVisibleRooms(this.msc3946ProcessDynamicPredecessor); + rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); + return rooms; + } +} + +export default class RoomListStoreV3 { + private static internalInstance: RoomListStoreV3Class; + + public static get instance(): RoomListStoreV3Class { + if (!RoomListStoreV3.internalInstance) { + const instance = new RoomListStoreV3Class(defaultDispatcher); + instance.start(); + RoomListStoreV3.internalInstance = instance; + } + + return this.internalInstance; + } +} + +window.mxRoomListStoreV3 = RoomListStoreV3.instance; From d65589b93f50b25a9742f4376c4673649277a878 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 24 Feb 2025 21:44:05 +0530 Subject: [PATCH 02/10] Make it possible to swap sorting algorithm --- src/stores/room-list-v3/RoomListStoreV3.ts | 33 +++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 10791dd9f46..4b661c97029 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -15,6 +15,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore"; import { RoomSkipList } from "./skip-list/RoomSkipList"; import { RecencySorter } from "./skip-list/sorters/RecencySorter"; +import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; export class RoomListStoreV3Class extends AsyncStoreWithClient { private roomSkipList?: RoomSkipList; @@ -25,17 +26,36 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); } + public getRooms(): Room[] { + let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? []; + rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); + return rooms; + } + public getSortedRooms(): Room[] { if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); else return []; } + public useAlphabeticSorting(): void { + if (this.roomSkipList) { + const sorter = new AlphabeticSorter(); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + + public useRecencySorting(): void { + if (this.roomSkipList && this.matrixClient) { + const sorter = new RecencySorter(this.matrixClient?.getSafeUserId() ?? ""); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + protected async onReady(): Promise { if (this.roomSkipList?.initialized || !this.matrixClient) return; - const sorter = new RecencySorter(this.matrixClient.getSafeUserId() ?? ""); + const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); this.roomSkipList = new RoomSkipList(sorter); - const rooms = this.fetchRoomsFromSdk(); - if (!rooms) return; + const rooms = this.getRooms(); this.roomSkipList.seed(rooms); this.emit(LISTS_UPDATE_EVENT); } @@ -43,13 +63,6 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { protected async onAction(payload: ActionPayload): Promise { return; } - - private fetchRoomsFromSdk(): Room[] | null { - if (!this.matrixClient) return null; - let rooms = this.matrixClient.getVisibleRooms(this.msc3946ProcessDynamicPredecessor); - rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); - return rooms; - } } export default class RoomListStoreV3 { From 727db6cb13e0ef036c731091e23d9b1c9dbea875 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 09:37:52 +0530 Subject: [PATCH 03/10] Don't attach to window object We don't want the store to be created if the labs flag is off --- src/@types/global.d.ts | 2 -- src/stores/room-list-v3/RoomListStoreV3.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 154a6504c67..1df84ad344b 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -47,7 +47,6 @@ import { type DeepReadonly } from "./common"; import type MatrixChat from "../components/structures/MatrixChat"; import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; import { type ModuleApiType } from "../modules/Api.ts"; -import type RoomListStoreV3 from "../stores/room-list-v3/RoomListStoreV3.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -100,7 +99,6 @@ declare global { mxToastStore: ToastStore; mxDeviceListener: DeviceListener; mxRoomListStore: RoomListStore; - mxRoomListStoreV3: RoomListStoreV3; mxRoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; mxIntegrationManagers: typeof IntegrationManagers; diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 4b661c97029..d6ce63713c8 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -78,5 +78,3 @@ export default class RoomListStoreV3 { return this.internalInstance; } } - -window.mxRoomListStoreV3 = RoomListStoreV3.instance; From b6ff82cca7aa7e6d07715f42c5c2e7418f10796a Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 09:42:46 +0530 Subject: [PATCH 04/10] Remove the store class Probably best to include this PR with the minimal vm implmentation --- src/stores/room-list-v3/RoomListStoreV3.ts | 80 ---------------------- 1 file changed, 80 deletions(-) delete mode 100644 src/stores/room-list-v3/RoomListStoreV3.ts diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts deleted file mode 100644 index d6ce63713c8..00000000000 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* -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 { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; -import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; -import type { ActionPayload } from "../../dispatcher/payloads"; -import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; -import SettingsStore from "../../settings/SettingsStore"; -import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; -import defaultDispatcher from "../../dispatcher/dispatcher"; -import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore"; -import { RoomSkipList } from "./skip-list/RoomSkipList"; -import { RecencySorter } from "./skip-list/sorters/RecencySorter"; -import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; - -export class RoomListStoreV3Class extends AsyncStoreWithClient { - private roomSkipList?: RoomSkipList; - private readonly msc3946ProcessDynamicPredecessor: boolean; - - public constructor(dispatcher: MatrixDispatcher) { - super(dispatcher); - this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); - } - - public getRooms(): Room[] { - let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? []; - rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); - return rooms; - } - - public getSortedRooms(): Room[] { - if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); - else return []; - } - - public useAlphabeticSorting(): void { - if (this.roomSkipList) { - const sorter = new AlphabeticSorter(); - this.roomSkipList.useNewSorter(sorter, this.getRooms()); - } - } - - public useRecencySorting(): void { - if (this.roomSkipList && this.matrixClient) { - const sorter = new RecencySorter(this.matrixClient?.getSafeUserId() ?? ""); - this.roomSkipList.useNewSorter(sorter, this.getRooms()); - } - } - - protected async onReady(): Promise { - if (this.roomSkipList?.initialized || !this.matrixClient) return; - const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); - this.roomSkipList = new RoomSkipList(sorter); - const rooms = this.getRooms(); - this.roomSkipList.seed(rooms); - this.emit(LISTS_UPDATE_EVENT); - } - - protected async onAction(payload: ActionPayload): Promise { - return; - } -} - -export default class RoomListStoreV3 { - private static internalInstance: RoomListStoreV3Class; - - public static get instance(): RoomListStoreV3Class { - if (!RoomListStoreV3.internalInstance) { - const instance = new RoomListStoreV3Class(defaultDispatcher); - instance.start(); - RoomListStoreV3.internalInstance = instance; - } - - return this.internalInstance; - } -} From ba951ed72827b092b92e12de799a094d69ffa0d3 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 09:45:55 +0530 Subject: [PATCH 05/10] Create a new room list store that wraps around the skip list --- src/stores/room-list-v3/RoomListStoreV3.ts | 81 ++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/stores/room-list-v3/RoomListStoreV3.ts diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts new file mode 100644 index 00000000000..ec7c6926e1d --- /dev/null +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -0,0 +1,81 @@ +/* +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 { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; +import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; +import type { ActionPayload } from "../../dispatcher/payloads"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import SettingsStore from "../../settings/SettingsStore"; +import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore"; +import { RoomSkipList } from "./skip-list/RoomSkipList"; +import { RecencySorter } from "./skip-list/sorters/RecencySorter"; +import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; + +export class RoomListStoreV3Class extends AsyncStoreWithClient { + private roomSkipList?: RoomSkipList; + private readonly msc3946ProcessDynamicPredecessor: boolean; + + public constructor(dispatcher: MatrixDispatcher) { + super(dispatcher); + this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); + } + + public getRooms(): Room[] { + let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? []; + rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); + return rooms; + } + + public getSortedRooms(): Room[] { + if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList); + else return []; + } + + public useAlphabeticSorting(): void { + if (this.roomSkipList) { + const sorter = new AlphabeticSorter(); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + + public useRecencySorting(): void { + if (this.roomSkipList && this.matrixClient) { + const sorter = new RecencySorter(this.matrixClient?.getSafeUserId() ?? ""); + this.roomSkipList.useNewSorter(sorter, this.getRooms()); + } + } + + protected async onReady(): Promise { + if (this.roomSkipList?.initialized || !this.matrixClient) return; + const sorter = new RecencySorter(this.matrixClient.getSafeUserId()); + this.roomSkipList = new RoomSkipList(sorter); + const rooms = this.getRooms(); + this.roomSkipList.seed(rooms); + this.emit(LISTS_UPDATE_EVENT); + } + + protected async onAction(payload: ActionPayload): Promise { + return; + } +} + +export default class RoomListStoreV3 { + private static internalInstance: RoomListStoreV3Class; + + public static get instance(): RoomListStoreV3Class { + if (!RoomListStoreV3.internalInstance) { + const instance = new RoomListStoreV3Class(defaultDispatcher); + instance.start(); + RoomListStoreV3.internalInstance = instance; + } + + return this.internalInstance; + } +} + From c42d02c631af9cf5016384db971eec55d3a5be8c Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 11:53:30 +0530 Subject: [PATCH 06/10] Create a minimal view model --- .../viewmodels/roomlist/RoomListViewModel.tsx | 18 +++++++++++++++ .../views/rooms/RoomListView/RoomListView.tsx | 23 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/components/viewmodels/roomlist/RoomListViewModel.tsx diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx new file mode 100644 index 00000000000..97312d05049 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -0,0 +1,18 @@ +/* +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 RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3"; + +export interface RoomListViewState { + rooms: Room[]; +} + +export function useRoomListViewModel(): RoomListViewState { + const rooms = RoomListStoreV3.instance.getSortedRooms(); + return { rooms }; +} diff --git a/src/components/views/rooms/RoomListView/RoomListView.tsx b/src/components/views/rooms/RoomListView/RoomListView.tsx index 2aa11269ffb..3266256d674 100644 --- a/src/components/views/rooms/RoomListView/RoomListView.tsx +++ b/src/components/views/rooms/RoomListView/RoomListView.tsx @@ -6,11 +6,14 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { AutoSizer, List } from "react-virtualized"; +import type { ListRowProps } from "react-virtualized"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../../settings/UIFeature"; import { RoomListSearch } from "./RoomListSearch"; import { RoomListHeaderView } from "./RoomListHeaderView"; +import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; type RoomListViewProps = { /** @@ -25,11 +28,31 @@ type RoomListViewProps = { */ export const RoomListView: React.FC = ({ activeSpace }) => { const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); + const { rooms } = useRoomListViewModel(); + + const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => { + return ( +
+ {rooms[index].name} +
+ ); + }; return (
{displayRoomSearch && } + + {({ height, width }) => ( + + )} +
); }; From 57f32fc2bec6366cd38996df4012466552ad0642 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 25 Feb 2025 15:05:56 +0530 Subject: [PATCH 07/10] Fix CI --- src/stores/room-list-v3/RoomListStoreV3.ts | 1 - .../__snapshots__/RoomListView-test.tsx.snap | 52 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index ec7c6926e1d..d6ce63713c8 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -78,4 +78,3 @@ export default class RoomListStoreV3 { return this.internalInstance; } } - diff --git a/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap index 90e1454f507..bed1b535d64 100644 --- a/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap @@ -21,6 +21,32 @@ exports[` should not render the RoomListSearch component when UI +
+
+
+
+
+
+
+
+
`; @@ -137,6 +163,32 @@ exports[` should render the RoomListSearch component when UIComp
+
+
+
+
+
+
+
+
+
`; From 33d9427aadbd92c7ecadfbbffedca8a9cf64fd5f Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 27 Feb 2025 14:19:46 +0530 Subject: [PATCH 08/10] Implement filtering within skip list --- src/stores/room-list-v3/RoomListStoreV3.ts | 5 ++- src/stores/room-list-v3/skip-list/RoomNode.ts | 15 ++++++++ .../room-list-v3/skip-list/RoomSkipList.ts | 35 ++++++++++++++++++- .../skip-list/filters/FavouriteFilter.ts | 20 +++++++++++ .../room-list-v3/skip-list/filters/index.ts | 25 +++++++++++++ 5 files changed, 98 insertions(+), 2 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 diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index d6ce63713c8..dba6c65fe05 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; import type { ActionPayload } from "../../dispatcher/payloads"; +import type { Filter } from "./skip-list/filters"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import SettingsStore from "../../settings/SettingsStore"; import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; @@ -16,10 +17,12 @@ import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore"; import { RoomSkipList } from "./skip-list/RoomSkipList"; import { RecencySorter } from "./skip-list/sorters/RecencySorter"; import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; +import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter"; export class RoomListStoreV3Class extends AsyncStoreWithClient { private roomSkipList?: RoomSkipList; private readonly msc3946ProcessDynamicPredecessor: boolean; + private filters: Filter[] = [new FavouriteFilter()]; public constructor(dispatcher: MatrixDispatcher) { super(dispatcher); @@ -54,7 +57,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, this.filters); const rooms = this.getRooms(); this.roomSkipList.seed(rooms); this.emit(LISTS_UPDATE_EVENT); diff --git a/src/stores/room-list-v3/skip-list/RoomNode.ts b/src/stores/room-list-v3/skip-list/RoomNode.ts index cbc2a3346f4..3bd9a2d2248 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, Filters } from "./filters"; /** * Room skip list stores room nodes. @@ -26,4 +27,18 @@ export class RoomNode { * eg: previous[i] gives the previous room node from this room node in level i. */ public previous: RoomNode[] = []; + + /** + * Aggregates all the filters that apply to this room. + * eg: if filters[Filter.FavouriteFilter] is true, then this room is a favourite + * room. + */ + public filters: Map = new Map(); + + public calculateFilters(filters: Filter[]): void { + for (const filter of filters) { + const matchesFilter = filter.matches(this.room); + this.filters.set(filter.key, matchesFilter); + } + } } diff --git a/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/src/stores/room-list-v3/skip-list/RoomSkipList.ts index 260786594fc..2da18593dac 100644 --- a/src/stores/room-list-v3/skip-list/RoomSkipList.ts +++ b/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -7,6 +7,7 @@ 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, Filters } from "./filters"; import { RoomNode } from "./RoomNode"; import { shouldPromote } from "./utils"; import { Level } from "./Level"; @@ -20,7 +21,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 +39,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.calculateFilters(this.filters); currentLevel.setNext(node); this.roomNodeMap.set(node.room.roomId, node); } @@ -81,6 +86,7 @@ export class RoomSkipList implements Iterable { this.removeRoom(room); const newNode = new RoomNode(room); + newNode.calculateFilters(this.filters); this.roomNodeMap.set(room.roomId, newNode); /** @@ -159,6 +165,10 @@ export class RoomSkipList implements Iterable { return new SortedRoomIterator(this.levels[0].head!); } + public getFiltered(filterKeys: Filters[]): SortedFilteredIterator { + return new SortedFilteredIterator(this.levels[0].head!, filterKeys); + } + /** * The number of rooms currently in the skip list. */ @@ -179,3 +189,26 @@ class SortedRoomIterator implements Iterator { }; } } + +class SortedFilteredIterator implements Iterator { + public constructor( + private current: RoomNode, + private filterKeys: Filters[], + ) {} + + public [Symbol.iterator](): SortedFilteredIterator { + return this; + } + + public next(): IteratorResult { + let current = this.current; + while (current && this.filterKeys.some((key) => !current.filters.get(key))) { + 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 00000000000..0e21d55d711 --- /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 { Filters } 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(): Filters.FavouriteFilter { + return Filters.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 00000000000..f372afe1144 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/filters/index.ts @@ -0,0 +1,25 @@ +/* +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 Filters { + FavouriteFilter, +} + +export interface Filter { + /** + * Boolean return value indicates whether this room satisfies + * the filter condition. + */ + matches(room: Room): boolean; + + /** + * An unique string used to identify a given filter. + */ + key: Filters; +} From ce34cf0159f122108ccdb6ee2556a30107342287 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 27 Feb 2025 14:22:05 +0530 Subject: [PATCH 09/10] Write test --- .../skip-list/RoomSkipList-test.ts | 187 ++++++++++-------- 1 file changed, 105 insertions(+), 82 deletions(-) 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 3172307a818..08917451d94 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 @@ -13,6 +13,9 @@ import { mkMessage, mkStubRoom, stubClient } from "../../../../test-utils"; import { RoomSkipList } from "../../../../../src/stores/room-list-v3/skip-list/RoomSkipList"; 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 { DefaultTagID } from "../../../../../src/stores/room-list/models"; +import { FavouriteFilter } from "../../../../../src/stores/room-list-v3/skip-list/filters/FavouriteFilter"; +import { Filters } from "../../../../../src/stores/room-list-v3/skip-list/filters"; describe("RoomSkipList", () => { function getMockedRooms(client: MatrixClient, roomCount: number = 100): Room[] { @@ -41,100 +44,120 @@ describe("RoomSkipList", () => { return { skipList, rooms, totalRooms: rooms.length, sorter }; } - it("Rooms are in sorted order after initial seed", () => { - const { skipList, totalRooms } = generateSkipList(); - expect(skipList.size).toEqual(totalRooms); - const sortedRooms = [...skipList]; - for (let i = 0; i < totalRooms; ++i) { - expect(sortedRooms[i].roomId).toEqual(`!foo${totalRooms - i - 1}:matrix.org`); - } - }); + describe("Sorting", () => { + it("Rooms are in sorted order after initial seed", () => { + const { skipList, totalRooms } = generateSkipList(); + expect(skipList.size).toEqual(totalRooms); + const sortedRooms = [...skipList]; + for (let i = 0; i < totalRooms; ++i) { + expect(sortedRooms[i].roomId).toEqual(`!foo${totalRooms - i - 1}:matrix.org`); + } + }); + + it("Tolerates multiple, repeated inserts of existing rooms", () => { + const { skipList, rooms, totalRooms } = generateSkipList(); + // Let's choose 5 rooms from the list + const toInsert = [23, 76, 2, 90, 66].map((i) => rooms[i]); + for (const room of toInsert) { + // Insert this room 10 times + for (let i = 0; i < 10; ++i) { + skipList.addRoom(room); + } + } + // Sorting order should be the same as before + const sortedRooms = [...skipList]; + for (let i = 0; i < totalRooms; ++i) { + expect(sortedRooms[i].roomId).toEqual(`!foo${totalRooms - i - 1}:matrix.org`); + } + }); - it("Tolerates multiple, repeated inserts of existing rooms", () => { - const { skipList, rooms, totalRooms } = generateSkipList(); - // Let's choose 5 rooms from the list - const toInsert = [23, 76, 2, 90, 66].map((i) => rooms[i]); - for (const room of toInsert) { - // Insert this room 10 times - for (let i = 0; i < 10; ++i) { + it("Sorting order is maintained when rooms are inserted", () => { + const { skipList, rooms, totalRooms } = generateSkipList(); + // To simulate the worst case, let's say the order gets reversed one by one + for (let i = 0; i < rooms.length; ++i) { + const room = rooms[i]; + const event = mkMessage({ + room: room.roomId, + user: `@foo${i}:matrix.org`, + ts: totalRooms - i, + event: true, + }); + room.timeline.push(event); skipList.addRoom(room); + expect(skipList.size).toEqual(rooms.length); } - } - // Sorting order should be the same as before - const sortedRooms = [...skipList]; - for (let i = 0; i < totalRooms; ++i) { - expect(sortedRooms[i].roomId).toEqual(`!foo${totalRooms - i - 1}:matrix.org`); - } - }); + const sortedRooms = [...skipList]; + for (let i = 0; i < totalRooms; ++i) { + expect(sortedRooms[i].roomId).toEqual(`!foo${i}:matrix.org`); + } + }); - it("Sorting order is maintained when rooms are inserted", () => { - const { skipList, rooms, totalRooms } = generateSkipList(); - // To simulate the worst case, let's say the order gets reversed one by one - for (let i = 0; i < rooms.length; ++i) { - const room = rooms[i]; - const event = mkMessage({ - room: room.roomId, - user: `@foo${i}:matrix.org`, - ts: totalRooms - i, - event: true, + it("Re-sort works when sorter is swapped", () => { + const { skipList, rooms, sorter } = generateSkipList(); + const sortedByRecency = [...rooms].sort((a, b) => sorter.comparator(a, b)); + expect(sortedByRecency).toEqual([...skipList]); + // Now switch over to alphabetic sorter + const newSorter = new AlphabeticSorter(); + skipList.useNewSorter(newSorter, rooms); + const sortedByAlphabet = [...rooms].sort((a, b) => newSorter.comparator(a, b)); + expect(sortedByAlphabet).toEqual([...skipList]); + }); + + describe("Empty skip list functionality", () => { + it("Insertions into empty skip list works", () => { + // Create an empty skip list + const client = stubClient(); + const sorter = new RecencySorter(client.getSafeUserId()); + const roomSkipList = new RoomSkipList(sorter); + expect(roomSkipList.size).toEqual(0); + roomSkipList.seed([]); + expect(roomSkipList.size).toEqual(0); + + // Create some rooms + const totalRooms = 10; + const rooms = getMockedRooms(client, totalRooms); + + // Shuffle and insert the rooms + for (const room of shuffle(rooms)) { + roomSkipList.addRoom(room); + } + + expect(roomSkipList.size).toEqual(totalRooms); + const sortedRooms = [...roomSkipList]; + for (let i = 0; i < totalRooms; ++i) { + expect(sortedRooms[i].roomId).toEqual(`!foo${totalRooms - i - 1}:matrix.org`); + } }); - room.timeline.push(event); - skipList.addRoom(room); - expect(skipList.size).toEqual(rooms.length); - } - const sortedRooms = [...skipList]; - for (let i = 0; i < totalRooms; ++i) { - expect(sortedRooms[i].roomId).toEqual(`!foo${i}:matrix.org`); - } - }); - it("Re-sort works when sorter is swapped", () => { - const { skipList, rooms, sorter } = generateSkipList(); - const sortedByRecency = [...rooms].sort((a, b) => sorter.comparator(a, b)); - expect(sortedByRecency).toEqual([...skipList]); - // Now switch over to alphabetic sorter - const newSorter = new AlphabeticSorter(); - skipList.useNewSorter(newSorter, rooms); - const sortedByAlphabet = [...rooms].sort((a, b) => newSorter.comparator(a, b)); - expect(sortedByAlphabet).toEqual([...skipList]); + it("Tolerates deletions until skip list is empty", () => { + const { skipList, rooms } = generateSkipList(10); + const sorted = [...skipList]; + for (const room of shuffle(rooms)) { + skipList.removeRoom(room); + const i = sorted.findIndex((r) => r.roomId === room.roomId); + sorted.splice(i, 1); + expect([...skipList]).toEqual(sorted); + } + expect(skipList.size).toEqual(0); + }); + }); }); - describe("Empty skip list functionality", () => { - it("Insertions into empty skip list works", () => { - // Create an empty skip list + describe("Filtering", () => { + it("Skiplist produces filtered output", () => { const client = stubClient(); const sorter = new RecencySorter(client.getSafeUserId()); - const roomSkipList = new RoomSkipList(sorter); - expect(roomSkipList.size).toEqual(0); - roomSkipList.seed([]); - expect(roomSkipList.size).toEqual(0); - - // Create some rooms - const totalRooms = 10; - const rooms = getMockedRooms(client, totalRooms); - - // Shuffle and insert the rooms - for (const room of shuffle(rooms)) { - roomSkipList.addRoom(room); - } - - expect(roomSkipList.size).toEqual(totalRooms); - const sortedRooms = [...roomSkipList]; - for (let i = 0; i < totalRooms; ++i) { - expect(sortedRooms[i].roomId).toEqual(`!foo${totalRooms - i - 1}:matrix.org`); + const skipList = new RoomSkipList(sorter, [new FavouriteFilter()]); + const rooms = getMockedRooms(client); + // Let's say that every other room is a favourite room + for (const [i, room] of rooms.entries()) { + if (i % 2 === 0) room.tags[DefaultTagID.Favourite] = {}; } - }); + skipList.seed(rooms); - it("Tolerates deletions until skip list is empty", () => { - const { skipList, rooms } = generateSkipList(10); - const sorted = [...skipList]; - for (const room of shuffle(rooms)) { - skipList.removeRoom(room); - const i = sorted.findIndex((r) => r.roomId === room.roomId); - sorted.splice(i, 1); - expect([...skipList]).toEqual(sorted); - } - expect(skipList.size).toEqual(0); + const expected = sorter.sort(rooms).filter((_, i) => i % 2 === 0); + const result = Array.from(skipList.getFiltered([Filters.FavouriteFilter])); + expect(result).toEqual(expected); }); }); }); From 20af4b82b1625c33bffb5c2f3281be348e5ddf8e Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 27 Feb 2025 16:03:20 +0530 Subject: [PATCH 10/10] Fix broken test --- .../stores/room-list-v3/skip-list/RoomSkipList-test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 08917451d94..9672ef37c3d 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 @@ -149,13 +149,15 @@ describe("RoomSkipList", () => { const sorter = new RecencySorter(client.getSafeUserId()); const skipList = new RoomSkipList(sorter, [new FavouriteFilter()]); const rooms = getMockedRooms(client); + // Let's say that every other room is a favourite room - for (const [i, room] of rooms.entries()) { + const sortedRooms = sorter.sort(rooms); + for (const [i, room] of sortedRooms.entries()) { if (i % 2 === 0) room.tags[DefaultTagID.Favourite] = {}; } skipList.seed(rooms); - const expected = sorter.sort(rooms).filter((_, i) => i % 2 === 0); + const expected = sortedRooms.filter((_, i) => i % 2 === 0); const result = Array.from(skipList.getFiltered([Filters.FavouriteFilter])); expect(result).toEqual(expected); });