Remove the need for a tag manager

Instead putting the tag handling in the Algorithm class
This commit is contained in:
Travis Ralston 2020-04-29 16:19:10 -06:00
parent 9c0422691a
commit e7fffee175
9 changed files with 212 additions and 269 deletions

View file

@ -109,9 +109,10 @@ 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.
The `RoomListStore` is the major coordinator of various `Algorithm` implementations, which take care
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` superclass is also
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

@ -19,7 +19,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { ActionPayload, defaultDispatcher } from "../../dispatcher-types";
import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID, OrderedDefaultTagIDs, TagID } from "./models";
import { IAlgorithm, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/IAlgorithm";
import { Algorithm, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/Algorithm";
import TagOrderStore from "../TagOrderStore";
import { getAlgorithmInstance } from "./algorithms";
import { AsyncStore } from "../AsyncStore";
@ -41,7 +41,7 @@ class _RoomListStore extends AsyncStore<ActionPayload> {
private matrixClient: MatrixClient;
private initialListsGenerated = false;
private enabled = false;
private algorithm: IAlgorithm;
private algorithm: Algorithm;
private readonly watchedSettings = [
'RoomList.orderAlphabetically',

View file

@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "./RoomListStore2";
import OldRoomListStore from "../RoomListStore";
import { ITagMap } from "./algorithms/IAlgorithm";
import { ITagMap } from "./algorithms/Algorithm";
import { UPDATE_EVENT } from "../AsyncStore";
/**

View file

@ -1,32 +0,0 @@
/*
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

@ -0,0 +1,178 @@
/*
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 { DefaultTagID, TagID } from "../models";
import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { EffectiveMembership, splitRoomsByMembership } from "../membership";
export enum SortAlgorithm {
Manual = "MANUAL",
Alphabetic = "ALPHABETIC",
Recent = "RECENT",
}
export enum ListAlgorithm {
// Orders Red > Grey > Bold > Idle
Importance = "IMPORTANCE",
// Orders however the SortAlgorithm decides
Natural = "NATURAL",
}
export interface ITagSortingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: SortAlgorithm;
}
export interface ITagMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: Room[];
}
// TODO: Add locking support to avoid concurrent writes?
// TODO: EventEmitter support? Might not be needed.
export abstract class Algorithm {
protected cached: ITagMap = {};
protected sortAlgorithms: ITagSortingMap;
protected rooms: Room[] = [];
protected roomsByTag: {
// @ts-ignore - TS wants this to be a string but we know better
[tagId: TagID]: Room[];
} = {};
protected constructor() {
}
/**
* Asks the Algorithm to regenerate all lists, using the tags given
* as reference for which lists to generate and which way to generate
* them.
* @param {ITagSortingMap} tagSortingMap The tags to generate.
* @returns {Promise<*>} A promise which resolves when complete.
*/
public async populateTags(tagSortingMap: ITagSortingMap): Promise<any> {
if (!tagSortingMap) throw new Error(`Map cannot be null or empty`);
this.sortAlgorithms = tagSortingMap;
return this.setKnownRooms(this.rooms);
}
/**
* Gets an ordered set of rooms for the all known tags.
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
public getOrderedRooms(): ITagMap {
return this.cached;
}
/**
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
* previously known information and instead use these rooms instead.
* @param {Room[]} rooms The rooms to force the algorithm to use.
* @returns {Promise<*>} A promise which resolves when complete.
*/
public async 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: ITagMap = {};
for (const tagId in this.sortAlgorithms) {
// noinspection JSUnfilteredForInLoop
newTags[tagId] = [];
}
// If we can avoid doing work, do so.
if (!rooms.length) {
await this.generateFreshTags(newTags); // just in case it wants to do something
this.cached = newTags;
return;
}
// Split out the easy rooms first (leave and invite)
const memberships = splitRoomsByMembership(rooms);
for (const room of memberships[EffectiveMembership.Invite]) {
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
newTags[DefaultTagID.Invite].push(room);
}
for (const room of memberships[EffectiveMembership.Leave]) {
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
newTags[DefaultTagID.Archived].push(room);
}
// Now process all the joined rooms. This is a bit more complicated
for (const room of memberships[EffectiveMembership.Join]) {
const tags = Object.keys(room.tags || {});
let inTag = false;
if (tags.length > 0) {
for (const tag of tags) {
if (!isNullOrUndefined(newTags[tag])) {
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
newTags[tag].push(room);
inTag = true;
}
}
}
if (!inTag) {
// TODO: Determine if DM and push there instead
newTags[DefaultTagID.Untagged].push(room);
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`);
}
}
await this.generateFreshTags(newTags);
this.cached = newTags;
}
/**
* Called when the Algorithm believes a complete regeneration of the existing
* lists is needed.
* @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag
* will already have the rooms which belong to it - they just need ordering. Must
* be mutated in place.
* @returns {Promise<*>} A promise which resolves when complete.
*/
protected abstract generateFreshTags(updatedTagMap: ITagMap): Promise<any>;
/**
* Called when the Algorithm wants a whole tag to be reordered. Typically this will
* be done whenever the tag's scope changes (added/removed rooms).
* @param {TagID} tagId The tag ID which changed.
* @param {Room[]} rooms The rooms within the tag, unordered.
* @returns {Promise<Room[]>} Resolves to the ordered rooms in the tag.
*/
protected abstract regenerateTag(tagId: TagID, rooms: Room[]): Promise<Room[]>;
/**
* Asks the Algorithm to update its knowledge of a room. For example, when
* a user tags a room, joins/creates a room, or leaves a room the Algorithm
* should be told that the room's info might have changed. The Algorithm
* may no-op this request if no changes are required.
* @param {Room} room The room which might have affected sorting.
* @returns {Promise<boolean>} A promise which resolve to true or false
* depending on whether or not getOrderedRooms() should be called after
* processing.
*/
// TODO: Take a ReasonForChange to better predict the behaviour?
// TODO: Intercept here and handle tag changes automatically
public abstract handleRoomUpdate(room: Room): Promise<boolean>;
}

