Skip to content

Commit 3054358

Browse files
committed
feat: first draft of sections in RLSV3
1 parent ee5d260 commit 3054358

12 files changed

Lines changed: 297 additions & 56 deletions

File tree

apps/web/src/stores/room-list-v3/RoomListStoreV3.ts

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import type { FilterKey } from "./skip-list/filters";
1515
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
1616
import SettingsStore from "../../settings/SettingsStore";
1717
import defaultDispatcher from "../../dispatcher/dispatcher";
18-
import { RoomSkipList } from "./skip-list/RoomSkipList";
1918
import { RecencySorter } from "./skip-list/sorters/RecencySorter";
2019
import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter";
2120
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
@@ -36,6 +35,7 @@ import { Action } from "../../dispatcher/actions";
3635
import { UnreadSorter } from "./skip-list/sorters/UnreadSorter";
3736
import { getChangedOverrideRoomMutePushRules } from "./utils";
3837
import { isRoomVisible } from "./isRoomVisible";
38+
import { type Section, SectionStore } from "./SectionStore";
3939

4040
/**
4141
* These are the filters passed to the room skip list.
@@ -64,7 +64,7 @@ export type RoomsResult = {
6464
// The filter queried
6565
filterKeys?: FilterKey[];
6666
// The resulting list of rooms
67-
rooms: Room[];
67+
sections: Section[];
6868
};
6969

7070
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
@@ -75,7 +75,11 @@ export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
7575
* This store is being actively developed so expect the methods to change in future.
7676
*/
7777
export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
78-
private roomSkipList?: RoomSkipList;
78+
/**
79+
* The section store holds the actual skip lists that are used to store rooms.
80+
*/
81+
private sectionStore?: SectionStore;
82+
7983
private readonly msc3946ProcessDynamicPredecessor: boolean;
8084

8185
public constructor(dispatcher: MatrixDispatcher) {
@@ -100,14 +104,14 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
100104
* Check whether the initial list of rooms has loaded.
101105
*/
102106
public get isLoadingRooms(): boolean {
103-
return !this.roomSkipList?.initialized;
107+
return !this.sectionStore?.initialized;
104108
}
105109

106110
/**
107111
* Get a list of sorted rooms.
108112
*/
109113
public getSortedRooms(): Room[] {
110-
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList);
114+
if (this.sectionStore?.initialized) return this.sectionStore.getSortedRooms();
111115
else return [];
112116
}
113117

@@ -120,25 +124,32 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
120124
*/
121125
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): RoomsResult {
122126
const spaceId = SpaceStore.instance.activeSpace;
123-
if (this.roomSkipList?.initialized)
127+
console.log(
128+
"Getting sorted rooms in active space with filters",
129+
filterKeys,
130+
"and spaceId",
131+
spaceId,
132+
this.sectionStore?.getSections(filterKeys),
133+
);
134+
if (this.sectionStore?.initialized)
124135
return {
125136
spaceId: spaceId,
126137
filterKeys,
127-
rooms: Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)),
138+
sections: this.sectionStore.getSections(filterKeys),
128139
};
129-
else return { spaceId: spaceId, filterKeys, rooms: [] };
140+
else return { spaceId: spaceId, filterKeys, sections: [] };
130141
}
131142

132143
/**
133144
* Resort the list of rooms using a different algorithm.
134145
* @param algorithm The sorting algorithm to use.
135146
*/
136147
public resort(algorithm: SortingAlgorithm): void {
137-
if (!this.roomSkipList) throw new Error("Cannot resort room list before skip list is created.");
148+
if (!this.sectionStore) throw new Error("Cannot resort room list before skip list is created.");
138149
if (!this.matrixClient) throw new Error("Cannot resort room list without matrix client.");
139-
if (this.roomSkipList.activeSortAlgorithm === algorithm) return;
150+
if (this.sectionStore.activeSortAlgorithm === algorithm) return;
140151
const sorter = this.getSorterFromSortingAlgorithm(algorithm, this.matrixClient.getSafeUserId());
141-
this.roomSkipList.useNewSorter(sorter, this.getRooms());
152+
this.sectionStore.useNewSorter(sorter, this.getRooms());
142153
this.emit(LISTS_UPDATE_EVENT);
143154
SettingsStore.setValue("RoomList.preferredSorting", null, SettingLevel.DEVICE, algorithm);
144155
}
@@ -147,26 +158,26 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
147158
* Currently active sorting algorithm if the store is ready or undefined otherwise.
148159
*/
149160
public get activeSortAlgorithm(): SortingAlgorithm | undefined {
150-
return this.roomSkipList?.activeSortAlgorithm;
161+
return this.sectionStore?.activeSortAlgorithm;
151162
}
152163

