Merge pull request #4754 from matrix-org/travis/room-list/per-list-algo

Support per-list options and algorithms on the new room list
This commit is contained in:
Travis Ralston 2020-06-12 08:40:23 -06:00 committed by GitHub
commit 9eb965878a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 448 additions and 254 deletions

BIN
docs/img/RoomListStore2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -2,20 +2,31 @@
It's so complicated it needs its own README.
![](img/RoomListStore2.png)
Legend:
* Orange = External event.
* Purple = Deterministic flow.
* Green = Algorithm definition.
* Red = Exit condition/point.
* Blue = Process definition.
## 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 rooms get ordered within tags affected by the list algorithm.
Algorithm respectively. The list algorithm determines the primary ordering of a given tag whereas the
tag sorting defines how rooms within that tag get sorted, at the discretion of the list ordering.
Behaviour of the overall room list (sticky rooms, etc) are determined by the generically-named Algorithm
class. Here is where much of the coordination from the room list store is done to figure out which list
algorithm to call, instead of having all the logic in the room list store itself.
Behaviour of the room list takes the shape of determining what features the room list supports, as well
as determining where and when to apply the sorting algorithm in a tag. The importance algorithm, which
is described later in this doc, is an example of an algorithm which makes heavy behavioural changes
to the room list.
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.
the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm,
later described in this document, heavily uses the list ordering behaviour to break the tag into categories.
Each category then gets sorted by the appropriate tag sorting algorithm.
### Tag sorting algorithm: Alphabetical
@ -70,11 +81,11 @@ Conveniently, each tag gets ordered by those categories as presented: red rooms
above bold, etc.
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
gets applied to each category in a sub-sub-list fashion. This should result in the red rooms (for example)
gets applied to each category in a sub-list fashion. This should result in the red rooms (for example)
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
collectively the tag will be sorted into categories with red being at the top.
### Sticky rooms
## Sticky rooms
When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
@ -128,13 +139,13 @@ maintain the caching behaviour described above.
## Class breakdowns
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 get 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` class is responsible
for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get
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 various bits throughout the room list store should also have jsdoc of some kind to help describe
what they do and how they work.

View file

@ -314,6 +314,14 @@ limitations under the License.
font-size: $font-15px;
line-height: $font-20px;
font-weight: 600;
margin-bottom: 12px;
margin-bottom: 4px;
}
.mx_RadioButton, .mx_Checkbox {
margin-top: 8px;
}
.mx_Checkbox {
margin-left: -8px; // to counteract the indent from the component
}
}

View file

@ -27,8 +27,11 @@ import RoomTile2 from "./RoomTile2";
import { ResizableBox, ResizeCallbackData } from "react-resizable";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import StyledCheckbox from "../elements/StyledCheckbox";
import StyledRadioButton from "../elements/StyledRadioButton";
import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
/*******************************************************************
* CAUTION *
@ -116,9 +119,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
this.setState({menuDisplayed: false});
};
private onUnreadFirstChanged = () => {
// TODO: Support per-list algorithm changes
console.log("Unread first changed");
private onUnreadFirstChanged = async () => {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
await RoomListStore.instance.setListOrder(this.props.layout.tagId, newAlgorithm);
};
private onTagSortChanged = async (sort: SortAlgorithm) => {
await RoomListStore.instance.setTagSorting(this.props.layout.tagId, sort);
};
private onMessagePreviewChanged = () => {
@ -149,6 +157,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
let contextMenu = null;
if (this.state.menuDisplayed) {
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.layout.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance;
contextMenu = (
<ContextMenu
chevronFace="none"
@ -159,14 +169,27 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<div className="mx_RoomSublist2_contextMenu">
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div>
TODO: Radios are blocked by https://github.com/matrix-org/matrix-react-sdk/pull/4731
<StyledRadioButton
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical}
name={`mx_${this.props.layout.tagId}_sortBy`}
>
{_t("Activity")}
</StyledRadioButton>
<StyledRadioButton
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical}
name={`mx_${this.props.layout.tagId}_sortBy`}
>
{_t("A-Z")}
</StyledRadioButton>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
<StyledCheckbox
onChange={this.onUnreadFirstChanged}
checked={false/*TODO*/}
checked={isUnreadFirst}
>
{_t("Always show first")}
</StyledCheckbox>

View file

@ -1138,6 +1138,8 @@
"Not now": "Not now",
"Don't ask me again": "Don't ask me again",
"Sort by": "Sort by",
"Activity": "Activity",
"A-Z": "A-Z",
"Unread rooms": "Unread rooms",
"Always show first": "Always show first",
"Show": "Show",

View file

@ -17,25 +17,21 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client";
import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/list-ordering/Algorithm";
import { OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import TagOrderStore from "../TagOrderStore";
import { AsyncStore } from "../AsyncStore";
import { Room } from "matrix-js-sdk/src/models/room";
import { ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { getListAlgorithmInstance } from "./algorithms/list-ordering";
import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { IFilterCondition } from "./filters/IFilterCondition";
import { TagWatcher } from "./TagWatcher";
import RoomViewStore from "../RoomViewStore";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
interface IState {
tagsEnabled?: boolean;
preferredSort?: SortAlgorithm;
preferredAlgorithm?: ListAlgorithm;
}
/**
@ -48,7 +44,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
private _matrixClient: MatrixClient;
private initialListsGenerated = false;
private enabled = false;
private algorithm: Algorithm;
private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this);
@ -64,6 +60,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.checkEnabled();
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
RoomViewStore.addListener(this.onRVSUpdate);
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
}
public get orderedLists(): ITagMap {
@ -85,14 +82,10 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
private async readAndCacheSettingsFromStore() {
const tagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance");
const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically");
await this.updateState({
tagsEnabled,
preferredSort: orderAlphabetically ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent,
preferredAlgorithm: orderByImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural,
});
this.setAlgorithmClass();
await this.updateAlgorithmInstances();
}
private onRVSUpdate = () => {
@ -259,17 +252,57 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
}
}
private getSortAlgorithmFor(tagId: TagID): SortAlgorithm {
switch (tagId) {
case DefaultTagID.Invite:
case DefaultTagID.Untagged:
case DefaultTagID.Archived:
case DefaultTagID.LowPriority:
case DefaultTagID.DM:
return this.state.preferredSort;
case DefaultTagID.Favourite:
default:
return SortAlgorithm.Manual;
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.algorithm.setTagSorting(tagId, sort);
localStorage.setItem(`mx_tagSort_${tagId}`, sort);
}
public getTagSorting(tagId: TagID): SortAlgorithm {
return this.algorithm.getTagSorting(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredTagSorting(tagId: TagID): SortAlgorithm {
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
}
public async setListOrder(tagId: TagID, order: ListAlgorithm) {
await this.algorithm.setListOrdering(tagId, order);
localStorage.setItem(`mx_listOrder_${tagId}`, order);
}
public getListOrder(tagId: TagID): ListAlgorithm {
return this.algorithm.getListOrdering(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredListOrder(tagId: TagID): ListAlgorithm {
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
}
private async updateAlgorithmInstances() {
const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance");
const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically");
const defaultSort = orderAlphabetically ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent;
const defaultOrder = orderByImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural;
for (const tag of Object.keys(this.orderedLists)) {
const definedSort = this.getTagSorting(tag);
const definedOrder = this.getListOrder(tag);
const storedSort = this.getStoredTagSorting(tag);
const storedOrder = this.getStoredListOrder(tag);
const tagSort = storedSort ? storedSort : (definedSort ? definedSort : defaultSort);
const listOrder = storedOrder ? storedOrder : (definedOrder ? definedOrder : defaultOrder);
if (tagSort !== definedSort) {
await this.setTagSorting(tag, tagSort);
}
if (listOrder !== definedOrder) {
await this.setListOrder(tag, listOrder);
}
}
}
@ -279,15 +312,6 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
await super.updateState(newState);
}
private setAlgorithmClass() {
if (this.algorithm) {
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
}
this.algorithm = getListAlgorithmInstance(this.state.preferredAlgorithm);
this.algorithm.setFilterConditions(this.filterConditions);
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
}
private onAlgorithmListUpdated = () => {
console.log("Underlying algorithm has triggered a list update - refiring");
this.emit(LISTS_UPDATE_EVENT, this);
@ -296,9 +320,11 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
private async regenerateAllLists() {
console.warn("Regenerating all room lists");
const tags: ITagSortingMap = {};
const sorts: ITagSortingMap = {};
const orders: IListOrderingMap = {};
for (const tagId of OrderedDefaultTagIDs) {
tags[tagId] = this.getSortAlgorithmFor(tagId);
sorts[tagId] = this.getStoredTagSorting(tagId) || SortAlgorithm.Alphabetic;
orders[tagId] = this.getStoredListOrder(tagId) || ListAlgorithm.Natural;
}
if (this.state.tagsEnabled) {
@ -307,7 +333,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
console.log("rtags", roomTags);
}
await this.algorithm.populateTags(tags);
await this.algorithm.populateTags(sorts, orders);
await this.algorithm.setKnownRooms(this.matrixClient.getRooms());
this.initialListsGenerated = true;

View file

@ -14,17 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DefaultTagID, RoomUpdateCause, 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";
import { ITagMap, ITagSortingMap } from "../models";
import DMRoomMap from "../../../../utils/DMRoomMap";
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../../filters/IFilterCondition";
import DMRoomMap from "../../../utils/DMRoomMap";
import { EventEmitter } from "events";
import { UPDATE_EVENT } from "../../../AsyncStore";
import { ArrayUtil } from "../../../../utils/arrays";
import { getEnumValues } from "../../../../utils/enums";
import { arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
import { getEnumValues } from "../../../utils/enums";
import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
import {
IListOrderingMap,
IOrderingAlgorithmMap,
ITagMap,
ITagSortingMap,
ListAlgorithm,
SortAlgorithm
} from "./models";
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../filters/IFilterCondition";
import { EffectiveMembership, splitRoomsByMembership } from "../membership";
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
// TODO: Add locking support to avoid concurrent writes?
@ -44,21 +52,22 @@ interface IStickyRoom {
* management (which rooms go in which tags) and ask the implementation to
* deal with ordering mechanics.
*/
export abstract class Algorithm extends EventEmitter {
export class Algorithm extends EventEmitter {
private _cachedRooms: ITagMap = {};
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
private filteredRooms: ITagMap = {};
private _stickyRoom: IStickyRoom = null;
protected sortAlgorithms: ITagSortingMap;
protected rooms: Room[] = [];
protected roomIdsToTags: {
private sortAlgorithms: ITagSortingMap;
private listAlgorithms: IListOrderingMap;
private algorithms: IOrderingAlgorithmMap;
private rooms: Room[] = [];
private roomIdsToTags: {
[roomId: string]: TagID[];
} = {};
protected allowedByFilter: Map<IFilterCondition, Room[]> = new Map<IFilterCondition, Room[]>();
protected allowedRoomsByFilters: Set<Room> = new Set<Room>();
private allowedByFilter: Map<IFilterCondition, Room[]> = new Map<IFilterCondition, Room[]>();
private allowedRoomsByFilters: Set<Room> = new Set<Room>();
protected constructor() {
public constructor() {
super();
}
@ -68,6 +77,7 @@ export abstract class Algorithm extends EventEmitter {
public set stickyRoom(val: Room) {
// setters can't be async, so we call a private function to do the work
// noinspection JSIgnoredPromiseFromCall
this.updateStickyRoom(val);
}
@ -89,14 +99,38 @@ export abstract class Algorithm extends EventEmitter {
return this._cachedRooms;
}
/**
* Sets the filter conditions the Algorithm should use.
* @param filterConditions The filter conditions to use.
*/
public setFilterConditions(filterConditions: IFilterCondition[]): void {
for (const filter of filterConditions) {
this.addFilterCondition(filter);
}
public getTagSorting(tagId: TagID): SortAlgorithm {
return this.sortAlgorithms[tagId];
}
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
if (!tagId) throw new Error("Tag ID must be defined");
if (!sort) throw new Error("Algorithm must be defined");
this.sortAlgorithms[tagId] = sort;
const algorithm: OrderingAlgorithm = this.algorithms[tagId];
await algorithm.setSortAlgorithm(sort);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
}
public getListOrdering(tagId: TagID): ListAlgorithm {
return this.listAlgorithms[tagId];
}
public async setListOrdering(tagId: TagID, order: ListAlgorithm) {
if (!tagId) throw new Error("Tag ID must be defined");
if (!order) throw new Error("Algorithm must be defined");
this.listAlgorithms[tagId] = order;
const algorithm = getListAlgorithmInstance(order, tagId, this.sortAlgorithms[tagId]);
this.algorithms[tagId] = algorithm;
await algorithm.setRooms(this._cachedRooms[tagId])
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
}
public addFilterCondition(filterCondition: IFilterCondition): void {
@ -310,11 +344,21 @@ export abstract class Algorithm extends EventEmitter {
* as reference for which lists to generate and which way to generate
* them.
* @param {ITagSortingMap} tagSortingMap The tags to generate.
* @param {IListOrderingMap} listOrderingMap The ordering of those tags.
* @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`);
public async populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): Promise<any> {
if (!tagSortingMap) throw new Error(`Sorting map cannot be null or empty`);
if (!listOrderingMap) throw new Error(`Ordering ma cannot be null or empty`);
if (arrayHasDiff(Object.keys(tagSortingMap), Object.keys(listOrderingMap))) {
throw new Error(`Both maps must contain the exact same tags`);
}
this.sortAlgorithms = tagSortingMap;
this.listAlgorithms = listOrderingMap;
this.algorithms = {};
for (const tag of Object.keys(tagSortingMap)) {
this.algorithms[tag] = getListAlgorithmInstance(this.listAlgorithms[tag], tag, this.sortAlgorithms[tag]);
}
return this.setKnownRooms(this.rooms);
}
@ -428,7 +472,17 @@ export abstract class Algorithm extends EventEmitter {
* be mutated in place.
* @returns {Promise<*>} A promise which resolves when complete.
*/
protected abstract generateFreshTags(updatedTagMap: ITagMap): Promise<any>;
private async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
for (const tag of Object.keys(updatedTagMap)) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
await algorithm.setRooms(updatedTagMap[tag]);
updatedTagMap[tag] = algorithm.orderedRooms;
}
}
/**
* Asks the Algorithm to update its knowledge of a room. For example, when
@ -441,5 +495,48 @@ export abstract class Algorithm extends EventEmitter {
* depending on whether or not getOrderedRooms() should be called after
* processing.
*/
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
if (cause === RoomUpdateCause.PossibleTagChange) {
// TODO: Be smarter and splice rather than regen the planet.
// TODO: No-op if no change.
await this.setKnownRooms(this.rooms);
return true;
}
if (cause === RoomUpdateCause.NewRoom) {
// TODO: Be smarter and insert rather than regen the planet.
await this.setKnownRooms([room, ...this.rooms]);
return true;
}
if (cause === RoomUpdateCause.RoomRemoved) {
// TODO: Be smarter and splice rather than regen the planet.
await this.setKnownRooms(this.rooms.filter(r => r !== room));
return true;
}
let tags = this.roomIdsToTags[room.roomId];
if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
}
let changed = false;
for (const tag of tags) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
await algorithm.handleRoomUpdate(room, cause);
this.cachedRooms[tag] = algorithm.orderedRooms;
// Flag that we've done something
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
changed = true;
}
return true;
};
}