View file

@ -14,89 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IAlgorithm, ITagMap, ITagSortingMap, ListAlgorithm } from "./IAlgorithm";
import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { Algorithm, ITagMap } from "./Algorithm";
import { DefaultTagID } from "../models";
import { splitRoomsByMembership } from "../membership";
/**
* A demonstration/temporary algorithm to verify the API surface works.
* TODO: Remove this before shipping
*/
export class ChaoticAlgorithm implements IAlgorithm {
export class ChaoticAlgorithm extends Algorithm {
private cached: ITagMap = {};
private sortAlgorithms: ITagSortingMap;
private rooms: Room[] = [];
constructor(private representativeAlgorithm: ListAlgorithm) {
constructor() {
super();
console.log("Constructed a ChaoticAlgorithm");
}
getOrderedRooms(): ITagMap {
return this.cached;
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
return Promise.resolve();
}
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
protected async regenerateTag(tagId: string | DefaultTagID, rooms: []): Promise<[]> {
return Promise.resolve(rooms);
}
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({alg: this.representativeAlgorithm, 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;
public async handleRoomUpdate(room): Promise<boolean> {
return Promise.resolve(false);
}
}

View file

@ -1,88 +0,0 @@
/*
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 { TagID } from "../models";
import { Room } from "matrix-js-sdk/src/models/room";
export enum SortAlgorithm {
Manual = "MANUAL",
Alphabetic = "ALPHABETIC",
Recent = "RECENT",
}
export enum ListAlgorithm {
// Orders Red > Grey > Bold > Idle
Importance = "IMPORTANCE",
// Orders however the SortAlgorithm decides
Natural = "NATURAL",
}
export interface ITagSortingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: SortAlgorithm;
}
export interface ITagMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: Room[];
}
// TODO: Convert IAlgorithm to an abstract class?
// TODO: Add locking support to avoid concurrent writes?
// TODO: EventEmitter support
/**
* Represents an algorithm for the RoomListStore to use
*/
export interface IAlgorithm {
/**
* Asks the Algorithm to regenerate all lists, using the tags given
* as reference for which lists to generate and which way to generate
* them.
* @param {ITagSortingMap} tagSortingMap The tags to generate.
* @returns {Promise<*>} A promise which resolves when complete.
*/
populateTags(tagSortingMap: ITagSortingMap): Promise<any>;
/**
* Gets an ordered set of rooms for the all known tags.
* @returns {ITagMap} The cached list of rooms, ordered,
* for each tag. May be empty, but never null/undefined.
*/
getOrderedRooms(): ITagMap;
/**
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
* previously known information and instead use these rooms instead.
* @param {Room[]} rooms The rooms to force the algorithm to use.
* @returns {Promise<*>} A promise which resolves when complete.
*/
setKnownRooms(rooms: Room[]): Promise<any>;
/**
* Asks the Algorithm to update its knowledge of a room. For example, when
* a user tags a room, joins/creates a room, or leaves a room the Algorithm
* should be told that the room's info might have changed. The Algorithm
* may no-op this request if no changes are required.
* @param {Room} room The room which might have affected sorting.
* @returns {Promise<boolean>} A promise which resolve to true or false
* depending on whether or not getOrderedRooms() should be called after
* processing.
*/
handleRoomUpdate(room: Room): Promise<boolean>; // TODO: Take a ReasonForChange to better predict the behaviour?
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IAlgorithm, ITagMap, ITagSortingMap } from "./IAlgorithm";
import { Algorithm, ITagMap, ITagSortingMap } from "./Algorithm";
import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { DefaultTagID, TagID } from "../models";
@ -60,7 +60,7 @@ export enum Category {
* within the same category. For more information, see the comments contained
* within the class.
*/
export class ImportanceAlgorithm implements IAlgorithm {
export class ImportanceAlgorithm extends Algorithm {
// HOW THIS WORKS
// --------------
@ -68,7 +68,7 @@ export class ImportanceAlgorithm implements IAlgorithm {
// 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,
// Tags are fed into the algorithmic functions from the Algorithm superclass,
// 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
@ -84,9 +84,6 @@ export class ImportanceAlgorithm implements IAlgorithm {
// 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]: {
@ -118,72 +115,19 @@ export class ImportanceAlgorithm implements IAlgorithm {
} = {};
constructor() {
super();
console.log("Constructed an ImportanceAlgorithm");
}
getOrderedRooms(): ITagMap {
return this.cached;
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
return Promise.resolve();
}
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
protected async regenerateTag(tagId: string | DefaultTagID, rooms: []): Promise<[]> {
return Promise.resolve(rooms);
}
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;
public async handleRoomUpdate(room): Promise<boolean> {
return Promise.resolve(false);
}
}

View file

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IAlgorithm, ListAlgorithm } from "./IAlgorithm";
import { Algorithm, ListAlgorithm } from "./Algorithm";
import { ChaoticAlgorithm } from "./ChaoticAlgorithm";
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => IAlgorithm } = {
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => Algorithm } = {
[ListAlgorithm.Natural]: () => new ChaoticAlgorithm(ListAlgorithm.Natural),
[ListAlgorithm.Importance]: () => new ImportanceAlgorithm(),
};
@ -26,9 +26,9 @@ const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => IAlgorithm } =
/**
* Gets an instance of the defined algorithm
* @param {ListAlgorithm} algorithm The algorithm to get an instance of.
* @returns {IAlgorithm} The algorithm instance.
* @returns {Algorithm} The algorithm instance.
*/
export function getAlgorithmInstance(algorithm: ListAlgorithm): IAlgorithm {
export function getAlgorithmInstance(algorithm: ListAlgorithm): Algorithm {
if (!ALGORITHM_FACTORIES[algorithm]) {
throw new Error(`${algorithm} is not a known algorithm`);
}