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

Commit 6548748

Browse files
committed
Introduce sticky rooms to the new room list
Originally this was intended to be done only in the importance algorithm, however it is clear that all algorithms will need to deal with this. As such, it has been put into the base class to deal with as we may override it in the future. This commit should be self-documenting enough to describe what is going on, though the major highlight is that the handling of the sticky room is done by lying to the underlying algorithm. This has not been optimized for performance yet. For element-hq/element-web#13635
1 parent e809f28 commit 6548748

File tree

6 files changed

+197
-40
lines changed

6 files changed

+197
-40
lines changed

src/stores/room-list/README.md

+23-23
Original file line numberDiff line numberDiff line change
@@ -74,29 +74,29 @@ gets applied to each category in a sub-sub-list fashion. This should result in t
7474
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
7575
collectively the tag will be sorted into categories with red being at the top.
7676

77-
<!-- TODO: Implement sticky rooms as described below -->
78-
79-
The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing.
80-
The sticky room will remain in position on the room list regardless of other factors going on as typically
81-
clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms
82-
above the selected room at all times, where N is the number of rooms above the selected rooms when it was
83-
selected.
84-
85-
For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one
86-
room above their selection at all times. If they receive another notification, and the tag ordering is
87-
specified as Recent, they'll see the new notification go to the top position, and the one that was previously
88-
there fall behind the sticky room.
89-
90-
The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the
91-
tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another
92-
room, the previous sticky room gets recalculated to determine which category it needs to be in as the user
93-
could have been scrolled up while new messages were received.
94-
95-
Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what
96-
kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user
97-
selecting the third room (leaving 2 above it), and then having the rooms above it read on another device.
98-
This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain
99-
2 rooms above the sticky room.
77+
### Sticky rooms
78+
79+
When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
80+
From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
81+
manages which room is sticky. This is to ensure that all algorithms handle it the same.
82+
83+
The sticky flag is simply to say it will not move higher or lower down the list while it is active. For
84+
example, if using the importance algorithm, the room would naturally become idle once viewed and thus
85+
would normally fly down the list out of sight. The sticky room concept instead holds it in place, never
86+
letting it fly down until the user moves to another room.
87+
88+
Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky
89+
room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and
90+
selects the middle room, they will see exactly one room above their selection at all times. If they
91+
receive another notification which causes the room to move into the topmost position, the room that was
92+
above the sticky room will move underneath to allow for the new room to take the top slot, maintaining
93+
the sticky room's position.
94+
95+
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
96+
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
97+
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
98+
the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
99+
above the sticky room as it will try to maintain 2 rooms above the sticky room.
100100

101101
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
102102
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain

src/stores/room-list/RoomListStore2.ts

+20
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
2929
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
3030
import { IFilterCondition } from "./filters/IFilterCondition";
3131
import { TagWatcher } from "./TagWatcher";
32+
import RoomViewStore from "../RoomViewStore";
3233

3334
interface IState {
3435
tagsEnabled?: boolean;
@@ -62,6 +63,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
6263

6364
this.checkEnabled();
6465
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
66+
RoomViewStore.addListener(this.onRVSUpdate);
6567
}
6668

6769
public get orderedLists(): ITagMap {
@@ -93,6 +95,23 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
9395
this.setAlgorithmClass();
9496
}
9597