153164
protected async onReady(): Promise<any> {
154-
if (this.roomSkipList?.initialized || !this.matrixClient) return;
165+
if (this.sectionStore?.initialized || !this.matrixClient) return;
155166
const sorter = this.getPreferredSorter(this.matrixClient.getSafeUserId());
156-
this.roomSkipList = new RoomSkipList(sorter, FILTERS);
167+
this.sectionStore = new SectionStore(sorter, FILTERS);
157168
await SpaceStore.instance.storeReadyPromise;
158169
const rooms = this.getRooms();
159-
this.roomSkipList.seed(rooms);
170+
this.sectionStore.seed(rooms);
160171
this.emit(LISTS_LOADED_EVENT);
161172
this.emit(LISTS_UPDATE_EVENT);
162173
}
163174

164175
protected async onNotReady(): Promise<void> {
165-
this.roomSkipList = undefined;
176+
this.sectionStore = undefined;
166177
}
167178

168179
protected async onAction(payload: ActionPayload): Promise<void> {
169-
if (!this.matrixClient || !this.roomSkipList?.initialized) return;
180+
if (!this.matrixClient || !this.sectionStore?.initialized) return;
170181

171182
/**
172183
* For the kind of updates that we care about (represented by the cases below),
@@ -242,7 +253,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
242253
(oldMembership === EffectiveMembership.Invite || oldMembership === EffectiveMembership.Join) &&
243254
newMembership === EffectiveMembership.Leave
244255
) {
245-
this.roomSkipList.removeRoom(payload.room);
256+
this.sectionStore.removeRoom(payload.room);
246257
this.emit(LISTS_UPDATE_EVENT);
247258
return;
248259
}
@@ -258,7 +269,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
258269
);
259270
const predecessors = roomUpgradeHistory.slice(0, roomUpgradeHistory.indexOf(room));
260271
for (const predecessor of predecessors) {
261-
this.roomSkipList.removeRoom(predecessor);
272+
this.sectionStore.removeRoom(predecessor);
262273
}
263274
}
264275

@@ -268,7 +279,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
268279

269280
case Action.AfterForgetRoom: {
270281
const room = payload.room;
271-
this.roomSkipList.removeRoom(room);
282+
this.sectionStore.removeRoom(room);
272283
this.emit(LISTS_UPDATE_EVENT);
273284
break;
274285
}
@@ -279,6 +290,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
279290
* This method deals with the two types of account data payloads that we care about.
280291
*/
281292
private handleAccountDataPayload(payload: ActionPayload): void {
293+
if (!this.sectionStore) throw new Error("sectionStore hasn't been created yet!");
294+
282295
const eventType = payload.event_type;
283296
let needsEmit = false;
284297
switch (eventType) {
@@ -293,7 +306,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
293306
logger.warn(`${roomId} was found in DMs but the room is not in the store`);
294307
continue;
295308
}
296-
this.roomSkipList!.reInsertRoom(room);
309+
this.sectionStore.reInsertRoom(room);
297310
needsEmit = true;
298311
}
299312
}
@@ -307,7 +320,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
307320
.map((id) => this.matrixClient?.getRoom(id))
308321
.filter((room) => !!room);
309322
for (const room of rooms) {
310-
this.roomSkipList!.reInsertRoom(room);
323+
this.sectionStore.reInsertRoom(room);
311324
needsEmit = true;
312325
}
313326
break;
@@ -354,24 +367,24 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
354367
* @param isNewRoom Set this to true if this a new room that the isn't already in the skiplist
355368
*/
356369
private addRoomAndEmit(room: Room, isNewRoom = false): void {
357-
if (!this.roomSkipList) throw new Error("roomSkipList hasn't been created yet!");
370+
if (!this.sectionStore) throw new Error("roomSkipList hasn't been created yet!");
358371
if (isNewRoom) {
359372
if (!isRoomVisible(room)) {
360373
logger.info(
361374
`RoomListStoreV3: Refusing to add new room ${room.roomId} because isRoomVisible returned false.`,
362375
);
363376
return;
364377
}
365-
this.roomSkipList.addNewRoom(room);
378+
this.sectionStore.addNewRoom(room);
366379
} else {
367-
this.roomSkipList.reInsertRoom(room);
380+
this.sectionStore.reInsertRoom(room);
368381
}
369382
this.emit(LISTS_UPDATE_EVENT);
370383
}
371384

