Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit b0af163

Browse files
authored
Merge pull request #5825 from matrix-org/travis/spaces/room-list
Restabilize room list ordering with prefiltering on spaces/communities
2 parents 5d027ff + 479df8a commit b0af163

File tree

8 files changed

+217
-105
lines changed

8 files changed

+217
-105
lines changed

docs/room-list-store.md

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ It's so complicated it needs its own README.
66

77
Legend:
88
* Orange = External event.
9-
* Purple = Deterministic flow.
9+
* Purple = Deterministic flow.
1010
* Green = Algorithm definition.
1111
* Red = Exit condition/point.
1212
* Blue = Process definition.
@@ -24,8 +24,8 @@ algorithm to call, instead of having all the logic in the room list store itself
2424

2525

2626
Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm
27-
the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm,
28-
later described in this document, heavily uses the list ordering behaviour to break the tag into categories.
27+
the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm,
28+
later described in this document, heavily uses the list ordering behaviour to break the tag into categories.
2929
Each category then gets sorted by the appropriate tag sorting algorithm.
3030

3131
### Tag sorting algorithm: Alphabetical
@@ -36,7 +36,7 @@ useful.
3636

3737
### Tag sorting algorithm: Manual
3838

39-
Manual sorting makes use of the `order` property present on all tags for a room, per the
39+
Manual sorting makes use of the `order` property present on all tags for a room, per the
4040
[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values
4141
of `order` cause rooms to appear closer to the top of the list.
4242

@@ -74,15 +74,15 @@ relative (perceived) importance to the user:
7474
set to 'All Messages'.
7575
* **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without
7676
a badge/notification count (or 'Mentions Only'/'Muted').
77-
* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user
77+
* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user
7878
last read it.
7979

8080
Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey
8181
above bold, etc.
8282

8383
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
8484
gets applied to each category in a sub-list fashion. This should result in the red rooms (for example)
85-
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
85+
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
8686
collectively the tag will be sorted into categories with red being at the top.
8787

8888
## Sticky rooms
@@ -103,48 +103,62 @@ receive another notification which causes the room to move into the topmost posi
103103
above the sticky room will move underneath to allow for the new room to take the top slot, maintaining
104104
the sticky room's position.
105105

106-
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
107-
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
108-
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
109-
the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
106+
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
107+
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
108+
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
109+
the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
110110
above the sticky room as it will try to maintain 2 rooms above the sticky room.
111111

112112
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
113113
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
114114
the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed.
115-
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
115+
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
116116
put the sticky room in a position where it's had to decrease N will not increase N.
117117

118118
## Responsibilities of the store
119119

120-
The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets
121-
an object containing the tags it needs to worry about and the rooms within. The room list component will
122-
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
120+
The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets
121+
an object containing the tags it needs to worry about and the rooms within. The room list component will
122+
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
123123
all kinds of filtering.
124124

125125
## Filtering
126126

127-
Filters are provided to the store as condition classes, which are then passed along to the algorithm
128-
implementations. The implementations then get to decide how to actually filter the rooms, however in
129-
practice the base `Algorithm` class deals with the filtering in a more optimized/generic way.
127+
Filters are provided to the store as condition classes and have two major kinds: Prefilters and Runtime.
130128

131-
The results of filters get cached to avoid needlessly iterating over potentially thousands of rooms,
132-
as the old room list store does. When a filter condition changes, it emits an update which (in this
133-
case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a
129+
Prefilters flush out rooms which shouldn't appear to the algorithm implementations. Typically this is
130+
due to some higher order room list filtering (such as spaces or tags) deliberately exposing a subset of
131+
rooms to the user. The algorithm implementations will not see a room being prefiltered out.
132+
133+
Runtime filters are used for more dynamic filtering, such as the user filtering by room name. These
134+
filters are passed along to the algorithm implementations where those implementations decide how and
135+
when to apply the filter. In practice, the base `Algorithm` class ends up doing the heavy lifting for
136+
optimization reasons.
137+
138+
The results of runtime filters get cached to avoid needlessly iterating over potentially thousands of
139+
rooms, as the old room list store does. When a filter condition changes, it emits an update which (in this
140+
case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a
134141
minor subset where possible to avoid over-iterating rooms.
135142

136143
All filter conditions are considered "stable" by the consumers, meaning that the consumer does not
137144
expect a change in the condition unless the condition says it has changed. This is intentional to
138145
maintain the caching behaviour described above.
139146

147+
One might ask why we don't just use prefilter conditions for everything, and the answer is one of slight
148+
subtlety: in the cases of prefilters we are knowingly exposing the user to a workspace-style UX where
149+
room notifications are self-contained within that workspace. Runtime filters tend to not want to affect
150+
visible notification counts (as it doesn't want the room header to suddenly be confusing to the user as
151+
they type), and occasionally UX like "found 2/12 rooms" is desirable. If prefiltering were used instead,
152+
the notification counts would vary while the user was typing and "found 2/12" UX would not be possible.
153+
140154
## Class breakdowns
141155

142-
The `RoomListStore` is the major coordinator of various algorithm implementations, which take care
143-
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible
144-
for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get
145-
defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the
146-
user). Various list-specific utilities are also included, though they are expected to move somewhere
147-
more general when needed. For example, the `membership` utilities could easily be moved elsewhere
156+
The `RoomListStore` is the major coordinator of various algorithm implementations, which take care
157+
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible
158+
for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get
159+
defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the
160+
user). Various list-specific utilities are also included, though they are expected to move somewhere
161+
more general when needed. For example, the `membership` utilities could easily be moved elsewhere
148162
as needed.
149163

150164
The various bits throughout the room list store should also have jsdoc of some kind to help describe

src/stores/room-list/RoomListStore.ts

Lines changed: 114 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/*
2-
Copyright 2018, 2019 New Vector Ltd
3-
Copyright 2020 The Matrix.org Foundation C.I.C.
2+
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
43
54
Licensed under the Apache License, Version 2.0 (the "License");
65
you may not use this file except in compliance with the License.
@@ -15,27 +14,27 @@ See the License for the specific language governing permissions and
1514
limitations under the License.
1615
*/
1716

18-
import { MatrixClient } from "matrix-js-sdk/src/client";
17+
import {MatrixClient} from "matrix-js-sdk/src/client";
1918
import SettingsStore from "../../settings/SettingsStore";
20-
import { DefaultTagID, isCustomTag, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
21-
import { Room } from "matrix-js-sdk/src/models/room";
22-
import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
23-
import { ActionPayload } from "../../dispatcher/payloads";
19+
import {DefaultTagID, isCustomTag, OrderedDefaultTagIDs, RoomUpdateCause, TagID} from "./models";
20+
import {Room} from "matrix-js-sdk/src/models/room";
21+
import {IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm} from "./algorithms/models";
22+
import {ActionPayload} from "../../dispatcher/payloads";
2423
import defaultDispatcher from "../../dispatcher/dispatcher";
25-
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
26-
import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition";
27-
import { TagWatcher } from "./TagWatcher";
24+
import {readReceiptChangeIsFor} from "../../utils/read-receipts";
25+
import {FILTER_CHANGED, FilterKind, IFilterCondition} from "./filters/IFilterCondition";
26+
import {TagWatcher} from "./TagWatcher";
2827
import RoomViewStore from "../RoomViewStore";
29-
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
30-
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
31-
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
28+
import {Algorithm, LIST_UPDATED_EVENT} from "./algorithms/Algorithm";
29+
import {EffectiveMembership, getEffectiveMembership} from "../../utils/membership";
30+
import {isNullOrUndefined} from "matrix-js-sdk/src/utils";
3231
import RoomListLayoutStore from "./RoomListLayoutStore";
33-
import { MarkedExecution } from "../../utils/MarkedExecution";
34-
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
35-
import { NameFilterCondition } from "./filters/NameFilterCondition";
36-
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
37-
import { VisibilityProvider } from "./filters/VisibilityProvider";
38-
import { SpaceWatcher } from "./SpaceWatcher";
32+
import {MarkedExecution} from "../../utils/MarkedExecution";
33+
import {AsyncStoreWithClient} from "../AsyncStoreWithClient";
34+
import {NameFilterCondition} from "./filters/NameFilterCondition";
35+
import {RoomNotificationStateStore} from "../notifications/RoomNotificationStateStore";
36+
import {VisibilityProvider} from "./filters/VisibilityProvider";
37+
import {SpaceWatcher} from "./SpaceWatcher";
3938

4039
interface IState {
4140
tagsEnabled?: boolean;
@@ -57,6 +56,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
5756
private initialListsGenerated = false;
5857
private algorithm = new Algorithm();
5958
private filterConditions: IFilterCondition[] = [];
59+
private prefilterConditions: IFilterCondition[] = [];
6060
private tagWatcher: TagWatcher;
6161
private spaceWatcher: SpaceWatcher;
6262
private updateFn = new MarkedExecution(() => {
@@ -104,6 +104,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
104104
public async resetStore() {
105105
await this.reset();
106106
this.filterConditions = [];
107+
this.prefilterConditions = [];
107108
this.initialListsGenerated = false;
108109
this.setupWatchers();
109110

@@ -435,6 +436,39 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
435436
}
436437
}
437438

439+
private async recalculatePrefiltering() {
440+
if (!this.algorithm) return;
441+
if (!this.algorithm.hasTagSortingMap) return; // we're still loading
442+
443+
if (SettingsStore.getValue("advancedRoomListLogging")) {
444+
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
445+
console.log("Calculating new prefiltered room list");
446+
}
447+
448+
// Inhibit updates because we're about to lie heavily to the algorithm
449+
this.algorithm.updatesInhibited = true;
450+
451+
// Figure out which rooms are about to be valid, and the state of affairs
452+
const rooms = this.getPlausibleRooms();
453+
const currentSticky = this.algorithm.stickyRoom;
454+
const stickyIsStillPresent = currentSticky && rooms.includes(currentSticky);
455+
456+
// Reset the sticky room before resetting the known rooms so the algorithm
457+
// doesn't freak out.
458+
await this.algorithm.setStickyRoom(null);
459+
await this.algorithm.setKnownRooms(rooms);
460+
461+
// Set the sticky room back, if needed, now that we have updated the store.
462+
// This will use relative stickyness to the new room set.
463+
if (stickyIsStillPresent) {
464+
await this.algorithm.setStickyRoom(currentSticky);
465+
}
466+
467+
// Finally, mark an update and resume updates from the algorithm
468+
this.updateFn.mark();
469+
this.algorithm.updatesInhibited = false;
470+
}
471+
438472
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
439473
await this.setAndPersistTagSorting(tagId, sort);
440474
this.updateFn.trigger();
@@ -557,6 +591,34 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
557591
this.updateFn.trigger();
558592
};
559593

594+
private onPrefilterUpdated = async () => {
595+
await this.recalculatePrefiltering();
596+
this.updateFn.trigger();
597+
};
598+
599+
private getPlausibleRooms(): Room[] {
600+
if (!this.matrixClient) return [];
601+
602+
let rooms = [
603+
...this.matrixClient.getVisibleRooms(),
604+
// also show space invites in the room list
605+
...this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "invite"),
606+
].filter(r => VisibilityProvider.instance.isRoomVisible(r));
607+
608+
if (this.prefilterConditions.length > 0) {
609+
rooms = rooms.filter(r => {
610+
for (const filter of this.prefilterConditions) {
611+
if (!filter.isVisible(r)) {
612+
return false;
613+
}
614+
}
615+
return true;
616+
});
617+
}
618+
619+
return rooms;
620+
}
621+
560622
/**
561623
* Regenerates the room whole room list, discarding any previous results.
562624
*
@@ -568,11 +630,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
568630
public async regenerateAllLists({trigger = true}) {
569631
console.warn("Regenerating all room lists");
570632

571-
const rooms = [
572-
...this.matrixClient.getVisibleRooms(),
573-
// also show space invites in the room list
574-
...this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "invite"),
575-
].filter(r => VisibilityProvider.instance.isRoomVisible(r));
633+
const rooms = this.getPlausibleRooms();
576634

577635
const customTags = new Set<TagID>();
578636
if (this.state.tagsEnabled) {
@@ -601,32 +659,58 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
601659
if (trigger) this.updateFn.trigger();
602660
}
603661

662+
/**
663+
* Adds a filter condition to the room list store. Filters may be applied async,
664+
* and thus might not cause an update to the store immediately.
665+
* @param {IFilterCondition} filter The filter condition to add.
666+
*/
604667
public addFilter(filter: IFilterCondition): void {
605668
if (SettingsStore.getValue("advancedRoomListLogging")) {
606669
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
607670
console.log("Adding filter condition:", filter);
608671
}
609-
this.filterConditions.push(filter);
610-
if (this.algorithm) {
611-
this.algorithm.addFilterCondition(filter);
672+
let promise = Promise.resolve();
673+
if (filter.kind === FilterKind.Prefilter) {
674+
filter.on(FILTER_CHANGED, this.onPrefilterUpdated);
675+
this.prefilterConditions.push(filter);
676+
promise = this.recalculatePrefiltering();
677+
} else {
678+
this.filterConditions.push(filter);
679+
if (this.algorithm) {
680+
this.algorithm.addFilterCondition(filter);
681+
}
612682
}
613-
this.updateFn.trigger();
683+
promise.then(() => this.updateFn.trigger());
614684
}
615685

686+
/**
687+
* Removes a filter condition from the room list store. If the filter was
688+
* not previously added to the room list store, this will no-op. The effects
689+
* of removing a filter may be applied async and therefore might not cause
690+
* an update right away.
691+
* @param {IFilterCondition} filter The filter condition to remove.
692+
*/
616693
public removeFilter(filter: IFilterCondition): void {
617694
if (SettingsStore.getValue("advancedRoomListLogging")) {
618695
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
619696
console.log("Removing filter condition:", filter);
620697
}
621-
const idx = this.filterConditions.indexOf(filter);
698+
let promise = Promise.resolve();
699+
let idx = this.filterConditions.indexOf(filter);
622700
if (idx >= 0) {
623701
this.filterConditions.splice(idx, 1);
624702

625703
if (this.algorithm) {
626704
this.algorithm.removeFilterCondition(filter);
627705
}
628706
}
629-
this.updateFn.trigger();
707+
idx = this.prefilterConditions.indexOf(filter);
708+
if (idx >= 0) {
709+
filter.off(FILTER_CHANGED, this.onPrefilterUpdated);
710+
this.prefilterConditions.splice(idx, 1);
711+
promise = this.recalculatePrefiltering();
712+
}
713+
promise.then(() => this.updateFn.trigger());
630714
}
631715

632716
/**

0 commit comments

Comments
 (0)