98+
private onRVSUpdate = () => {
99+
if (!this.enabled) return; // TODO: Remove enabled flag when RoomListStore2 takes over
100+
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
101+
102+
const activeRoomId = RoomViewStore.getRoomId();
103+
if (!activeRoomId && this.algorithm.stickyRoom) {
104+
this.algorithm.stickyRoom = null;
105+
} else if (activeRoomId) {
106+
const activeRoom = this.matrixClient.getRoom(activeRoomId);
107+
if (!activeRoom) throw new Error(`${activeRoomId} is current in RVS but missing from client`);
108+
if (activeRoom !== this.algorithm.stickyRoom) {
109+
console.log(`Changing sticky room to ${activeRoomId}`);
110+
this.algorithm.stickyRoom = activeRoom;
111+
}
112+
}
113+
};
114+
96115
protected async onDispatch(payload: ActionPayload) {
97116
if (payload.action === 'MatrixActions.sync') {
98117
// Filter out anything that isn't the first PREPARED sync.
@@ -110,6 +129,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
110129
console.log("Regenerating room lists: Startup");
111130
await this.readAndCacheSettingsFromStore();
112131
await this.regenerateAllLists();
132+
this.onRVSUpdate(); // fake an RVS update to adjust sticky room, if needed
113133
}
114134

115135
// TODO: Remove this once the RoomListStore becomes default

src/stores/room-list/algorithms/list-ordering/Algorithm.ts

+135-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ITagMap, ITagSortingMap } from "../models";
2222
import DMRoomMap from "../../../../utils/DMRoomMap";
2323
import { FILTER_CHANGED, IFilterCondition } from "../../filters/IFilterCondition";
2424
import { EventEmitter } from "events";
25+
import { UPDATE_EVENT } from "../../../AsyncStore";
2526

2627
// TODO: Add locking support to avoid concurrent writes?
2728

@@ -30,14 +31,22 @@ import { EventEmitter } from "events";
3031
*/
3132
export const LIST_UPDATED_EVENT = "list_updated_event";
3233