372385
private onActiveSpaceChanged(): void {
373-
if (!this.roomSkipList) return;
374-
this.roomSkipList.calculateActiveSpaceForNodes();
386+
if (!this.sectionStore) return;
387+
this.sectionStore.calculateActiveSpaceForNodes();
375388
this.emit(LISTS_UPDATE_EVENT);
376389
}
377390
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
* Copyright 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type Room } from "matrix-js-sdk/src/matrix";
9+
10+
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
11+
import { TagFilter } from "./skip-list/filters/TagFilter";
12+
import { RoomSkipList } from "./skip-list/RoomSkipList";
13+
import { type SortingAlgorithm, type Sorter } from "./skip-list/sorters";
14+
import { DefaultTagID } from "./skip-list/tag";
15+
import { type FilterKey, type Filter } from "./skip-list/filters";
16+
import { filterBoolean } from "../../utils/arrays";
17+
18+
/**
19+
* Represents a named section of rooms in the room list, identified by a tag.
20+
*/
21+
export interface Section {
22+
/** The tag that identifies this section. */
23+
tag: string;
24+
/** The ordered list of rooms belonging to this section. */
25+
rooms: Room[];
26+
}
27+
28+
/**
29+
* A synthetic tag used to represent the "Chats" section, which contains
30+
* every room that does not belong to any other explicit tag section.
31+
*/
32+
export const CHATS_TAG = "chats";
33+
34+
/**
35+
* Manages an ordered collection of {@link RoomSkipList}s, one per tag section,
36+
* and exposes a unified API for seeding, mutating, and querying the room list.
37+
*
38+
* Each section is backed by a {@link RoomSkipList} that keeps rooms sorted
39+
* according to the active {@link Sorter} and filtered by the section-specific
40+
* {@link Filter} as well as any additional filters supplied at construction time.
41+
*/
42+
export class SectionStore {
43+
/**
44+
* Maps section tags to their corresponding skip lists.
45+
*/
46+
private roomSkipListByTag: Map<string, RoomSkipList> = new Map();
47+
/**
48+
* Maps section tags to their corresponding tag filters, used to determine which rooms belong in which sections.
49+
*/
50+
private filterByTag: Map<string, Filter> = new Map();
51+
52+
/**
53+
* Defines the display order of sections.
54+
*/
55+
private sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority];
56+
57+
/**
58+
* Creates a new `SectionStore`.
59+
*
60+
* @param sorter - The sorting algorithm used to order rooms within each section.
61+
* @param filters - Additional filters applied on top of each section's built-in tag filter.
62+
*/
63+
public constructor(
64+
private sorter: Sorter,
65+
filters: Filter[],
66+
) {
67+
const tagsToExclude = this.sortedTags.filter((tag) => tag !== CHATS_TAG);
68+
this.sortedTags.forEach((tag) => {
69+
const filter = tag === CHATS_TAG ? new ExcludeTagsFilter(tagsToExclude) : new TagFilter(tag);
70+
this.filterByTag.set(tag, filter);
71+
this.roomSkipListByTag.set(tag, new RoomSkipList(sorter, [filter, ...filters]));
72+
});
73+
}
74+
75+
/**
76+
* Whether every section's underlying skip list has been seeded and is ready
77+
* to serve room data. Returns `false` if any section has not yet been initialized.
78+
*/
79+
public get initialized(): boolean {
80+
return this.sortedTags.every((tag) => this.roomSkipListByTag.get(tag)?.initialized);
81+
}
82+
83+
/**
84+
* Seeds all section skip lists with an initial set of rooms.
85+
* Each room is placed into whichever sections its tags qualify it for.
86+
* Must be called before any mutation methods.
87+
*
88+
* @param rooms - The full list of rooms to seed the store with.
89+
*/
90+
public seed(rooms: Room[]): void {
91+
console.log("Seeding section store with rooms", rooms);
92+
this.runOnAllList((list) => list.seed(rooms));
93+
}
94+
95+
/**
96+
* Replaces the active sorter and rebuilds all section skip lists from scratch.
97+
*
98+
* @param sorter - The new sorting algorithm to use.
99+
* @param rooms - The full list of rooms used to re-seed the store after the sorter change.
100+
*/
101+
public useNewSorter(sorter: Sorter, rooms: Room[]): void {
102+
this.roomSkipListByTag.forEach((skipList) => skipList.useNewSorter(sorter, rooms));
103+
}
104+
105+
/**
106+
* The currently active sorting algorithm.
107+
*/
108+
public get activeSortAlgorithm(): SortingAlgorithm {
109+
return this.sorter.type;
110+
}
111+
112+
/**
113+
* Re-evaluates the active space membership for every room node across all
114+
* section skip lists. Should be called whenever the active space changes so
115+
* that space-filtered queries return up-to-date results.
116+
*/
117+
public calculateActiveSpaceForNodes(): void {
118+
this.runOnAllList((list) => list.calculateActiveSpaceForNodes());
119+
}
120+
121+
/**
122+
* Inserts a newly joined room into all relevant section skip lists.
123+
* Throws if the room is already present in any skip list.
124+
*
125+
* @param room - The room to add.
126+
*/
127+
public addNewRoom(room: Room): void {
128+
this.runOnAllList((list) => list.addNewRoom(room));
129+
}
130+
131+
/**
132+
* Removes a room from all section skip lists.
133+
* Has no effect on sections that do not contain the room.
134+
*
135+
* @param room - The room to remove.
136+
*/
137+
public removeRoom(room: Room): void {
138+
this.runOnAllList((list) => list.removeRoom(room));
139+
}
140+
141+
/**
142+
* Re-inserts a room into its correct sorted position across all section skip
143+
* lists. Use this when a room's sort key has changed (e.g. a new message was
144+
* received) so that it is repositioned without a full rebuild.
145+
*
146+
* @param room - The room to re-insert.
147+
*/
148+
public reInsertRoom(room: Room): void {
149+
this.runOnAllList((list) => list.reInsertRoom(room));
150+
}
151+
152+
// TODO incorrect implementation
153+
// Rooms are not sorted across sections, but rather within each section.
154+
public getSortedRooms(): Room[] {
155+
return this.sortedTags.map((tag) => Array.from(this.roomSkipListByTag.get(tag) || [])).flat();
156+
}
157+
158+
/**
159+
* Returns one {@link Section} per tag, each containing the rooms that belong
160+
* to that section and are part of the currently active space. Optionally
161+
* further restricts each section's rooms to those matching the given filter keys.
162+
*
163+
* @param filterKeys - An optional list of {@link FilterKey}s used to narrow
164+
* the rooms returned within each section (e.g. only unread or mention rooms).
165+
* @returns An array of sections in display order: Favourites, All Chats, Low Priority.
166+
*/
167+
public getSections(filterKeys?: FilterKey[]): Section[] {
168+
return this.sortedTags.map((tag) => {
169+
const filters = filterBoolean([this.filterByTag.get(tag)?.key, ...(filterKeys || [])]);
170+
171+
return {
172+
tag,
173+
rooms: Array.from(this.roomSkipListByTag.get(tag)?.getRoomsInActiveSpace(filters) || []),
174+
};
175+
});
176+
}
177+
178+
/**
179+
* Runs the provided callback on all the skip lists
180+
* @param cb The callback to run on all the skip lists
181+
*/
182+
private runOnAllList(cb: (list: RoomSkipList) => void): void {
183+
this.sortedTags.forEach((tag) => {
184+
const skipList = this.roomSkipListByTag.get(tag);
185+
if (skipList) cb(skipList);
186+
});
187+
}
188+
}

0 commit comments

Comments
 (0)