-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Expand file tree
/
Copy pathRoomListStoreV3.ts
More file actions
530 lines (472 loc) · 20.6 KB
/
RoomListStoreV3.ts
File metadata and controls
530 lines (472 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
/*
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 } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import type { ActionPayload } from "../../dispatcher/payloads";
import type { Filter, FilterKey } from "./skip-list/filters";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import SettingsStore from "../../settings/SettingsStore";
import defaultDispatcher from "../../dispatcher/dispatcher";
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 { type SpaceKey, 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";
import { MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE } from "../../utils/notifications";
import { Action } from "../../dispatcher/actions";
import { UnreadSorter } from "./skip-list/sorters/UnreadSorter";
import { getChangedOverrideRoomMutePushRules } from "./utils";
import { isRoomVisible } from "./isRoomVisible";
import { RoomSkipList } from "./skip-list/RoomSkipList";
import { DefaultTagID } from "./skip-list/tag";
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
import { TagFilter } from "./skip-list/filters/TagFilter";
import { filterBoolean } from "../../utils/arrays";
import { createSection } from "./section";
/**
* 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(),
];
export enum RoomListStoreV3Event {
// The event/channel which is called when the room lists have been changed.
ListsUpdate = "lists_update",
// The event which is called when the room list is loaded.
ListsLoaded = "lists_loaded",
/** Fired when a new section is created in the room list. */
SectionCreated = "section_created",
/** Fired when a room's tags change. */
RoomTagged = "room_tagged",
}
// The result object for returning rooms from the store
export type RoomsResult = {
// The ID of the active space queried
spaceId: SpaceKey;
// The filter queried
filterKeys?: FilterKey[];
// The resulting list of rooms
sections: Section[];
};
/**
* Represents a named section of rooms in the room list, identified by a tag.
*/
export interface Section {
/** The tag that identifies this section. */
tag: string;
/** The ordered list of rooms belonging to this section. */
rooms: Room[];
}
/**
* A synthetic tag used to represent the "Chats" section, which contains
* every room that does not belong to any other explicit tag section.
*/
export const CHATS_TAG = "chats";
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
/**
* 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> {
/**
* Contains all the rooms in the active space
*/
private roomSkipList?: RoomSkipList;
/**
* Maps section tags to their corresponding tag filters, used to determine which rooms belong in which sections.
*/
private readonly filterByTag: Map<string, Filter> = new Map();
/**
* Defines the display order of sections.
*/
private sortedTags: string[] = [];
private readonly msc3946ProcessDynamicPredecessor: boolean;
/**
* Whether a batched LISTS_UPDATE_EVENT emission is pending.
* Used by {@link scheduleEmit} to coalesce rapid-fire updates into a single emit per frame.
*/
private pendingEmit = false;
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());
SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => this.onOrderedCustomSectionsChange());
this.loadCustomSections();
}
/**
* Get a list of unsorted, unfiltered rooms.
*/
public getRooms(): Room[] {
let rooms = this.matrixClient?.getVisibleRooms(this.msc3946ProcessDynamicPredecessor) ?? [];
rooms = rooms.filter((r) => isRoomVisible(r));
return rooms;
}
/**
* Check whether the initial list of rooms has loaded.
*/
public get isLoadingRooms(): boolean {
return !this.roomSkipList?.initialized;
}
/**
* 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[]): RoomsResult {
const spaceId = SpaceStore.instance.activeSpace;
const areSectionsEnabled = SettingsStore.getValue("feature_room_list_sections");
const sections = areSectionsEnabled
? this.getSections(filterKeys)
: [{ tag: CHATS_TAG, rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filterKeys) ?? []) }];
return {
spaceId: spaceId,
filterKeys,
sections,
};
}
/**
* 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 = this.getSorterFromSortingAlgorithm(algorithm, 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, this.getSkipListFilters());
await SpaceStore.instance.storeReadyPromise;
const rooms = this.getRooms();
this.roomSkipList.seed(rooms);
this.emit(LISTS_LOADED_EVENT);
this.emit(LISTS_UPDATE_EVENT);
}
protected async onNotReady(): Promise<void> {
this.roomSkipList = undefined;
}
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);
this.emit(ROOM_TAGGED_EVENT);
break;
}
case "MatrixActions.Room.accountData": {
const eventType = payload.event_type;
if (eventType === MARKED_UNREAD_TYPE_STABLE || eventType === MARKED_UNREAD_TYPE_UNSTABLE) {
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": {
this.handleAccountDataPayload(payload);
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 the user is kicked, re-insert the room and do nothing more.
const ownUserId = this.matrixClient.getSafeUserId();
const isKicked = (payload.room as Room).getMember(ownUserId)?.isKicked();
if (isKicked) {
this.addRoomAndEmit(payload.room);
return;
}
// If the user has left this room, remove it from the skiplist.
if (
(oldMembership === EffectiveMembership.Invite || oldMembership === EffectiveMembership.Join) &&
newMembership === EffectiveMembership.Leave
) {
this.roomSkipList.removeRoom(payload.room);
this.scheduleEmit();
return;
}
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
const room: Room = payload.room;
const roomUpgradeHistory = room.client.getRoomUpgradeHistory(
room.roomId,
true,
this.msc3946ProcessDynamicPredecessor,
);
const predecessors = roomUpgradeHistory.slice(0, roomUpgradeHistory.indexOf(room));
for (const predecessor of predecessors) {
this.roomSkipList.removeRoom(predecessor);
}
}
this.addRoomAndEmit(payload.room, oldMembership === EffectiveMembership.Leave);
break;
}
case Action.AfterForgetRoom: {
const room = payload.room;
this.roomSkipList.removeRoom(room);
this.scheduleEmit();
break;
}
}
}
/**
* This method deals with the two types of account data payloads that we care about.
*/
private handleAccountDataPayload(payload: ActionPayload): void {
const eventType = payload.event_type;
let needsEmit = false;
switch (eventType) {
// When we're told about new DMs, insert the associated dm rooms.
case EventType.Direct: {
const dmMap = payload.event.getContent();
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?.reInsertRoom(room);
needsEmit = true;
}
}
break;
}
case EventType.PushRules: {
// When a room becomes muted/unmuted, re-insert that room.
const possibleMuteChangeRoomIds = getChangedOverrideRoomMutePushRules(payload);
if (!possibleMuteChangeRoomIds) return;
const rooms = possibleMuteChangeRoomIds
.map((id) => this.matrixClient?.getRoom(id))
.filter((room) => !!room);
for (const room of rooms) {
this.roomSkipList?.reInsertRoom(room);
needsEmit = true;
}
break;
}
}
if (needsEmit) this.scheduleEmit();
}
/**
* 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");
return this.getSorterFromSortingAlgorithm(preferred, myUserId);
}
/**
* Get a sorter instance from the sorting algorithm enum value.
* @param algorithm The sorting algorithm
* @param myUserId The user-id of the current user
* @returns the sorter instance
*/
private getSorterFromSortingAlgorithm(algorithm: SortingAlgorithm, myUserId: string): Sorter {
switch (algorithm) {
case SortingAlgorithm.Alphabetic:
return new AlphabeticSorter();
case SortingAlgorithm.Recency:
return new RecencySorter(myUserId);
case SortingAlgorithm.Unread:
return new UnreadSorter(myUserId);
default:
logger.info(
`RoomListStoreV3: There is no sorting implementation for algorithm ${algorithm}, defaulting to recency sorter`,
);
return new RecencySorter(myUserId);
}
}
/**
* Schedule a batched emission of LISTS_UPDATE_EVENT using requestAnimationFrame.
* Multiple calls within the same frame are coalesced into a single emit.
*/
private scheduleEmit(): void {
if (!this.pendingEmit) {
this.pendingEmit = true;
requestAnimationFrame(() => {
this.pendingEmit = false;
this.emit(LISTS_UPDATE_EVENT);
});
}
}
/**
* Add a room to the skiplist and emit an update.
* @param room The room to add to the skiplist
* @param isNewRoom Set this to true if this a new room that the isn't already in the skiplist
*/
private addRoomAndEmit(room: Room, isNewRoom = false): void {
if (!this.roomSkipList) throw new Error("roomSkipList hasn't been created yet!");
if (isNewRoom) {
if (!isRoomVisible(room)) {
logger.info(
`RoomListStoreV3: Refusing to add new room ${room.roomId} because isRoomVisible returned false.`,
);
return;
}
this.roomSkipList.addNewRoom(room);
} else {
this.roomSkipList.reInsertRoom(room);
}
this.scheduleEmit();
}
private onActiveSpaceChanged(): void {
if (!this.roomSkipList) return;
this.roomSkipList.calculateActiveSpaceForNodes();
this.scheduleEmit();
}
/**
* Get the list of filters to be used in the skip list, including the tag filters for sectioning.
*/
private getSkipListFilters(): Filter[] {
const tagsToExclude = this.sortedTags.filter((tag) => tag !== CHATS_TAG);
const tagFilters = this.sortedTags.map((tag) =>
tag === CHATS_TAG ? new ExcludeTagsFilter(tagsToExclude) : new TagFilter(tag),
);
this.sortedTags.forEach((tag, index) => this.filterByTag.set(tag, tagFilters[index]));
return [...FILTERS, ...tagFilters];
}
/**
* Get the sections to display in the room list, based on the current active space and the provided filters.
* @param filterKeys - Optional array of filters that the rooms must match against to be included in the sections.
* @returns An array of sections
*/
private getSections(filterKeys?: FilterKey[]): Section[] {
return this.sortedTags.map((tag) => {
const filters = filterBoolean([this.filterByTag.get(tag)?.key, ...(filterKeys || [])]);
return {
tag,
rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filters) || []),
};
});
}
/**
* Handle changes to the order of custom sections.
* Reloads the custom sections, updates the skip list filters to reflect the new order and emits an update.
* Emit {@link LISTS_UPDATE_EVENT}.
*/
private onOrderedCustomSectionsChange(): void {
this.loadCustomSections();
if (!this.roomSkipList) return;
this.roomSkipList.useNewFilters(this.getSkipListFilters());
this.scheduleEmit();
}
/**
* Create a new section.
* Emits {@link SECTION_CREATED_EVENT} if the section was successfully created.
*/
public async createSection(): Promise<void> {
const tag = await createSection();
if (!tag) return;
this.emit(SECTION_CREATED_EVENT, tag);
}
/**
* Returns the ordered section tags.
*/
public get orderedSectionTags(): string[] {
return this.sortedTags;
}
/**
* Load the custom sections from the settings store and update the sorted tags.
*/
private loadCustomSections(): void {
const orderedCustomSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
}
}
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.getRoomListStoreV3 = () => RoomListStoreV3.instance;