34+
interface IStickyRoom {
35+
room: Room;
36+
position: number;
37+
tag: TagID;
38+
}
39+
3340
/**
3441
* Represents a list ordering algorithm. This class will take care of tag
3542
* management (which rooms go in which tags) and ask the implementation to
3643
* deal with ordering mechanics.
3744
*/
3845
export abstract class Algorithm extends EventEmitter {
3946
private _cachedRooms: ITagMap = {};
47+
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
4048
private filteredRooms: ITagMap = {};
49+
private _stickyRoom: IStickyRoom = null;
4150

4251
protected sortAlgorithms: ITagSortingMap;
4352
protected rooms: Room[] = [];
@@ -51,16 +60,88 @@ export abstract class Algorithm extends EventEmitter {
5160
super();
5261
}
5362

63+
public get stickyRoom(): Room {
64+
return this._stickyRoom ? this._stickyRoom.room : null;
65+
}
66+
67+
public set stickyRoom(val: Room) {
68+
// We wrap this in a closure because we can't use async setters.
69+
// We need async so we can wait for handleRoomUpdate() to do its thing, otherwise
70+
// we risk duplicating rooms.
71+
(async () => {
72+
// It's possible to have no selected room. In that case, clear the sticky room
73+
if (!val) {
74+
if (this._stickyRoom) {
75+
// Lie to the algorithm and re-add the room to the algorithm
76+
await this.handleRoomUpdate(this._stickyRoom.room, RoomUpdateCause.NewRoom);
77+
}
78+
this._stickyRoom = null;
79+
return;
80+
}
81+
82+
// When we do have a room though, we expect to be able to find it
83+
const tag = this.roomIdsToTags[val.roomId][0];
84+
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
85+
let position = this.cachedRooms[tag].indexOf(val);
86+
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
87+
88+
// 🐉 Here be dragons.
89+
// Before we can go through with lying to the underlying algorithm about a room
90+
// we need to ensure that when we do we're ready for the innevitable sticky room
91+
// update we'll receive. To prepare for that, we first remove the sticky room and
92+
// recalculate the state ourselves so that when the underlying algorithm calls for
93+
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
94+
// a new update for ourselves.
95+
const lastStickyRoom = this._stickyRoom;
96+
console.log(`Last sticky room:`, lastStickyRoom);
97+
this._stickyRoom = null;
98+
this.recalculateStickyRoom();
99+
100+
// When we do have the room, re-add the old room (if needed) to the algorithm
101+
// and remove the sticky room from the algorithm. This is so the underlying
102+
// algorithm doesn't try and confuse itself with the sticky room concept.
103+
if (lastStickyRoom) {
104+
// Lie to the algorithm and re-add the room to the algorithm
105+
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
106+
}
107+
// Lie to the algorithm and remove the room from it's field of view
108+
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
109+
110+
// Now that we're done lying to the algorithm, we need to update our position
111+
// marker only if the user is moving further down the same list. If they're switching
112+
// lists, or moving upwards, the position marker will splice in just fine but if
113+
// they went downwards in the same list we'll be off by 1 due to the shifting rooms.
114+
if (lastStickyRoom && lastStickyRoom.tag === tag && lastStickyRoom.position <= position) {
115+
position++;
116+
}
117+
118+
this._stickyRoom = {
119+
room: val,
120+
position: position,
121+
tag: tag,
122+
};
123+
this.recalculateStickyRoom();
124+
125+
// Finally, trigger an update
126+
this.emit(LIST_UPDATED_EVENT);
127+
})();
128+
}
129+
54130
protected get hasFilters(): boolean {
55131
return this.allowedByFilter.size > 0;
56132
}
57133

58134
protected set cachedRooms(val: ITagMap) {
59135
this._cachedRooms = val;
60136
this.recalculateFilteredRooms();
137+
this.recalculateStickyRoom();
61138
}
62139

63140
protected get cachedRooms(): ITagMap {
141+
// 🐉 Here be dragons.
142+
// Note: this is used by the underlying algorithm classes, so don't make it return
143+
// the sticky room cache. If it ends up returning the sticky room cache, we end up
144+
// corrupting our caches and confusing them.
64145
return this._cachedRooms;
65146
}
66147

@@ -154,6 +235,59 @@ export abstract class Algorithm extends EventEmitter {
154235
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
155236
}
156237

238+
/**
239+
* Recalculate the sticky room position. If this is being called in relation to
240+
* a specific tag being updated, it should be given to this function to optimize
241+
* the call.
242+
* @param updatedTag The tag that was updated, if possible.
243+
*/
244+
protected recalculateStickyRoom(updatedTag: TagID = null): void {
245+
// 🐉 Here be dragons.
246+
// This function does far too much for what it should, and is called by many places.
247+
// Not only is this responsible for ensuring the sticky room is held in place at all
248+
// times, it is also responsible for ensuring our clone of the cachedRooms is up to
249+
// date. If either of these desyncs, we see weird behaviour like duplicated rooms,
250+
// outdated lists, and other nonsensical issues that aren't necessarily obvious.
251+
252+
if (!this._stickyRoom) {
253+
// If there's no sticky room, just do nothing useful.
254+
if (!!this._cachedStickyRooms) {
255+
// Clear the cache if we won't be needing it
256+
this._cachedStickyRooms = null;
257+
this.emit(LIST_UPDATED_EVENT);
258+
}
259+
return;
260+
}
261+
262+
if (!this._cachedStickyRooms || !updatedTag) {
263+
console.log(`Generating clone of cached rooms for sticky room handling`);
264+
const stickiedTagMap: ITagMap = {};
265+
for (const tagId of Object.keys(this.cachedRooms)) {
266+
stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone
267+
}
268+
this._cachedStickyRooms = stickiedTagMap;
269+
}
270+
271+
if (updatedTag) {
272+
// Update the tag indicated by the caller, if possible. This is mostly to ensure
273+
// our cache is up to date.
274+
console.log(`Replacing cached sticky rooms for ${updatedTag}`);
275+
this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone
276+
}
277+
278+
// Now try to insert the sticky room, if we need to.
279+
// We need to if there's no updated tag (we regenned the whole cache) or if the tag
280+
// we might have updated from the cache is also our sticky room.
281+
const sticky = this._stickyRoom;
282+
if (!updatedTag || updatedTag === sticky.tag) {
283+
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
284+
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
285+
}
286+
287+
// Finally, trigger an update
288+
this.emit(LIST_UPDATED_EVENT);
289+
}
290+
157291
/**
158292
* Asks the Algorithm to regenerate all lists, using the tags given
159293
* as reference for which lists to generate and which way to generate
@@ -174,7 +308,7 @@ export abstract class Algorithm extends EventEmitter {
174308
*/
175309
public getOrderedRooms(): ITagMap {
176310
if (!this.hasFilters) {
177-
return this.cachedRooms;
311+
return this._cachedStickyRooms || this.cachedRooms;
178312
}
179313
return this.filteredRooms;
180314
}

src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts

+11-15
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ limitations under the License.
1717

1818
import { Algorithm } from "./Algorithm";
1919
import { Room } from "matrix-js-sdk/src/models/room";
20-
import { DefaultTagID, RoomUpdateCause, TagID } from "../../models";
20+
import { RoomUpdateCause, TagID } from "../../models";
2121
import { ITagMap, SortAlgorithm } from "../models";
2222
import { sortRoomsWithAlgorithm } from "../tag-sorting";
2323
import * as Unread from '../../../../Unread';
@@ -82,15 +82,14 @@ export class ImportanceAlgorithm extends Algorithm {
8282
// HOW THIS WORKS
8383
// --------------
8484
//
85-
// This block of comments assumes you've read the README one level higher.
85+
// This block of comments assumes you've read the README two levels higher.
8686
// You should do that if you haven't already.
8787
//
8888
// Tags are fed into the algorithmic functions from the Algorithm superclass,
8989
// which cause subsequent updates to the room list itself. Categories within
9090
// those tags are tracked as index numbers within the array (zero = top), with
9191
// each sticky room being tracked separately. Internally, the category index
92-
// can be found from `this.indices[tag][category]` and the sticky room information
93-
// from `this.stickyRoom`.
92+
// can be found from `this.indices[tag][category]`.
9493
//
9594
// The room list store is always provided with the `this.cachedRooms` results, which are
9695
// updated as needed and not recalculated often. For example, when a room needs to
@@ -102,17 +101,6 @@ export class ImportanceAlgorithm extends Algorithm {
102101
[tag: TagID]: ICategoryIndex;
103102
} = {};
104103

105-
// TODO: Use this (see docs above)
106-
private stickyRoom: {
107-
roomId: string;
108-
tag: TagID;
109-
fromTop: number;
110-
} = {
111-
roomId: null,
112-
tag: null,
113-
fromTop: 0,
114-
};
115-
116104
constructor() {
117105
super();
118106
console.log("Constructed an ImportanceAlgorithm");
@@ -195,6 +183,12 @@ export class ImportanceAlgorithm extends Algorithm {
195183
return;
196184
}
197185

186+
if (cause === RoomUpdateCause.RoomRemoved) {
187+
// TODO: Be smarter and splice rather than regen the planet.
188+
await this.setKnownRooms(this.rooms.filter(r => r !== room));
189+
return;
190+
}
191+
198192
let tags = this.roomIdsToTags[room.roomId];
199193
if (!tags) {
200194
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
@@ -251,6 +245,8 @@ export class ImportanceAlgorithm extends Algorithm {
251245
taggedRooms.splice(startIdx, 0, ...sorted);
252246

253247
// Finally, flag that we've done something
248+
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
249+
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
254250
changed = true;
255251
}
256252
return changed;

src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,17 @@ export class NaturalAlgorithm extends Algorithm {
4646
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
4747
return false;
4848
}
49+
let changed = false;
4950
for (const tag of tags) {
5051
// TODO: Optimize this loop to avoid useless operations
5152
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
5253
this.cachedRooms[tag] = await sortRoomsWithAlgorithm(this.cachedRooms[tag], tag, this.sortAlgorithms[tag]);
54+
55+
// Flag that we've done something
56+
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
57+
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
58+
changed = true;
5359
}
54-
return true; // assume we changed something
60+
return changed;
5561
}
5662
}

src/stores/room-list/models.ts

+1
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,5 @@ export enum RoomUpdateCause {
4040
Timeline = "TIMELINE",
4141
RoomRead = "ROOM_READ", // TODO: Use this.
4242
NewRoom = "NEW_ROOM",
43+
RoomRemoved = "ROOM_REMOVED",
4344
}

0 commit comments

Comments
 (0)