View file

@ -15,12 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Algorithm } from "./Algorithm";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomUpdateCause, TagID } from "../../models";
import { ITagMap, SortAlgorithm } from "../models";
import { SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting";
import * as Unread from '../../../../Unread';
import { OrderingAlgorithm } from "./OrderingAlgorithm";
/**
* The determined category of a room.
@ -77,32 +77,16 @@ const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idl
* within the same category. For more information, see the comments contained
* within the class.
*/
export class ImportanceAlgorithm extends Algorithm {
export class ImportanceAlgorithm extends OrderingAlgorithm {
// This tracks the category for the tag it represents by tracking the index of
// each category within the list, where zero is the top of the list. This then
// tracks when rooms change categories and splices the orderedRooms array as
// needed, preventing many ordering operations.
// HOW THIS WORKS
// --------------
//
// This block of comments assumes you've read the README two levels higher.
// You should do that if you haven't already.
//
// 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
// can be found from `this.indices[tag][category]`.
//
// The room list store is always provided with the `this.cachedRooms` 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.cachedRooms` will be spliced instead of iterated.
// The `indices` help track the positions of each category to make splicing easier.
private indices: ICategoryIndex = {};
private indices: {
// @ts-ignore - TS wants this to be a string but we know better than it
[tag: TagID]: ICategoryIndex;
} = {};
constructor() {
super();
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
console.log("Constructed an ImportanceAlgorithm");
}
@ -143,122 +127,86 @@ export class ImportanceAlgorithm extends Algorithm {
return Category.Idle;
}
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
for (const tagId of Object.keys(updatedTagMap)) {
const unorderedRooms = updatedTagMap[tagId];
const sortBy = this.sortAlgorithms[tagId];
if (!sortBy) throw new Error(`${tagId} does not have a sorting algorithm`);
if (sortBy === SortAlgorithm.Manual) {
// Manual tags essentially ignore the importance algorithm, so don't do anything
// special about them.
updatedTagMap[tagId] = await sortRoomsWithAlgorithm(unorderedRooms, tagId, sortBy);
} else {
// Every other sorting type affects the categories, not the whole tag.
const categorized = this.categorizeRooms(unorderedRooms);
for (const category of Object.keys(categorized)) {
const roomsToOrder = categorized[category];
categorized[category] = await sortRoomsWithAlgorithm(roomsToOrder, tagId, sortBy);
}
const newlyOrganized: Room[] = [];
const newIndices: ICategoryIndex = {};
for (const category of CATEGORY_ORDER) {
newIndices[category] = newlyOrganized.length;
newlyOrganized.push(...categorized[category]);
}
this.indices[tagId] = newIndices;
updatedTagMap[tagId] = newlyOrganized;
public async setRooms(rooms: Room[]): Promise<any> {
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
} else {
// Every other sorting type affects the categories, not the whole tag.
const categorized = this.categorizeRooms(rooms);
for (const category of Object.keys(categorized)) {
const roomsToOrder = categorized[category];
categorized[category] = await sortRoomsWithAlgorithm(roomsToOrder, this.tagId, this.sortingAlgorithm);
}
const newlyOrganized: Room[] = [];
const newIndices: ICategoryIndex = {};
for (const category of CATEGORY_ORDER) {
newIndices[category] = newlyOrganized.length;
newlyOrganized.push(...categorized[category]);
}
this.indices = newIndices;
this.cachedOrderedRooms = newlyOrganized;
}
}
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
if (cause === RoomUpdateCause.PossibleTagChange) {
// TODO: Be smarter and splice rather than regen the planet.
// TODO: No-op if no change.
await this.setKnownRooms(this.rooms);
return;
// TODO: Handle NewRoom and RoomRemoved
if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
throw new Error(`Unsupported update cause: ${cause}`);
}
if (cause === RoomUpdateCause.NewRoom) {
// TODO: Be smarter and insert rather than regen the planet.
await this.setKnownRooms([room, ...this.rooms]);
return;
}
if (cause === RoomUpdateCause.RoomRemoved) {
// TODO: Be smarter and splice rather than regen the planet.
await this.setKnownRooms(this.rooms.filter(r => r !== room));
return;
}
let tags = this.roomIdsToTags[room.roomId];
if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
}
const category = this.getRoomCategory(room);
let changed = false;
for (const tag of tags) {
if (this.sortAlgorithms[tag] === SortAlgorithm.Manual) {
continue; // Nothing to do here.
}
const taggedRooms = this.cachedRooms[tag];
const indices = this.indices[tag];
let roomIdx = taggedRooms.indexOf(room);
if (roomIdx === -1) {
console.warn(`Degrading performance to find missing room in "${tag}": ${room.roomId}`);
roomIdx = taggedRooms.findIndex(r => r.roomId === room.roomId);
}
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${tag}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, indices);
taggedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
taggedRooms.splice(indices[category], 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// The room received an update, so take out the slice and sort it. This should be relatively
// quick because the room is inserted at the top of the category, and most popular sorting
// algorithms will deal with trying to keep the active room at the top/start of the category.
// For the few algorithms that will have to move the thing quite far (alphabetic with a Z room
// for example), the list should already be sorted well enough that it can rip through the
// array and slot the changed room in quickly.
const nextCategoryStartIdx = category === CATEGORY_ORDER[CATEGORY_ORDER.length - 1]
? Number.MAX_SAFE_INTEGER
: indices[CATEGORY_ORDER[CATEGORY_ORDER.indexOf(category) + 1]];
const startIdx = indices[category];
const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
const unsortedSlice = taggedRooms.splice(startIdx, numSort);
const sorted = await sortRoomsWithAlgorithm(unsortedSlice, tag, this.sortAlgorithms[tag]);
taggedRooms.splice(startIdx, 0, ...sorted);
// Finally, flag that we've done something
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
changed = true;
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
return; // Nothing to do here.
}
return changed;
let roomIdx = this.cachedOrderedRooms.indexOf(room);
if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
}
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// The room received an update, so take out the slice and sort it. This should be relatively
// quick because the room is inserted at the top of the category, and most popular sorting
// algorithms will deal with trying to keep the active room at the top/start of the category.
// For the few algorithms that will have to move the thing quite far (alphabetic with a Z room
// for example), the list should already be sorted well enough that it can rip through the
// array and slot the changed room in quickly.
const nextCategoryStartIdx = category === CATEGORY_ORDER[CATEGORY_ORDER.length - 1]
? Number.MAX_SAFE_INTEGER
: this.indices[CATEGORY_ORDER[CATEGORY_ORDER.indexOf(category) + 1]];
const startIdx = this.indices[category];
const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
const unsortedSlice = this.cachedOrderedRooms.splice(startIdx, numSort);
const sorted = await sortRoomsWithAlgorithm(unsortedSlice, this.tagId, this.sortingAlgorithm);
this.cachedOrderedRooms.splice(startIdx, 0, ...sorted);
return true; // change made
}
// noinspection JSMethodCanBeStatic
private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category {
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
const category = CATEGORY_ORDER[i];
@ -274,6 +222,7 @@ export class ImportanceAlgorithm extends Algorithm {
throw new Error("Programming error: somehow you've ended up with an index that isn't in a category");
}
// noinspection JSMethodCanBeStatic
private moveRoomIndexes(nRooms: number, fromCategory: Category, toCategory: Category, indices: ICategoryIndex) {
// We have to update the index of the category *after* the from/toCategory variables
// in order to update the indices correctly. Because the room is moving from/to those

View file

@ -14,49 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Algorithm } from "./Algorithm";
import { ITagMap } from "../models";
import { SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting";
import { OrderingAlgorithm } from "./OrderingAlgorithm";
import { RoomUpdateCause, TagID } from "../../models";
import { Room } from "matrix-js-sdk/src/models/room";
/**
* Uses the natural tag sorting algorithm order to determine tag ordering. No
* additional behavioural changes are present.
*/
export class NaturalAlgorithm extends Algorithm {
export class NaturalAlgorithm extends OrderingAlgorithm {
constructor() {
super();
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
console.log("Constructed a NaturalAlgorithm");
}
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
for (const tagId of Object.keys(updatedTagMap)) {
const unorderedRooms = updatedTagMap[tagId];
const sortBy = this.sortAlgorithms[tagId];
if (!sortBy) throw new Error(`${tagId} does not have a sorting algorithm`);
updatedTagMap[tagId] = await sortRoomsWithAlgorithm(unorderedRooms, tagId, sortBy);
}
public async setRooms(rooms: Room[]): Promise<any> {
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
}
public async handleRoomUpdate(room, cause): Promise<boolean> {
const tags = this.roomIdsToTags[room.roomId];
if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
// TODO: Handle NewRoom and RoomRemoved
if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
throw new Error(`Unsupported update cause: ${cause}`);
}
let changed = false;
for (const tag of tags) {
// TODO: Optimize this loop to avoid useless operations
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedRooms[tag] = await sortRoomsWithAlgorithm(this.cachedRooms[tag], tag, this.sortAlgorithms[tag]);
// Flag that we've done something
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
changed = true;
}
return changed;
// TODO: Optimize this to avoid useless operations
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm);
return true;
}
}

View file

@ -0,0 +1,72 @@
/*
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 { Room } from "matrix-js-sdk/src/models/room";
import { RoomUpdateCause, TagID } from "../../models";
import { SortAlgorithm } from "../models";
/**
* Represents a list ordering algorithm. Subclasses should populate the
* `cachedOrderedRooms` field.
*/
export abstract class OrderingAlgorithm {
protected cachedOrderedRooms: Room[];
protected sortingAlgorithm: SortAlgorithm;
protected constructor(protected tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
// noinspection JSIgnoredPromiseFromCall
this.setSortAlgorithm(initialSortingAlgorithm); // we use the setter for validation
}
/**
* The rooms as ordered by the algorithm.
*/
public get orderedRooms(): Room[] {
return this.cachedOrderedRooms || [];
}
/**
* Sets the sorting algorithm to use within the list.
* @param newAlgorithm The new algorithm. Must be defined.
* @returns Resolves when complete.
*/
public async setSortAlgorithm(newAlgorithm: SortAlgorithm) {
if (!newAlgorithm) throw new Error("A sorting algorithm must be defined");
this.sortingAlgorithm = newAlgorithm;
// Force regeneration of the rooms
await this.setRooms(this.orderedRooms);
}
/**
* Sets the rooms the algorithm should be handling, implying a reconstruction
* of the ordering.
* @param rooms The rooms to use going forward.
* @returns Resolves when complete.
*/
public abstract setRooms(rooms: Room[]): Promise<any>;
/**
* Handle a room update. The Algorithm will only call this for causes which
* the list ordering algorithm can handle within the same tag. For example,
* tag changes will not be sent here.
* @param room The room where the update happened.
* @param cause The cause of the update.
* @returns True if the update requires the Algorithm to update the presentation layers.
*/
// XXX: TODO: We assume this will only ever be a position update and NOT a NewRoom or RemoveRoom change!!
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
}

View file

@ -14,25 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Algorithm } from "./Algorithm";
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
import { ListAlgorithm } from "../models";
import { ListAlgorithm, SortAlgorithm } from "../models";
import { NaturalAlgorithm } from "./NaturalAlgorithm";
import { TagID } from "../../models";
import { OrderingAlgorithm } from "./OrderingAlgorithm";
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => Algorithm } = {
[ListAlgorithm.Natural]: () => new NaturalAlgorithm(),
[ListAlgorithm.Importance]: () => new ImportanceAlgorithm(),
interface AlgorithmFactory {
(tagId: TagID, initialSortingAlgorithm: SortAlgorithm): OrderingAlgorithm;
}
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: AlgorithmFactory } = {
[ListAlgorithm.Natural]: (tagId, initSort) => new NaturalAlgorithm(tagId, initSort),
[ListAlgorithm.Importance]: (tagId, initSort) => new ImportanceAlgorithm(tagId, initSort),
};
/**
* Gets an instance of the defined algorithm
* @param {ListAlgorithm} algorithm The algorithm to get an instance of.
* @param {TagID} tagId The tag the algorithm is for.
* @param {SortAlgorithm} initSort The initial sorting algorithm for the ordering algorithm.
* @returns {Algorithm} The algorithm instance.
*/
export function getListAlgorithmInstance(algorithm: ListAlgorithm): Algorithm {
export function getListAlgorithmInstance(algorithm: ListAlgorithm, tagId: TagID, initSort: SortAlgorithm): OrderingAlgorithm {
if (!ALGORITHM_FACTORIES[algorithm]) {
throw new Error(`${algorithm} is not a known algorithm`);
}
return ALGORITHM_FACTORIES[algorithm]();
return ALGORITHM_FACTORIES[algorithm](tagId, initSort);
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import { TagID } from "../models";
import { Room } from "matrix-js-sdk/src/models/room";
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
export enum SortAlgorithm {
Manual = "MANUAL",
@ -36,6 +37,16 @@ export interface ITagSortingMap {
[tagId: TagID]: SortAlgorithm;
}
export interface IListOrderingMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: ListAlgorithm;
}
export interface IOrderingAlgorithmMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: OrderingAlgorithm;
}
export interface ITagMap {
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
[tagId: TagID]: Room[];