From 8986612ae43c550d717d76a8064f49cca89ab909 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 20 Feb 2025 12:42:47 +0530 Subject: [PATCH 1/2] WIP: Draft implementation of RLS --- src/@types/global.d.ts | 2 + src/stores/room-list-v3/RoomListStoreV3.ts | 133 ++++++++++++++++++ .../room-list-v3/filters/AllRoomsFilter.ts | 20 +++ .../room-list-v3/filters/FavouriteFilter.ts | 21 +++ src/stores/room-list-v3/filters/index.ts | 20 +++ .../room-list-v3/sorters/AlphabeticSorter.ts | 18 +++ .../room-list-v3/sorters/RecencySorter.ts | 16 +++ src/stores/room-list-v3/sorters/index.ts | 12 ++ 8 files changed, 242 insertions(+) create mode 100644 src/stores/room-list-v3/RoomListStoreV3.ts create mode 100644 src/stores/room-list-v3/filters/AllRoomsFilter.ts create mode 100644 src/stores/room-list-v3/filters/FavouriteFilter.ts create mode 100644 src/stores/room-list-v3/filters/index.ts create mode 100644 src/stores/room-list-v3/sorters/AlphabeticSorter.ts create mode 100644 src/stores/room-list-v3/sorters/RecencySorter.ts create mode 100644 src/stores/room-list-v3/sorters/index.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..41095f96f76 --- /dev/null +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -0,0 +1,133 @@ +/* +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 type { Filter, FilterKey } from "./filters"; +import type { Sorter } from "./sorters"; +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 { AllRoomsFilter } from "./filters/AllRoomsFilter"; +import { FavouriteFilter } from "./filters/FavouriteFilter"; +import { RecencySorter } from "./sorters/RecencySorter"; + +export class RoomListStoreV3Class extends AsyncStoreWithClient { + /** + * This is the unsorted, unfiltered raw list of rooms from the js-sdk. + */ + private rooms: Room[] = []; + + private readonly msc3946ProcessDynamicPredecessor: boolean; + + /** + * Mapping from FilterKey | string to a set of Rooms + */ + private filteredRooms: Map> = new Map(); + private sortedRooms: Room[] = []; + + private readonly filters: Filter[] = [new AllRoomsFilter(), new FavouriteFilter()]; + private sorter: Sorter = new RecencySorter(); + + public constructor(dispatcher: MatrixDispatcher) { + super(dispatcher); + this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); + } + + public setSorter(sorter: Sorter): void { + this.sorter = sorter; + } + + public getFilteredRooms(filters: FilterKey[]): Set | null { + const sets = filters.map((f) => this.filteredRooms.get(f)).filter((s) => !!s); + if (!sets.length) return null; + if (sets.length === 1) return sets[0]; + // Find the intersection of these filtered sets + const intersection = new Set(); + const [firstSet, ...otherSets] = sets; + for (const room of firstSet) { + if (!otherSets.some((set) => !set.has(room))) intersection.add(room); + } + return intersection; + } + + public getSortedFilteredRooms(filters: FilterKey[]): Array { + const filteredSet = this.getFilteredRooms(filters); + if (!filteredSet) return this.sortedRooms; + return this.sortedRooms?.filter((room) => filteredSet.has(room)); + } + + protected async onReady(): Promise { + const rooms = this.fetchRoomsFromSdk(); + if (!rooms) return; + this.rooms = rooms; + } + + protected async onAction(payload: ActionPayload): Promise { + if ( + ![ + "MatrixActions.Room.receipt", + "MatrixActions.Room.tags", + "MatrixActions.Room.timeline", + "MatrixActions.Event.decrypted", + "MatrixActions.accountData", + "MatrixActions.Room.myMembership", + ].includes(payload.action) + ) + return; + setTimeout(() => { + this.recalculate(); + }); + } + + private recalculate(): void { + const t0 = performance.now(); + this.fetchRoomsFromSdk(); + this.filterRooms(); + this.sortRooms(); + const t1 = performance.now(); + console.log("RLS Performance, time taken = ", t1 - t0); + this.emit(LISTS_UPDATE_EVENT); + } + + private filterRooms(): void { + for (const filter of this.filters) { + const rooms = filter.filter(this.rooms); + this.filteredRooms.set(filter.key, new Set(rooms)); + } + } + + private sortRooms(): void { + this.sortedRooms = this.sorter.sort(this.rooms); + } + + 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; diff --git a/src/stores/room-list-v3/filters/AllRoomsFilter.ts b/src/stores/room-list-v3/filters/AllRoomsFilter.ts new file mode 100644 index 00000000000..bfe7968dfd0 --- /dev/null +++ b/src/stores/room-list-v3/filters/AllRoomsFilter.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 { FilterKeysEnum } from "."; + +export class AllRoomsFilter implements Filter { + public filter(rooms: Room[]): Room[] { + return rooms; + } + + public get key(): FilterKeysEnum.All { + return FilterKeysEnum.All; + } +} diff --git a/src/stores/room-list-v3/filters/FavouriteFilter.ts b/src/stores/room-list-v3/filters/FavouriteFilter.ts new file mode 100644 index 00000000000..7458ef8ddc9 --- /dev/null +++ b/src/stores/room-list-v3/filters/FavouriteFilter.ts @@ -0,0 +1,21 @@ +/* +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 { FilterKeysEnum } from "."; +import { DefaultTagID } from "../../room-list/models"; + +export class FavouriteFilter implements Filter { + public filter(rooms: Room[]): Room[] { + return rooms.filter((room) => !!room.tags[DefaultTagID.Favourite]); + } + + public get key(): FilterKeysEnum.Favorite { + return FilterKeysEnum.Favorite; + } +} diff --git a/src/stores/room-list-v3/filters/index.ts b/src/stores/room-list-v3/filters/index.ts new file mode 100644 index 00000000000..866b7b7b36c --- /dev/null +++ b/src/stores/room-list-v3/filters/index.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"; + +export const enum FilterKeysEnum { + All, + Favorite, +} + +export type FilterKey = FilterKeysEnum | string; + +export interface Filter { + key: FilterKey; + filter(rooms: Room[]): Room[]; +} diff --git a/src/stores/room-list-v3/sorters/AlphabeticSorter.ts b/src/stores/room-list-v3/sorters/AlphabeticSorter.ts new file mode 100644 index 00000000000..1161f183ac5 --- /dev/null +++ b/src/stores/room-list-v3/sorters/AlphabeticSorter.ts @@ -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 type { Sorter } from "."; + +export class AlphabeticSorter implements Sorter { + public sort(rooms: Room[]): Room[] { + const collator = new Intl.Collator(); + return rooms.sort((a, b) => { + return collator.compare(a.name, b.name); + }); + } +} diff --git a/src/stores/room-list-v3/sorters/RecencySorter.ts b/src/stores/room-list-v3/sorters/RecencySorter.ts new file mode 100644 index 00000000000..c1d922ba03f --- /dev/null +++ b/src/stores/room-list-v3/sorters/RecencySorter.ts @@ -0,0 +1,16 @@ +/* +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 { Sorter } from "."; +import { sortRooms } from "../../room-list/algorithms/tag-sorting/RecentAlgorithm"; + +export class RecencySorter implements Sorter { + public sort(rooms: Room[]): Room[] { + return sortRooms(rooms); + } +} diff --git a/src/stores/room-list-v3/sorters/index.ts b/src/stores/room-list-v3/sorters/index.ts new file mode 100644 index 00000000000..56243fbe6bb --- /dev/null +++ b/src/stores/room-list-v3/sorters/index.ts @@ -0,0 +1,12 @@ +/* +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 interface Sorter { + sort(rooms: Room[]): Room[]; +} From 525b42e747307c8791beaf6e3683b9bbf59da6b3 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Fri, 21 Feb 2025 15:31:00 +0530 Subject: [PATCH 2/2] WIP - SkipList for performance --- src/stores/room-list-v3/RoomListStoreV3.ts | 9 ++ src/stores/room-list-v3/RoomSkipList.ts | 149 ++++++++++++++++++ .../algorithms/tag-sorting/RecentAlgorithm.ts | 2 +- 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/stores/room-list-v3/RoomSkipList.ts diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 41095f96f76..69d54988e36 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -18,6 +18,7 @@ import { LISTS_UPDATE_EVENT } from "../room-list/RoomListStore"; import { AllRoomsFilter } from "./filters/AllRoomsFilter"; import { FavouriteFilter } from "./filters/FavouriteFilter"; import { RecencySorter } from "./sorters/RecencySorter"; +import { RoomSkipList } from "./RoomSkipList"; export class RoomListStoreV3Class extends AsyncStoreWithClient { /** @@ -25,6 +26,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { */ private rooms: Room[] = []; + private roomSkipList?: RoomSkipList; + private readonly msc3946ProcessDynamicPredecessor: boolean; /** @@ -114,6 +117,12 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r)); return rooms; } + + public createSkipList(): void { + const rooms = this.fetchRoomsFromSdk(); + this.roomSkipList = new RoomSkipList(); + this.roomSkipList.create(rooms!); + } } export default class RoomListStoreV3 { diff --git a/src/stores/room-list-v3/RoomSkipList.ts b/src/stores/room-list-v3/RoomSkipList.ts new file mode 100644 index 00000000000..5984c52b774 --- /dev/null +++ b/src/stores/room-list-v3/RoomSkipList.ts @@ -0,0 +1,149 @@ +/* +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 { getLastTs, sortRooms } from "../room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; + +// See https://en.wikipedia.org/wiki/Skip_list + +export class RecencySorter { + public sort(rooms: Room[]): Room[] { + return sortRooms(rooms); + } + + public comparator(roomA: Room, roomB: Room): number { + let myUserId = ""; + if (MatrixClientPeg.get()) { + myUserId = MatrixClientPeg.get()!.getSafeUserId(); + } + const roomALastTs = getLastTs(roomA, myUserId); + const roomBLastTs = getLastTs(roomB, myUserId); + + return roomBLastTs - roomALastTs; + } +} + +export class RoomSkipList { + private readonly sentinels: Sentinel[] = []; + private readonly roomNodeMap: Map = new Map(); + private sorter: RecencySorter = new RecencySorter(); + + public create(rooms: Room[]): void { + if (rooms.length === 0) { + // No rooms, just create an empty level + this.sentinels[0] = new Sentinel(0); + return; + } + + // 1. First sort the rooms and create a base sorted linked list + const sortedRoomNodes = this.sorter.sort(rooms).map((room) => new RoomNode(room)); + let sentinel = new Sentinel(0); + for (const node of sortedRoomNodes) { + sentinel.setNext(node); + this.roomNodeMap.set(node.room.roomId, node); + } + + // 2. Create the rest of the sub linked lists + do { + this.sentinels[sentinel.level] = sentinel; + sentinel = sentinel.generateNextLevel(); + // todo: set max level + } while (sentinel.size > 1); + } + + public removeRoom(room: Room): void { + const existingNode = this.roomNodeMap.get(room.roomId); + if (existingNode) { + for (const sentinel of this.sentinels) { + sentinel.removeNode(existingNode); + } + } + } + + public addRoom(room: Room): void { + // First, let's delete this room from the skip list + this.removeRoom(room); + const newNode = new RoomNode(room); + + // Start on the highest level, account for empty levels + let sentinel = this.sentinels[0]; + for (let i = this.sentinels.length - 1; i >= 0; --i) { + if (this.sentinels[i].size) { + sentinel = this.sentinels[i]; + break; + } + } + + const current = sentinel.head; + for (let i = sentinel.level; i >= 0; --i) { + let nextNode = current?.next[i]; + while (this.sorter.comparator(room, nextNode.room) > 0) {} + } + } +} + +export class Sentinel { + private current?: RoomNode; + public head?: RoomNode; + public size: number = 0; + + public constructor(public readonly level: number) {} + + public setNext(node: RoomNode): void { + if (!this.head) this.head = node; + if (!this.current) { + this.current = node; + } else { + node.previous[this.level] = this.current; + this.current.next[this.level] = node; + this.current = node; + } + this.size++; + } + + public generateNextLevel(): Sentinel { + const nextLevelSentinel = new Sentinel(this.level + 1); + let current = this.head; + while (current) { + if (this.shouldPromote()) { + nextLevelSentinel.setNext(current); + } + current = current.next[this.level]; + } + return nextLevelSentinel; + } + + public removeNode(node: RoomNode): void { + // Let's first see if this node is even in this level + const nodeInThisLevel = this.head === node || node.previous[this.level]; + if (!nodeInThisLevel) { + // This node is not in this sentinel level, so nothing to do. + return; + } + const prev = node.previous[this.level]; + if (prev) { + prev.next[this.level] = node.next[this.level]; + } else { + // This node was the head since it has no back links! + // so update the head. + this.head = node.next[this.level]; + } + this.size--; + } + + private shouldPromote(): boolean { + return Math.random() < 0.5; + } +} + +export class RoomNode { + public constructor(public readonly room: Room) {} + + public next: RoomNode[] = []; + public previous: RoomNode[] = []; +} diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index 78afecc0e62..e84f538993d 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -62,7 +62,7 @@ export const sortRooms = (rooms: Room[]): Room[] => { }); }; -const getLastTs = (r: Room, userId: string): number => { +export const getLastTs = (r: Room, userId: string): number => { const mainTimelineLastTs = ((): number => { // Apparently we can have rooms without timelines, at least under testing // environments. Just return MAX_INT when this happens.