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:
Travis Ralston 2020-04-28 20:36:42 -06:00
parent becaddeb80
commit 00d400b516
6 changed files with 342 additions and 9 deletions

View 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.

View 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.
}

View file

@ -31,6 +31,7 @@ export class ChaoticAlgorithm implements IAlgorithm {
private rooms: Room[] = [];
constructor(private representativeAlgorithm: ListAlgorithm) {
console.log("Constructed a ChaoticAlgorithm");
}
getOrderedRooms(): ITagMap {

View file

@ -32,13 +32,6 @@ export enum ListAlgorithm {
Natural = "NATURAL",
}
export enum Category {
Red = "RED",
Grey = "GREY",
Bold = "BOLD",
Idle = "IDLE",
}
export interface ITagSortingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: SortAlgorithm;
@ -50,7 +43,7 @@ export interface ITagMap {
}
// 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
/**

View 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;
}
}

View file

@ -16,10 +16,11 @@ limitations under the License.
import { IAlgorithm, ListAlgorithm } from "./IAlgorithm";
import { ChaoticAlgorithm } from "./ChaoticAlgorithm";
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => IAlgorithm } = {
[ListAlgorithm.Natural]: () => new ChaoticAlgorithm(ListAlgorithm.Natural),
[ListAlgorithm.Importance]: () => new ChaoticAlgorithm(ListAlgorithm.Importance),
[ListAlgorithm.Importance]: () => new ImportanceAlgorithm(),
};
/**