Files
element-web/src/stores/room-list-v3/RoomListStoreV3.ts
R Midhun Suresh f4b03a1b06 Room List Store: Save preferred sorting algorithm and use that on app launch (#29493)
* Add `type` property to Sorter

So that we can uniquely identify any given sorting algorithm.

* Add a getter for the active sort algorithm

* Define a setting to store the sorting algorithm

* Add a method to resort the list of rooms

- Just one method where you specify the sorting algorithm by type.
- Persist the new sorting algorithm using SettingsStore.

* On startup, use preferred sorter

* Add tests
2025-03-14 09:41:04 +00:00

272 lines
11 KiB
TypeScript

/*
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 { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/matrix";
import type { EmptyObject, Room, RoomState } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import type { ActionPayload } from "../../dispatcher/payloads";
import type { FilterKey } from "./skip-list/filters";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import SettingsStore from "../../settings/SettingsStore";
import { VisibilityProvider } from "../room-list/filters/VisibilityProvider";
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";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership";
import SpaceStore from "../spaces/SpaceStore";
import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter";
import { UnreadFilter } from "./skip-list/filters/UnreadFilter";
import { PeopleFilter } from "./skip-list/filters/PeopleFilter";
import { RoomsFilter } from "./skip-list/filters/RoomsFilter";
import { InvitesFilter } from "./skip-list/filters/InvitesFilter";
import { MentionsFilter } from "./skip-list/filters/MentionsFilter";
import { LowPriorityFilter } from "./skip-list/filters/LowPriorityFilter";
import { type Sorter, SortingAlgorithm } from "./skip-list/sorters";
import { SettingLevel } from "../../settings/SettingLevel";
/**
* These are the filters passed to the room skip list.
*/
const FILTERS = [
new FavouriteFilter(),
new UnreadFilter(),
new PeopleFilter(),
new RoomsFilter(),
new InvitesFilter(),
new MentionsFilter(),
new LowPriorityFilter(),
];
/**
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
* This is the third such implementation hence the "V3".
* This store is being actively developed so expect the methods to change in future.
*/
export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
private roomSkipList?: RoomSkipList;
private readonly msc3946ProcessDynamicPredecessor: boolean;
public constructor(dispatcher: MatrixDispatcher) {
super(dispatcher);
this.msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, () => {
this.onActiveSpaceChanged();
});
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => this.onActiveSpaceChanged());
}
/**
* Get a list of unsorted, unfiltered rooms.
*/
public getRooms(): Room[] {
let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? [];
rooms = rooms.filter((r) => VisibilityProvider.instance.isRoomVisible(r));
return rooms;
}
/**
* Get a list of sorted rooms.
*/
public getSortedRooms(): Room[] {
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList);
else return [];
}
/**
* Get a list of sorted rooms that belong to the currently active space.
* If filterKeys is passed, only the rooms that match the given filters are
* returned.
* @param filterKeys Optional array of filters that the rooms must match against.
*/
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): Room[] {
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys));
else return [];
}
/**
* Resort the list of rooms using a different algorithm.
* @param algorithm The sorting algorithm to use.
*/
public resort(algorithm: SortingAlgorithm): void {
if (!this.roomSkipList) throw new Error("Cannot resort room list before skip list is created.");
if (!this.matrixClient) throw new Error("Cannot resort room list without matrix client.");
if (this.roomSkipList.activeSortAlgorithm === algorithm) return;
const sorter =
algorithm === SortingAlgorithm.Alphabetic
? new AlphabeticSorter()
: new RecencySorter(this.matrixClient.getSafeUserId());
this.roomSkipList.useNewSorter(sorter, this.getRooms());
this.emit(LISTS_UPDATE_EVENT);
SettingsStore.setValue("RoomList.preferredSorting", null, SettingLevel.DEVICE, algorithm);
}
/**
* Currently active sorting algorithm if the store is ready or undefined otherwise.
*/
public get activeSortAlgorithm(): SortingAlgorithm | undefined {
return this.roomSkipList?.activeSortAlgorithm;
}
protected async onReady(): Promise<any> {
if (this.roomSkipList?.initialized || !this.matrixClient) return;
const sorter = this.getPreferredSorter(this.matrixClient.getSafeUserId());
this.roomSkipList = new RoomSkipList(sorter, FILTERS);
await SpaceStore.instance.storeReadyPromise;
const rooms = this.getRooms();
this.roomSkipList.seed(rooms);
this.emit(LISTS_UPDATE_EVENT);
}
protected async onAction(payload: ActionPayload): Promise<void> {
if (!this.matrixClient || !this.roomSkipList?.initialized) return;
/**
* For the kind of updates that we care about (represented by the cases below),
* we try to find the associated room and simply re-insert it into the
* skiplist. If the position of said room in the sorted list changed, re-inserting
* would put it in the correct place.
*/
switch (payload.action) {
case "MatrixActions.Room.receipt": {
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
const room = payload.room;
if (!room) {
logger.warn(`Own read receipt was in unknown room ${room.roomId}`);
return;
}
this.addRoomAndEmit(room);
}
break;
}
case "MatrixActions.Room.tags": {
const room = payload.room;
this.addRoomAndEmit(room);
break;
}
case "MatrixActions.Event.decrypted": {
const roomId = payload.event.getRoomId();
if (!roomId) return;
const room = this.matrixClient.getRoom(roomId);
if (!room) {
logger.warn(`Event ${payload.event.getId()} was decrypted in an unknown room ${roomId}`);
return;
}
this.addRoomAndEmit(room);
break;
}
case "MatrixActions.accountData": {
if (payload.event_type !== EventType.Direct) return;
const dmMap = payload.event.getContent();
let needsEmit = false;
for (const userId of Object.keys(dmMap)) {
const roomIds = dmMap[userId];
for (const roomId of roomIds) {
const room = this.matrixClient.getRoom(roomId);
if (!room) {
logger.warn(`${roomId} was found in DMs but the room is not in the store`);
continue;
}
this.roomSkipList.addRoom(room);
needsEmit = true;
}
}
if (needsEmit) this.emit(LISTS_UPDATE_EVENT);
break;
}
case "MatrixActions.Room.timeline": {
// Ignore non-live events (backfill) and notification timeline set events (without a room)
if (!payload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent || !payload.room) return;
this.addRoomAndEmit(payload.room);
break;
}
case "MatrixActions.Room.myMembership": {
const oldMembership = getEffectiveMembership(payload.oldMembership);
const newMembership = getEffectiveMembershipTag(payload.room, payload.membership);
if (oldMembership === EffectiveMembership.Join && newMembership === EffectiveMembership.Leave) {
this.roomSkipList.removeRoom(payload.room);
this.emit(LISTS_UPDATE_EVENT);
return;
}
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
const roomState: RoomState = payload.room.currentState;
const predecessor = roomState.findPredecessor(this.msc3946ProcessDynamicPredecessor);
if (predecessor) {
const prevRoom = this.matrixClient?.getRoom(predecessor.roomId);
if (prevRoom) this.roomSkipList.removeRoom(prevRoom);
else logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`);
}
}
this.addRoomAndEmit(payload.room);
break;
}
}
}
/**
* Create the correct sorter depending on the persisted user preference.
* @param myUserId The user-id of our user.
* @returns Sorter object that can be passed to the skip list.
*/
private getPreferredSorter(myUserId: string): Sorter {
const preferred = SettingsStore.getValue("RoomList.preferredSorting");
switch (preferred) {
case SortingAlgorithm.Alphabetic:
return new AlphabeticSorter();
case SortingAlgorithm.Recency:
return new RecencySorter(myUserId);
default:
throw new Error(`Got unknown sort preference from RoomList.preferredSorting setting`);
}
}
/**
* Add a room to the skiplist and emit an update.
* @param room The room to add to the skiplist
*/
private addRoomAndEmit(room: Room): void {
if (!this.roomSkipList) throw new Error("roomSkipList hasn't been created yet!");
this.roomSkipList.addRoom(room);
this.emit(LISTS_UPDATE_EVENT);
}
private onActiveSpaceChanged(): void {
if (!this.roomSkipList) return;
this.roomSkipList.calculateActiveSpaceForNodes();
this.emit(LISTS_UPDATE_EVENT);
}
}
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;