Possible framework for a proof of concept
This is the fruits of about 3 attempts to write code that works. None of those attempts are here, but how edition 4 could work is at least documented now.
This commit is contained in:
parent
becaddeb80
commit
00d400b516
6 changed files with 342 additions and 9 deletions
117
src/stores/room-list/README.md
Normal file
117
src/stores/room-list/README.md
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
# Room list sorting
|
||||||
|
|
||||||
|
It's so complicated it needs its own README.
|
||||||
|
|
||||||
|
## Algorithms involved
|
||||||
|
|
||||||
|
There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting.
|
||||||
|
Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting
|
||||||
|
Algorithm respectively. The list algorithm determines the behaviour of the room list whereas the sorting
|
||||||
|
algorithm determines how individual tags (lists of rooms, sometimes called sublists) are ordered.
|
||||||
|
|
||||||
|
Behaviour of the room list takes the shape of default sorting on tags in most cases, though it can
|
||||||
|
override what is happening at the tag level depending on the algorithm used (as is the case with the
|
||||||
|
importance algorithm, described later).
|
||||||
|
|
||||||
|
Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm
|
||||||
|
the power to decide when and how to apply the tag sorting, if at all.
|
||||||
|
|
||||||
|
### Tag sorting algorithm: Alphabetical
|
||||||
|
|
||||||
|
When used, rooms in a given tag will be sorted alphabetically, where the alphabet is determined by a
|
||||||
|
simple string comparison operation (essentially giving the browser the problem of figuring out if A
|
||||||
|
comes before Z).
|
||||||
|
|
||||||
|
### Tag sorting algorithm: Manual
|
||||||
|
|
||||||
|
Manual sorting makes use of the `order` property present on all tags for a room, per the
|
||||||
|
[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values
|
||||||
|
of `order` cause rooms to appear closer to the top of the list.
|
||||||
|
|
||||||
|
### Tag sorting algorithm: Recent
|
||||||
|
|
||||||
|
Rooms are ordered by the timestamp of the most recent useful message. Usefulness is yet another algorithm
|
||||||
|
in the room list system which determines whether an event type is capable of bubbling up in the room list.
|
||||||
|
Normally events like room messages, stickers, and room security changes will be considered useful enough
|
||||||
|
to cause a shift in time.
|
||||||
|
|
||||||
|
Note that this is reliant on the event timestamps of the most recent message. Because Matrix is eventually
|
||||||
|
consistent this means that from time to time a room might plummet or skyrocket across the tag due to the
|
||||||
|
timestamp contained within the event (generated server-side by the sender's server).
|
||||||
|
|
||||||
|
### List ordering algorithm: Natural
|
||||||
|
|
||||||
|
This is the easiest of the algorithms to understand because it does essentially nothing. It imposes no
|
||||||
|
behavioural changes over the tag sorting algorithm and is by far the simplest way to order a room list.
|
||||||
|
Historically, it's been the only option in Riot and extremely common in most chat applications due to
|
||||||
|
its relative deterministic behaviour.
|
||||||
|
|
||||||
|
### List ordering algorithm: Importance
|
||||||
|
|
||||||
|
On the other end of the spectrum, this is the most complicated algorithm which exists. There's major
|
||||||
|
behavioural changes and the tag sorting algorithm is selectively applied depending on circumstances.
|
||||||
|
|
||||||
|
Each tag which is not manually ordered gets split into 4 sections or "categories". Manually ordered tags
|
||||||
|
simply get the manual sorting algorithm applied to them with no further involvement from the importance
|
||||||
|
algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off
|
||||||
|
relative (perceived) importance to the user:
|
||||||
|
|
||||||
|
* **Red**: The room has unread mentions waiting for the user.
|
||||||
|
* **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread
|
||||||
|
messages which cause a push notification or badge count. Typically this is the default as rooms are
|
||||||
|
set to 'All Messages'.
|
||||||
|
* **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without
|
||||||
|
a badge/notification count (or 'Mentions Only'/'Muted').
|
||||||
|
* **Idle**: No relevant activity has occurred in the room since the user last read it.
|
||||||
|
|
||||||
|
Conveniently, each tag is ordered by those categories as presented: red rooms appear above grey, grey
|
||||||
|
above idle, etc.
|
||||||
|
|
||||||
|
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
|
||||||
|
is applied to each category in a sub-sub-list fashion. This should result in the red rooms (for example)
|
||||||
|
being sorted alphabetically amongst each other and the grey rooms sorted amongst each other, but
|
||||||
|
collectively the tag will be sorted into categories with red being at the top.
|
||||||
|
|
||||||
|
The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing.
|
||||||
|
The sticky room will remain in position on the room list regardless of other factors going on as typically
|
||||||
|
clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms
|
||||||
|
above the selected room at all times where N is the number of rooms above the selected rooms when it was
|
||||||
|
selected.
|
||||||
|
|
||||||
|
For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one
|
||||||
|
room above their selection at all times. If they receive another notification and the tag ordering is set
|
||||||
|
to Recent, they'll see the new notification go to the top position and the one that was previously there
|
||||||
|
fall behind the sticky room.
|
||||||
|
|
||||||
|
The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the
|
||||||
|
tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another
|
||||||
|
room, the previous sticky room is recalculated to determine which category it needs to be in as the user
|
||||||
|
could have been scrolled up while new messages were received.
|
||||||
|
|
||||||
|
Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what
|
||||||
|
kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user
|
||||||
|
selecting the third room (leaving 2 above it), and then having the rooms above it read on another device.
|
||||||
|
This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain
|
||||||
|
2 rooms above the sticky room.
|
||||||
|
|
||||||
|
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
|
||||||
|
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
|
||||||
|
the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed.
|
||||||
|
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
|
||||||
|
put the sticky room in a position where it's had to decrease N will not increase N.
|
||||||
|
|
||||||
|
## Responsibilities of the store
|
||||||
|
|
||||||
|
The store is responsible for the ordering, upkeep, and tracking of all rooms. The component simply gets
|
||||||
|
an object containing the tags it needs to worry about and the rooms within. The room list component will
|
||||||
|
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
|
||||||
|
all kinds of filtering.
|
||||||
|
|
||||||
|
## Class breakdowns
|
||||||
|
|
||||||
|
The `RoomListStore` is the major coordinator of various `IAlgorithm` implementations, which take care
|
||||||
|
of the various `ListAlgorithm` and `SortingAlgorithm` options. A `TagManager` is responsible for figuring
|
||||||
|
out which tags get which rooms, as Matrix specifies them as a reverse map: tags are defined on rooms and
|
||||||
|
are not defined as a collection of rooms (unlike how they are presented to the user). Various list-specific
|
||||||
|
utilities are also included, though they are expected to move somewhere more general when needed. For
|
||||||
|
example, the `membership` utilities could easily be moved elsewhere as needed.
|
32
src/stores/room-list/TagManager.ts
Normal file
32
src/stores/room-list/TagManager.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {EventEmitter} from "events";
|
||||||
|
|
||||||
|
// TODO: Docs on what this is
|
||||||
|
export class TagManager extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implementation.
|
||||||
|
// This will need to track where rooms belong in tags, and which tags they
|
||||||
|
// should be tracked within. This is algorithm independent because all the
|
||||||
|
// algorithms need to know what each tag contains.
|
||||||
|
//
|
||||||
|
// This will likely make use of the effective membership to determine the
|
||||||
|
// invite+historical sections.
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ export class ChaoticAlgorithm implements IAlgorithm {
|
||||||
private rooms: Room[] = [];
|
private rooms: Room[] = [];
|
||||||
|
|
||||||
constructor(private representativeAlgorithm: ListAlgorithm) {
|
constructor(private representativeAlgorithm: ListAlgorithm) {
|
||||||
|
console.log("Constructed a ChaoticAlgorithm");
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrderedRooms(): ITagMap {
|
getOrderedRooms(): ITagMap {
|
||||||
|
|
|
@ -32,13 +32,6 @@ export enum ListAlgorithm {
|
||||||
Natural = "NATURAL",
|
Natural = "NATURAL",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Category {
|
|
||||||
Red = "RED",
|
|
||||||
Grey = "GREY",
|
|
||||||
Bold = "BOLD",
|
|
||||||
Idle = "IDLE",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ITagSortingMap {
|
export interface ITagSortingMap {
|
||||||
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
|
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
|
||||||
[tagId: TagID]: SortAlgorithm;
|
[tagId: TagID]: SortAlgorithm;
|
||||||
|
@ -50,7 +43,7 @@ export interface ITagMap {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Convert IAlgorithm to an abstract class?
|
// TODO: Convert IAlgorithm to an abstract class?
|
||||||
// TODO: Add locking support to avoid concurrent writes
|
// TODO: Add locking support to avoid concurrent writes?
|
||||||
// TODO: EventEmitter support
|
// TODO: EventEmitter support
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
189
src/stores/room-list/algorithms/ImportanceAlgorithm.ts
Normal file
189
src/stores/room-list/algorithms/ImportanceAlgorithm.ts
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IAlgorithm, ITagMap, ITagSortingMap } from "./IAlgorithm";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
|
||||||
|
import { DefaultTagID, TagID } from "../models";
|
||||||
|
import { splitRoomsByMembership } from "../membership";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The determined category of a room.
|
||||||
|
*/
|
||||||
|
export enum Category {
|
||||||
|
/**
|
||||||
|
* The room has unread mentions within.
|
||||||
|
*/
|
||||||
|
Red = "RED",
|
||||||
|
/**
|
||||||
|
* The room has unread notifications within. Note that these are not unread
|
||||||
|
* mentions - they are simply messages which the user has asked to cause a
|
||||||
|
* badge count update or push notification.
|
||||||
|
*/
|
||||||
|
Grey = "GREY",
|
||||||
|
/**
|
||||||
|
* The room has unread messages within (grey without the badge).
|
||||||
|
*/
|
||||||
|
Bold = "BOLD",
|
||||||
|
/**
|
||||||
|
* The room has no relevant unread messages within.
|
||||||
|
*/
|
||||||
|
Idle = "IDLE",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "importance" algorithm for room list sorting. Where
|
||||||
|
* the tag sorting algorithm does not interfere, rooms will be ordered into
|
||||||
|
* categories of varying importance to the user. Alphabetical sorting does not
|
||||||
|
* interfere with this algorithm, however manual ordering does.
|
||||||
|
*
|
||||||
|
* The importance of a room is defined by the kind of notifications, if any, are
|
||||||
|
* present on the room. These are classified internally as Red, Grey, Bold, and
|
||||||
|
* Idle. Red rooms have mentions, grey have unread messages, bold is a less noisy
|
||||||
|
* version of grey, and idle means all activity has been seen by the user.
|
||||||
|
*
|
||||||
|
* The algorithm works by monitoring all room changes, including new messages in
|
||||||
|
* tracked rooms, to determine if it needs a new category or different placement
|
||||||
|
* within the same category. For more information, see the comments contained
|
||||||
|
* within the class.
|
||||||
|
*/
|
||||||
|
export class ImportanceAlgorithm implements IAlgorithm {
|
||||||
|
|
||||||
|
// HOW THIS WORKS
|
||||||
|
// --------------
|
||||||
|
//
|
||||||
|
// This block of comments assumes you've read the README one level higher.
|
||||||
|
// You should do that if you haven't already.
|
||||||
|
//
|
||||||
|
// Tags are fed into the algorithmic functions from the TagManager changes,
|
||||||
|
// which cause subsequent updates to the room list itself. Categories within
|
||||||
|
// those tags are tracked as index numbers within the array (zero = top), with
|
||||||
|
// each sticky room being tracked separately. Internally, the category index
|
||||||
|
// can be found from `this.indices[tag][category]` and the sticky room information
|
||||||
|
// from `this.stickyRooms[tag]`.
|
||||||
|
//
|
||||||
|
// Room categories are constantly re-evaluated and tracked in the `this.categorized`
|
||||||
|
// object. Note that this doesn't track rooms by category but instead by room ID.
|
||||||
|
// The theory is that by knowing the previous position, new desired position, and
|
||||||
|
// category indices we can avoid tracking multiple complicated maps in memory.
|
||||||
|
//
|
||||||
|
// The room list store is always provided with the `this.cached` results, which are
|
||||||
|
// updated as needed and not recalculated often. For example, when a room needs to
|
||||||
|
// move within a tag, the array in `this.cached` will be spliced instead of iterated.
|
||||||
|
|
||||||
|
private cached: ITagMap = {};
|
||||||
|
private sortAlgorithms: ITagSortingMap;
|
||||||
|
private rooms: Room[] = [];
|
||||||
|
private indices: {
|
||||||
|
// @ts-ignore - TS wants this to be a string but we know better than it
|
||||||
|
[tag: TagID]: {
|
||||||
|
// @ts-ignore - TS wants this to be a string but we know better than it
|
||||||
|
[category: Category]: number; // integer
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
private stickyRooms: {
|
||||||
|
// @ts-ignore - TS wants this to be a string but we know better than it
|
||||||
|
[tag: TagID]: {
|
||||||
|
room?: Room;
|
||||||
|
nAbove: number; // integer
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
private categorized: {
|
||||||
|
// @ts-ignore - TS wants this to be a string but we know better than it
|
||||||
|
[tag: TagID]: {
|
||||||
|
// TODO: Remove note
|
||||||
|
// Note: Should in theory be able to only track this by room ID as we'll know
|
||||||
|
// the indices of each category and can determine if a category needs changing
|
||||||
|
// in the cached list. Could potentially save a bunch of time if we can figure
|
||||||
|
// out where a room is supposed to be using offsets, some math, and leaving the
|
||||||
|
// array generally alone.
|
||||||
|
[roomId: string]: {
|
||||||
|
room: Room;
|
||||||
|
category: Category;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
console.log("Constructed an ImportanceAlgorithm");
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrderedRooms(): ITagMap {
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
async populateTags(tagSortingMap: ITagSortingMap): Promise<any> {
|
||||||
|
if (!tagSortingMap) throw new Error(`Map cannot be null or empty`);
|
||||||
|
this.sortAlgorithms = tagSortingMap;
|
||||||
|
this.setKnownRooms(this.rooms); // regenerate the room lists
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRoomUpdate(room): Promise<boolean> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setKnownRooms(rooms: Room[]): Promise<any> {
|
||||||
|
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
|
||||||
|
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
|
||||||
|
|
||||||
|
this.rooms = rooms;
|
||||||
|
|
||||||
|
const newTags = {};
|
||||||
|
for (const tagId in this.sortAlgorithms) {
|
||||||
|
// noinspection JSUnfilteredForInLoop
|
||||||
|
newTags[tagId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can avoid doing work, do so.
|
||||||
|
if (!rooms.length) {
|
||||||
|
this.cached = newTags;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove logging
|
||||||
|
const memberships = splitRoomsByMembership(rooms);
|
||||||
|
console.log({memberships});
|
||||||
|
|
||||||
|
// Step through each room and determine which tags it should be in.
|
||||||
|
// We don't care about ordering or sorting here - we're simply organizing things.
|
||||||
|
for (const room of rooms) {
|
||||||
|
const tags = room.tags;
|
||||||
|
let inTag = false;
|
||||||
|
for (const tagId in tags) {
|
||||||
|
// noinspection JSUnfilteredForInLoop
|
||||||
|
if (isNullOrUndefined(newTags[tagId])) {
|
||||||
|
// skip the tag if we don't know about it
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inTag = true;
|
||||||
|
|
||||||
|
// noinspection JSUnfilteredForInLoop
|
||||||
|
newTags[tagId].push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the room wasn't pushed to a tag, push it to the untagged tag.
|
||||||
|
if (!inTag) {
|
||||||
|
newTags[DefaultTagID.Untagged].push(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Do sorting
|
||||||
|
|
||||||
|
// Finally, assign the tags to our cache
|
||||||
|
this.cached = newTags;
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,10 +16,11 @@ limitations under the License.
|
||||||
|
|
||||||
import { IAlgorithm, ListAlgorithm } from "./IAlgorithm";
|
import { IAlgorithm, ListAlgorithm } from "./IAlgorithm";
|
||||||
import { ChaoticAlgorithm } from "./ChaoticAlgorithm";
|
import { ChaoticAlgorithm } from "./ChaoticAlgorithm";
|
||||||
|
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
|
||||||
|
|
||||||
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => IAlgorithm } = {
|
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => IAlgorithm } = {
|
||||||
[ListAlgorithm.Natural]: () => new ChaoticAlgorithm(ListAlgorithm.Natural),
|
[ListAlgorithm.Natural]: () => new ChaoticAlgorithm(ListAlgorithm.Natural),
|
||||||
[ListAlgorithm.Importance]: () => new ChaoticAlgorithm(ListAlgorithm.Importance),
|
[ListAlgorithm.Importance]: () => new ImportanceAlgorithm(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in a new issue