diff --git a/src/components/views/rooms/RoomListView.tsx b/src/components/views/rooms/RoomListView.tsx index c5f593decf..58f93b8ffc 100644 --- a/src/components/views/rooms/RoomListView.tsx +++ b/src/components/views/rooms/RoomListView.tsx @@ -5,10 +5,27 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { useEffect, useState } from "react"; + +import type { Room } from "matrix-js-sdk/src/matrix"; +import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3"; +import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/SlidingRoomListStore"; type IProps = unknown; export const RoomListView: React.FC = (props: IProps) => { - return
New Room List
; + const [rooms, setRooms] = useState(RoomListStoreV3.instance.getSortedRooms()); + useEffect(() => { + RoomListStoreV3.instance.on(LISTS_UPDATE_EVENT, () => { + const newRooms = RoomListStoreV3.instance.getSortedRooms(); + setRooms(() => newRooms); + }); + }, []); + return ( +
+ {rooms.map((r) => ( +
{r.name}
+ ))} +
+ ); }; diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index 69d54988e3..9bf8e3b36a 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -8,107 +8,60 @@ 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"; -import { RoomSkipList } from "./RoomSkipList"; +import { RoomSkipList } from "./skip-list/RoomSkipList"; +import type { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; +import { RecencySorter } from "./skip-list/sorters/RecencySorter"; export class RoomListStoreV3Class extends AsyncStoreWithClient { - /** - * This is the unsorted, unfiltered raw list of rooms from the js-sdk. - */ - private rooms: Room[] = []; - private roomSkipList?: RoomSkipList; - 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)); + 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.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.roomSkipList.seed(rooms); 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)); + protected async onAction(payload: ActionPayload): Promise { + if (!this.matrixClient || !this.roomSkipList?.initialized) return; + const room = this.getRoomFromPayload(payload); + if (room) { + setTimeout(() => { + this.roomSkipList!.addRoom(room); + this.emit(LISTS_UPDATE_EVENT); + }); } } - private sortRooms(): void { - this.sortedRooms = this.sorter.sort(this.rooms); + private getRoomFromPayload(payload: ActionPayload): Room | undefined { + if (payload.room) { + return payload.room; + } + if (payload.action === "MatrixActions.Room.timeline") { + const eventPayload = payload; + const roomId = eventPayload.event.getRoomId(); + const room = this.matrixClient?.getRoom(roomId); + return room ?? undefined; + } } private fetchRoomsFromSdk(): Room[] | null { @@ -117,12 +70,6 @@ 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 deleted file mode 100644 index 5984c52b77..0000000000 --- a/src/stores/room-list-v3/RoomSkipList.ts +++ /dev/null @@ -1,149 +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 { 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-v3/filters/AllRoomsFilter.ts b/src/stores/room-list-v3/filters/AllRoomsFilter.ts deleted file mode 100644 index bfe7968dfd..0000000000 --- a/src/stores/room-list-v3/filters/AllRoomsFilter.ts +++ /dev/null @@ -1,20 +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 { 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 deleted file mode 100644 index 7458ef8ddc..0000000000 --- a/src/stores/room-list-v3/filters/FavouriteFilter.ts +++ /dev/null @@ -1,21 +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 { 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 deleted file mode 100644 index 866b7b7b36..0000000000 --- a/src/stores/room-list-v3/filters/index.ts +++ /dev/null @@ -1,20 +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 { 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/skip-list/Level.ts b/src/stores/room-list-v3/skip-list/Level.ts new file mode 100644 index 0000000000..16f42634b7 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/Level.ts @@ -0,0 +1,114 @@ +/* +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 { RoomNode } from "./RoomNode"; +import { shouldPromote } from "./utils"; + +/** + * Represents one level of the skip list + */ +export class Level { + public head?: RoomNode; + private current?: RoomNode; + private _size: number = 0; + + /** + * The number of elements in this level + */ + public get size(): number { + return this._size; + } + + public constructor(public readonly level: number) {} + + /** + * Insert node after current + */ + 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++; + } + + /** + * Iterate through the elements in this level and create + * a new level above this level by probabilistically determining + * whether a given element must be promoted to the new level. + */ + public generateNextLevel(): Level { + const nextLevelSentinel = new Level(this.level + 1); + let current = this.head; + while (current) { + if (shouldPromote()) { + nextLevelSentinel.setNext(current); + } + current = current.next[this.level]; + } + return nextLevelSentinel; + } + + /** + * Removes a given node from this level. + * Does nothing if the given node is not present in this level. + */ + 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) { + const nextNode = node.next[this.level]; + prev.next[this.level] = nextNode; + if (nextNode) nextNode.previous[this.level] = prev; + } else { + // This node was the head since it has no back links! + // so update the head. + const next = node.next[this.level]; + this.head = next; + if (next) next.previous[this.level] = node.previous[this.level]; + } + this._size--; + } + + /** + * Put newNode after node in this level. No checks are done to ensure + * that node is actually present in this level. + */ + public insertAfter(node: RoomNode, newNode: RoomNode): void { + const level = this.level; + const nextNode = node.next[level]; + if (nextNode) { + newNode.next[level] = nextNode; + nextNode.previous[level] = newNode; + } + node.next[level] = newNode; + newNode.previous[level] = node; + this._size++; + } + + /** + * Insert a given node at the head of this level. + */ + public insertAtHead(newNode: RoomNode): void { + const existingNode = this.head; + this.head = newNode; + if (existingNode) { + newNode.next[this.level] = existingNode; + existingNode.previous[this.level] = newNode; + } + this._size++; + } +} diff --git a/src/stores/room-list-v3/skip-list/RoomNode.ts b/src/stores/room-list-v3/skip-list/RoomNode.ts new file mode 100644 index 0000000000..b018e0c162 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/RoomNode.ts @@ -0,0 +1,29 @@ +/* +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"; + +/** + * Room skip list stores room nodes. + * These hold the actual room object and provides references to other nodes + * in different levels. + */ +export class RoomNode { + public constructor(public readonly room: Room) {} + + /** + * This array holds references to the next node in a given level. + * eg: next[i] gives the next room node from this room node in level i. + */ + public next: RoomNode[] = []; + + /** + * This array holds references to the previous node in a given level. + * eg: previous[i] gives the next room node from this room node in level i. + */ + public previous: RoomNode[] = []; +} diff --git a/src/stores/room-list-v3/skip-list/RoomSkipList.ts b/src/stores/room-list-v3/skip-list/RoomSkipList.ts new file mode 100644 index 0000000000..971d095a8c --- /dev/null +++ b/src/stores/room-list-v3/skip-list/RoomSkipList.ts @@ -0,0 +1,166 @@ +/* +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 "./sorters"; +import { RoomNode } from "./RoomNode"; +import { shouldPromote } from "./utils"; +import { Level } from "./Level"; + +/** + * Implements a skip list that stores rooms using a given sorting algorithm. + * See See https://en.wikipedia.org/wiki/Skip_list + */ +export class RoomSkipList implements Iterable { + private readonly levels: Level[] = [new Level(0)]; + private readonly roomNodeMap: Map = new Map(); + public initialized: boolean = false; + + public constructor(private readonly sorter: Sorter) {} + + /** + * Seed the list with an initial list of rooms. + */ + public seed(rooms: Room[]): void { + // 1. First sort the rooms and create a base sorted linked list + const sortedRoomNodes = this.sorter.sort(rooms).map((room) => new RoomNode(room)); + let currentLevel = this.levels[0]; + for (const node of sortedRoomNodes) { + currentLevel.setNext(node); + this.roomNodeMap.set(node.room.roomId, node); + } + + // 2. Create the rest of the sub linked lists + do { + this.levels[currentLevel.level] = currentLevel; + currentLevel = currentLevel.generateNextLevel(); + } while (currentLevel.size > 1); + this.initialized = true; + } + + /** + * Removes a given room from the skip list. + */ + public removeRoom(room: Room): void { + const existingNode = this.roomNodeMap.get(room.roomId); + this.roomNodeMap.delete(room.roomId); + if (existingNode) { + for (const level of this.levels) { + level.removeNode(existingNode); + } + } + } + + /** + * Adds a given room to the correct sorted position in the list. + * If the room is already present in the list, it is first removed. + */ + public addRoom(room: Room): void { + /** + * Remove this room from the skip list if necessary. + */ + this.removeRoom(room); + + const newNode = new RoomNode(room); + this.roomNodeMap.set(room.roomId, newNode); + + /** + * This array tracks where the new node must be inserted in a + * given level. + * The index is the level and the value represents where the + * insertion must happen. + * If the value is null, it simply means that we need to insert + * at the head. + * If the value is a RoomNode, simply insert after this node. + */ + const insertionNodes: (RoomNode | null)[] = []; + + /** + * Now we'll do the actual work of finding where to insert this + * node. + * + * We start at the top most level and move downwards ... + */ + for (let j = this.levels.length - 1; j >= 0; --j) { + const level = this.levels[j]; + + /** + * If the head is undefined, that means this level is empty. + * So mark it as such in insertionNodes and skip over this + * level. + */ + if (!level.head) { + insertionNodes[j] = null; + continue; + } + + /** + * So there's actually some nodes in this level ... + * All we need to do is find the node that is smaller or + * equal to the node that we wish to insert. + */ + let current = level.head; + let previous: RoomNode | null = null; + while (current) { + if (this.sorter.comparator(current.room, room) < 0) { + previous = current; + current = current.next[j]; + } else break; + } + + /** + * previous will now be null if there's no node in this level + * smaller than the node we wish to insert or it will be a + * RoomNode. + * This is exactly what we need to track in insertionNodes! + */ + insertionNodes[j] = previous; + } + + /** + * We're done with difficult part, now we just need to do the + * actual node insertion. + */ + for (const [level, node] of insertionNodes.entries()) { + /** + * Whether our new node should be present in a level + * is decided by coin toss. + */ + if (level === 0 || shouldPromote()) { + const levelObj = this.levels[level]; + if (node) levelObj.insertAfter(node, newNode); + else levelObj.insertAtHead(newNode); + } else { + break; + } + } + } + + public [Symbol.iterator](): SortedRoomIterator { + return new SortedRoomIterator(this.levels[0].head!); + } + + /** + * The number of rooms currently in the skip list. + */ + public get size(): number { + 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, + }; + } +} diff --git a/src/stores/room-list-v3/sorters/AlphabeticSorter.ts b/src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts similarity index 65% rename from src/stores/room-list-v3/sorters/AlphabeticSorter.ts rename to src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts index 1161f183ac..9b1d358238 100644 --- a/src/stores/room-list-v3/sorters/AlphabeticSorter.ts +++ b/src/stores/room-list-v3/skip-list/sorters/AlphabeticSorter.ts @@ -9,10 +9,15 @@ import type { Room } from "matrix-js-sdk/src/matrix"; import type { Sorter } from "."; export class AlphabeticSorter implements Sorter { + private readonly collator = new Intl.Collator(); + public sort(rooms: Room[]): Room[] { - const collator = new Intl.Collator(); return rooms.sort((a, b) => { - return collator.compare(a.name, b.name); + return this.comparator(a, b); }); } + + public comparator(roomA: Room, roomB: Room): number { + return this.collator.compare(roomA.name, roomB.name); + } } diff --git a/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts b/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts new file mode 100644 index 0000000000..7e8b3bd8c7 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/sorters/RecencySorter.ts @@ -0,0 +1,33 @@ +/* +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 { getLastTs } from "../../../room-list/algorithms/tag-sorting/RecentAlgorithm"; + +export class RecencySorter implements Sorter { + public constructor(private myUserId: string) {} + + public sort(rooms: Room[]): Room[] { + const tsCache: { [roomId: string]: number } = {}; + return [...rooms].sort((a, b) => this.comparator(a, b, tsCache)); + } + + public comparator(roomA: Room, roomB: Room, cache?: any): number { + const roomALastTs = this.getTs(roomA, cache); + const roomBLastTs = this.getTs(roomB, cache); + return roomBLastTs - roomALastTs; + } + + private getTs(room: Room, cache?: { [roomId: string]: number }): number { + const ts = cache?.[room.roomId] ?? getLastTs(room, this.myUserId); + if (cache) { + cache[room.roomId] = ts; + } + return ts; + } +} diff --git a/src/stores/room-list-v3/sorters/index.ts b/src/stores/room-list-v3/skip-list/sorters/index.ts similarity index 86% rename from src/stores/room-list-v3/sorters/index.ts rename to src/stores/room-list-v3/skip-list/sorters/index.ts index 56243fbe6b..c71553665e 100644 --- a/src/stores/room-list-v3/sorters/index.ts +++ b/src/stores/room-list-v3/skip-list/sorters/index.ts @@ -9,4 +9,5 @@ import type { Room } from "matrix-js-sdk/src/matrix"; export interface Sorter { sort(rooms: Room[]): Room[]; + comparator(roomA: Room, roomB: Room): number; } diff --git a/src/stores/room-list-v3/skip-list/utils.ts b/src/stores/room-list-v3/skip-list/utils.ts new file mode 100644 index 0000000000..4c0bac4af6 --- /dev/null +++ b/src/stores/room-list-v3/skip-list/utils.ts @@ -0,0 +1,10 @@ +/* +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. +*/ + +export function shouldPromote(): boolean { + return Math.random() < 0.5; +} diff --git a/src/stores/room-list-v3/sorters/RecencySorter.ts b/src/stores/room-list-v3/sorters/RecencySorter.ts deleted file mode 100644 index c1d922ba03..0000000000 --- a/src/stores/room-list-v3/sorters/RecencySorter.ts +++ /dev/null @@ -1,16 +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 { 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/test/unit-tests/stores/room-list-v3/RoomSkipList-test.ts b/test/unit-tests/stores/room-list-v3/RoomSkipList-test.ts new file mode 100644 index 0000000000..0510c4f9d1 --- /dev/null +++ b/test/unit-tests/stores/room-list-v3/RoomSkipList-test.ts @@ -0,0 +1,122 @@ +/* +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 { shuffle } from "lodash"; + +import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +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"; + +describe("RoomSkipList", () => { + function getMockedRooms(client: MatrixClient, roomCount: number = 100): Room[] { + const rooms: Room[] = []; + for (let i = 0; i < roomCount; ++i) { + const roomId = `!foo${i}:matrix.org`; + const room = mkStubRoom(roomId, `Foo Room ${i}`, client); + const event = mkMessage({ room: roomId, user: `@foo${i}:matrix.org`, ts: i + 1, event: true }); + room.timeline.push(event); + rooms.push(room); + } + return rooms; + } + + function generateSkipList(roomCount?: number): { skipList: RoomSkipList; rooms: Room[]; totalRooms: number } { + const client = stubClient(); + const sorter = new RecencySorter(client.getSafeUserId()); + const skipList = new RoomSkipList(sorter); + const rooms = getMockedRooms(client, roomCount); + skipList.seed(rooms); + return { skipList, rooms, totalRooms: rooms.length }; + } + + 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("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); + } + const sortedRooms = [...skipList]; + for (let i = 0; i < totalRooms; ++i) { + expect(sortedRooms[i].roomId).toEqual(`!foo${i}:matrix.org`); + } + }); + + 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`); + } + }); + + 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); + }); + }); +});