Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RLS - Draft Implementation #29323

Draft
wants to merge 2 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -99,6 +100,7 @@ declare global {
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStore;
mxRoomListStoreV3: RoomListStoreV3;
mxRoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;
Expand Down
142 changes: 142 additions & 0 deletions src/stores/room-list-v3/RoomListStoreV3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
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";
import { RoomSkipList } from "./RoomSkipList";

export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
/**
* 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<FilterKey, Set<Room>> = 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<Room> | 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<Room>();
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<Room> {
const filteredSet = this.getFilteredRooms(filters);
if (!filteredSet) return this.sortedRooms;
return this.sortedRooms?.filter((room) => filteredSet.has(room));
}

protected async onReady(): Promise<any> {
const rooms = this.fetchRoomsFromSdk();
if (!rooms) return;
this.rooms = rooms;
}

protected async onAction(payload: ActionPayload): Promise<void> {
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;
}

public createSkipList(): void {
const rooms = this.fetchRoomsFromSdk();
this.roomSkipList = new RoomSkipList();
this.roomSkipList.create(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;
149 changes: 149 additions & 0 deletions src/stores/room-list-v3/RoomSkipList.ts
Original file line number Diff line number Diff line change
@@ -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<string, RoomNode> = 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);

Check failure on line 71 in src/stores/room-list-v3/RoomSkipList.ts

View workflow job for this annotation

GitHub Actions / ESLint

'newNode' is assigned a value but never used

Check failure on line 71 in src/stores/room-list-v3/RoomSkipList.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

'newNode' is declared but its value is never read.

// 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];

Check failure on line 84 in src/stores/room-list-v3/RoomSkipList.ts

View workflow job for this annotation

GitHub Actions / ESLint

'nextNode' is never reassigned. Use 'const' instead
while (this.sorter.comparator(room, nextNode.room) > 0) {}

Check failure on line 85 in src/stores/room-list-v3/RoomSkipList.ts

View workflow job for this annotation

GitHub Actions / ESLint

Empty block statement

Check failure on line 85 in src/stores/room-list-v3/RoomSkipList.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

'nextNode' is possibly 'undefined'.
}
}
}

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[] = [];
}
20 changes: 20 additions & 0 deletions src/stores/room-list-v3/filters/AllRoomsFilter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 21 additions & 0 deletions src/stores/room-list-v3/filters/FavouriteFilter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
20 changes: 20 additions & 0 deletions src/stores/room-list-v3/filters/index.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
18 changes: 18 additions & 0 deletions src/stores/room-list-v3/sorters/AlphabeticSorter.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
Loading
Loading