Initial breakout for room list rewrite

This does a number of things (sorry):
* Estimates the type changes needed to the dispatcher (later to be replaced by https://github.com/matrix-org/matrix-react-sdk/pull/4593)
* Sets up the stack for a whole new room list store, and later components for usage.
* Create a proxy class to ensure the app still functions as expected when the various stores are enabled/disabled
* Demonstrates a possible structure for algorithms
This commit is contained in:
Travis Ralston 2020-03-20 14:38:20 -06:00
parent 82b55ffd77
commit 08419d195e
21 changed files with 794 additions and 47 deletions

View file

@ -15,10 +15,10 @@ limitations under the License.
*/
import dis from '../dispatcher';
import * as RoomNotifs from '../RoomNotifs';
import RoomListStore from './RoomListStore';
import EventEmitter from 'events';
import { throttle } from "lodash";
import SettingsStore from "../settings/SettingsStore";
import {RoomListStoreTempProxy} from "./room-list/RoomListStoreTempProxy";
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
@ -60,7 +60,7 @@ class CustomRoomTagStore extends EventEmitter {
trailing: true,
},
);
this._roomListStoreToken = RoomListStore.addListener(() => {
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
this._setState({tags: this._getUpdatedTags()});
});
dis.register(payload => this._onDispatch(payload));
@ -85,7 +85,7 @@ class CustomRoomTagStore extends EventEmitter {
}
getSortedTags() {
const roomLists = RoomListStore.getRoomLists();
const roomLists = RoomListStoreTempProxy.getRoomLists();
const tagNames = Object.keys(this._state.tags).sort();
const prefixes = tagNames.map((name, i) => {
@ -140,7 +140,7 @@ class CustomRoomTagStore extends EventEmitter {
return;
}
const newTagNames = Object.keys(RoomListStore.getRoomLists())
const newTagNames = Object.keys(RoomListStoreTempProxy.getRoomLists())
.filter((tagName) => {
return !tagName.match(STANDARD_TAGS_REGEX);
}).sort();

View file

@ -112,11 +112,19 @@ class RoomListStore extends Store {
constructor() {
super(dis);
this._checkDisabled();
this._init();
this._getManualComparator = this._getManualComparator.bind(this);
this._recentsComparator = this._recentsComparator.bind(this);
}
_checkDisabled() {
this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
if (this.disabled) {
console.warn("DISABLING LEGACY ROOM LIST STORE");
}
}
/**
* Changes the sorting algorithm used by the RoomListStore.
* @param {string} algorithm The new algorithm to use. Should be one of the ALGO_* constants.
@ -133,6 +141,8 @@ class RoomListStore extends Store {
}
_init() {
if (this.disabled) return;
// Initialise state
const defaultLists = {
"m.server_notice": [/* { room: js-sdk room, category: string } */],
@ -160,6 +170,8 @@ class RoomListStore extends Store {
}
_setState(newState) {
if (this.disabled) return;
// If we're changing the lists, transparently change the presentation lists (which
// is given to requesting components). This dramatically simplifies our code elsewhere
// while also ensuring we don't need to update all the calling components to support
@ -176,6 +188,8 @@ class RoomListStore extends Store {
}
__onDispatch(payload) {
if (this.disabled) return;
const logicallyReady = this._matrixClient && this._state.ready;
switch (payload.action) {
case 'setting_updated': {
@ -202,6 +216,9 @@ class RoomListStore extends Store {
break;
}
this._checkDisabled();
if (this.disabled) return;
// Always ensure that we set any state needed for settings here. It is possible that
// setting updates trigger on startup before we are ready to sync, so we want to make
// sure that the right state is in place before we actually react to those changes.

View file

@ -0,0 +1,213 @@
/*
Copyright 2018, 2019 New Vector Ltd
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 {Store} from 'flux/utils';
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import { ActionPayload, defaultDispatcher } from "../../dispatcher-types";
import SettingsStore from "../../settings/SettingsStore";
import { OrderedDefaultTagIDs, DefaultTagID, TagID } from "./models";
import { IAlgorithm, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/IAlgorithm";
import TagOrderStore from "../TagOrderStore";
import { getAlgorithmInstance } from "./algorithms";
interface IState {
tagsEnabled?: boolean;
preferredSort?: SortAlgorithm;
preferredAlgorithm?: ListAlgorithm;
}
class _RoomListStore extends Store<ActionPayload> {
private state: IState = {};
private matrixClient: MatrixClient;
private initialListsGenerated = false;
private enabled = false;
private algorithm: IAlgorithm;
private readonly watchedSettings = [
'RoomList.orderAlphabetically',
'RoomList.orderByImportance',
'feature_custom_tags',
];
constructor() {
super(defaultDispatcher);
this.checkEnabled();
this.reset();
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
}
public get orderedLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getOrderedRooms();
}
// TODO: Remove enabled flag when the old RoomListStore goes away
private checkEnabled() {
this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
if (this.enabled) {
console.log("ENABLING NEW ROOM LIST STORE");
}
}
private reset(): void {
// We don't call setState() because it'll cause changes to emitted which could
// crash the app during logout/signin/etc.
this.state = {};
}
private readAndCacheSettingsFromStore() {
const tagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance");
const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically");
this.setState({
tagsEnabled,
preferredSort: orderAlphabetically ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent,
preferredAlgorithm: orderByImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural,
});
this.setAlgorithmClass();
}
protected __onDispatch(payload: ActionPayload): void {
if (payload.action === 'MatrixActions.sync') {
// Filter out anything that isn't the first PREPARED sync.
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
return;
}
this.checkEnabled();
if (!this.enabled) return;
this.matrixClient = payload.matrixClient;
// Update any settings here, as some may have happened before we were logically ready.
this.readAndCacheSettingsFromStore();
// noinspection JSIgnoredPromiseFromCall
this.regenerateAllLists();
}
// TODO: Remove this once the RoomListStore becomes default
if (!this.enabled) return;
if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
// Reset state without causing updates as the client will have been destroyed
// and downstream code will throw NPE errors.
this.reset();
this.matrixClient = null;
this.initialListsGenerated = false; // we'll want to regenerate them
}
// Everything below here requires a MatrixClient or some sort of logical readiness.
const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return;
if (payload.action === 'setting_updated') {
if (this.watchedSettings.includes(payload.settingName)) {
this.readAndCacheSettingsFromStore();
// noinspection JSIgnoredPromiseFromCall
this.regenerateAllLists(); // regenerate the lists now
}
} else if (payload.action === 'MatrixActions.Room.receipt') {
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
const myUserId = this.matrixClient.getUserId();
for (const eventId of Object.keys(payload.event.getContent())) {
const receiptUsers = Object.keys(payload.event.getContent()[eventId]['m.read'] || {});
if (receiptUsers.includes(myUserId)) {
// TODO: Update room now that it's been read
return;
}
}
} else if (payload.action === 'MatrixActions.Room.tags') {
// TODO: Update room from tags
} else if (payload.action === 'MatrixActions.room.timeline') {
// TODO: Update room from new events
} else if (payload.action === 'MatrixActions.Event.decrypted') {
// TODO: Update room from decrypted event
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
// TODO: Update DMs
} else if (payload.action === 'MatrixActions.Room.myMembership') {
// TODO: Update room from membership change
} else if (payload.action === 'MatrixActions.room') {
// TODO: Update room from creation/join
} else if (payload.action === 'view_room') {
// TODO: Update sticky room
}
}
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;
}
}
private setState(newState: IState) {
if (!this.enabled) return;
this.state = Object.assign(this.state, newState);
this.__emitChange();
}
private setAlgorithmClass() {
this.algorithm = getAlgorithmInstance(this.state.preferredAlgorithm);
}
private async regenerateAllLists() {
console.log("REGEN");
const tags: ITagSortingMap = {};
for (const tagId of OrderedDefaultTagIDs) {
tags[tagId] = this.getSortAlgorithmFor(tagId);
}
if (this.state.tagsEnabled) {
// TODO: Find a more reliable way to get tags
const roomTags = TagOrderStore.getOrderedTags() || [];
console.log("rtags", roomTags);
}
await this.algorithm.populateTags(tags);
await this.algorithm.setKnownRooms(this.matrixClient.getRooms());
this.initialListsGenerated = true;
// TODO: How do we asynchronously update the store's state? or do we just give in and make it all sync?
}
}
export default class RoomListStore {
private static internalInstance: _RoomListStore;
public static get instance(): _RoomListStore {
if (!RoomListStore.internalInstance) {
RoomListStore.internalInstance = new _RoomListStore();
}
return RoomListStore.internalInstance;
}
}

View file

@ -0,0 +1,49 @@
/*
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";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "./RoomListStore2";
import OldRoomListStore from "../RoomListStore";
/**
* Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when
* it is available to everyone.
*
* TODO: Remove this when RoomListStore gets fully replaced.
*/
export class RoomListStoreTempProxy {
public static isUsingNewStore(): boolean {
return SettingsStore.isFeatureEnabled("feature_new_room_list");
}
public static addListener(handler: () => void) {
if (RoomListStoreTempProxy.isUsingNewStore()) {
return RoomListStore.instance.addListener(handler);
} else {
return OldRoomListStore.addListener(handler);
}
}
public static getRoomLists(): {[tagId in TagID]: Room[]} {
if (RoomListStoreTempProxy.isUsingNewStore()) {
return RoomListStore.instance.orderedLists;
} else {
return OldRoomListStore.getRoomLists();
}
}
}

View file

@ -0,0 +1,100 @@
/*
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, ListAlgorithm } from "./IAlgorithm";
import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { DefaultTagID } from "../models";
/**
* A demonstration/temporary algorithm to verify the API surface works.
* TODO: Remove this before shipping
*/
export class ChaoticAlgorithm implements IAlgorithm {
private cached: ITagMap = {};
private sortAlgorithms: ITagSortingMap;
private rooms: Room[] = [];
constructor(private representativeAlgorithm: ListAlgorithm) {
}
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
console.log('setting known rooms - regen in progress');
console.log({alg: this.representativeAlgorithm});
// 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

@ -0,0 +1,95 @@
/*
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 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;
}
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>;
}

View file

@ -0,0 +1,36 @@
/*
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, ListAlgorithm } from "./IAlgorithm";
import { ChaoticAlgorithm } from "./ChaoticAlgorithm";
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => IAlgorithm } = {
[ListAlgorithm.Natural]: () => new ChaoticAlgorithm(ListAlgorithm.Natural),
[ListAlgorithm.Importance]: () => new ChaoticAlgorithm(ListAlgorithm.Importance),
};
/**
* Gets an instance of the defined algorithm
* @param {ListAlgorithm} algorithm The algorithm to get an instance of.
* @returns {IAlgorithm} The algorithm instance.
*/
export function getAlgorithmInstance(algorithm: ListAlgorithm): IAlgorithm {
if (!ALGORITHM_FACTORIES[algorithm]) {
throw new Error(`${algorithm} is not a known algorithm`);
}
return ALGORITHM_FACTORIES[algorithm]();
}

View file

@ -0,0 +1,36 @@
/*
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.
*/
export enum DefaultTagID {
Invite = "im.vector.fake.invite",
Untagged = "im.vector.fake.recent", // legacy: used to just be 'recent rooms' but now it's all untagged rooms
Archived = "im.vector.fake.archived",
LowPriority = "m.lowpriority",
Favourite = "m.favourite",
DM = "im.vector.fake.direct",
ServerNotice = "m.server_notice",
}
export const OrderedDefaultTagIDs = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Archived,
];
export type TagID = string | DefaultTagID;