From 08419d195e365eab57e0ffe0e315a7de9264d041 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 20 Mar 2020 14:38:20 -0600 Subject: [PATCH] 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 --- package.json | 1 + src/actions/RoomListActions.js | 17 +- src/components/structures/LeftPanel.js | 34 ++- src/components/structures/LoggedInView.tsx | 13 +- src/components/views/dialogs/InviteDialog.js | 9 +- src/components/views/rooms/RoomList.js | 15 +- src/components/views/rooms/RoomList2.tsx | 130 +++++++++++ src/dispatcher-types.ts | 28 +++ src/i18n/strings/en_EN.json | 2 + src/settings/Settings.js | 6 + src/stores/CustomRoomTagStore.js | 8 +- src/stores/RoomListStore.js | 17 ++ src/stores/room-list/RoomListStore2.ts | 213 ++++++++++++++++++ .../room-list/RoomListStoreTempProxy.ts | 49 ++++ .../room-list/algorithms/ChaoticAlgorithm.ts | 100 ++++++++ src/stores/room-list/algorithms/IAlgorithm.ts | 95 ++++++++ src/stores/room-list/algorithms/index.ts | 36 +++ src/stores/room-list/models.ts | 36 +++ test/components/views/rooms/RoomList-test.js | 16 +- tsconfig.json | 3 +- yarn.lock | 13 ++ 21 files changed, 794 insertions(+), 47 deletions(-) create mode 100644 src/components/views/rooms/RoomList2.tsx create mode 100644 src/dispatcher-types.ts create mode 100644 src/stores/room-list/RoomListStore2.ts create mode 100644 src/stores/room-list/RoomListStoreTempProxy.ts create mode 100644 src/stores/room-list/algorithms/ChaoticAlgorithm.ts create mode 100644 src/stores/room-list/algorithms/IAlgorithm.ts create mode 100644 src/stores/room-list/algorithms/index.ts create mode 100644 src/stores/room-list/models.ts diff --git a/package.json b/package.json index dda4a5a897..22ff071ba7 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "@babel/register": "^7.7.4", "@peculiar/webcrypto": "^1.0.22", "@types/classnames": "^2.2.10", + "@types/flux": "^3.1.9", "@types/modernizr": "^3.5.3", "@types/qrcode": "^1.3.4", "@types/react": "16.9", diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js index 10a3848dda..072b1d9a86 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -15,11 +15,12 @@ limitations under the License. */ import { asyncAction } from './actionCreators'; -import RoomListStore, {TAG_DM} from '../stores/RoomListStore'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; import * as sdk from '../index'; +import {RoomListStoreTempProxy} from "../stores/room-list/RoomListStoreTempProxy"; +import {DefaultTagID} from "../stores/room-list/models"; const RoomListActions = {}; @@ -44,7 +45,7 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, // Is the tag ordered manually? if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { - const lists = RoomListStore.getRoomLists(); + const lists = RoomListStoreTempProxy.getRoomLists(); const newList = [...lists[newTag]]; newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order); @@ -73,11 +74,11 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, const roomId = room.roomId; // Evil hack to get DMs behaving - if ((oldTag === undefined && newTag === TAG_DM) || - (oldTag === TAG_DM && newTag === undefined) + if ((oldTag === undefined && newTag === DefaultTagID.DM) || + (oldTag === DefaultTagID.DM && newTag === undefined) ) { return Rooms.guessAndSetDMRoom( - room, newTag === TAG_DM, + room, newTag === DefaultTagID.DM, ).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set direct chat tag " + err); @@ -91,10 +92,10 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, const hasChangedSubLists = oldTag !== newTag; // More evilness: We will still be dealing with moving to favourites/low prio, - // but we avoid ever doing a request with TAG_DM. + // but we avoid ever doing a request with DefaultTagID.DM. // // if we moved lists, remove the old tag - if (oldTag && oldTag !== TAG_DM && + if (oldTag && oldTag !== DefaultTagID.DM && hasChangedSubLists ) { const promiseToDelete = matrixClient.deleteRoomTag( @@ -112,7 +113,7 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, } // if we moved lists or the ordering changed, add the new tag - if (newTag && newTag !== TAG_DM && + if (newTag && newTag !== DefaultTagID.DM && (hasChangedSubLists || metaData) ) { // metaData is the body of the PUT to set the tag, so it must diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index a9cd12199b..1993e2f419 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -26,6 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler'; import SettingsStore from '../../settings/SettingsStore'; import {_t} from "../../languageHandler"; import Analytics from "../../Analytics"; +import RoomList2 from "../views/rooms/RoomList2"; const LeftPanel = createReactClass({ @@ -273,6 +274,29 @@ const LeftPanel = createReactClass({ breadcrumbs = (); } + let roomList = null; + if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { + roomList = ; + } else { + roomList = ; + } + return (
{ tagPanelContainer } @@ -284,15 +308,7 @@ const LeftPanel = createReactClass({ { exploreButton } { searchBox }
- + {roomList} ); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 9de2aac8e9..0950e52bba 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -31,7 +31,6 @@ import dis from '../../dispatcher'; import sessionStore from '../../stores/SessionStore'; import {MatrixClientPeg, MatrixClientCreds} from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; -import RoomListStore from "../../stores/RoomListStore"; import TagOrderActions from '../../actions/TagOrderActions'; import RoomListActions from '../../actions/RoomListActions'; @@ -42,6 +41,8 @@ import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts"; import HomePage from "./HomePage"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PlatformPeg from "../../PlatformPeg"; +import { RoomListStoreTempProxy } from "../../stores/room-list/RoomListStoreTempProxy"; +import { DefaultTagID } from "../../stores/room-list/models"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. // NB. this is just for server notices rather than pinned messages in general. @@ -297,18 +298,18 @@ class LoggedInView extends React.PureComponent { }; onRoomStateEvents = (ev, state) => { - const roomLists = RoomListStore.getRoomLists(); - if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) { + const roomLists = RoomListStoreTempProxy.getRoomLists(); + if (roomLists[DefaultTagID.ServerNotice] && roomLists[DefaultTagID.ServerNotice].some(r => r.roomId === ev.getRoomId())) { this._updateServerNoticeEvents(); } }; _updateServerNoticeEvents = async () => { - const roomLists = RoomListStore.getRoomLists(); - if (!roomLists['m.server_notice']) return []; + const roomLists = RoomListStoreTempProxy.getRoomLists(); + if (!roomLists[DefaultTagID.ServerNotice]) return []; const pinnedEvents = []; - for (const room of roomLists['m.server_notice']) { + for (const room of roomLists[DefaultTagID.ServerNotice]) { const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", ""); if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue; diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 7cbbf8ba64..e719c45f49 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -34,8 +34,9 @@ import {humanizeTime} from "../../../utils/humanize"; import createRoom, {canEncryptToAllUsers} from "../../../createRoom"; import {inviteMultipleToRoom} from "../../../RoomInvite"; import SettingsStore from '../../../settings/SettingsStore'; -import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore"; import {Key} from "../../../Keyboard"; +import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy"; +import {DefaultTagID} from "../../../stores/room-list/models"; export const KIND_DM = "dm"; export const KIND_INVITE = "invite"; @@ -343,10 +344,10 @@ export default class InviteDialog extends React.PureComponent { _buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room - // Also pull in all the rooms tagged as TAG_DM so we don't miss anything. Sometimes the + // Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the // room list doesn't tag the room for the DMRoomMap, but does for the room list. - const taggedRooms = RoomListStore.getRoomLists(); - const dmTaggedRooms = taggedRooms[TAG_DM]; + const taggedRooms = RoomListStoreTempProxy.getRoomLists(); + const dmTaggedRooms = taggedRooms[DefaultTagID.DM]; const myUserId = MatrixClientPeg.get().getUserId(); for (const dmRoom of dmTaggedRooms) { const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId); diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 289a89a206..dc9c9238cd 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -29,7 +29,6 @@ import rate_limited_func from "../../../ratelimitedfunc"; import * as Rooms from '../../../Rooms'; import DMRoomMap from '../../../utils/DMRoomMap'; import TagOrderStore from '../../../stores/TagOrderStore'; -import RoomListStore, {TAG_DM} from '../../../stores/RoomListStore'; import CustomRoomTagStore from '../../../stores/CustomRoomTagStore'; import GroupStore from '../../../stores/GroupStore'; import RoomSubList from '../../structures/RoomSubList'; @@ -41,6 +40,8 @@ import * as Receipt from "../../../utils/Receipt"; import {Resizer} from '../../../resizer'; import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2'; import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex"; +import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy"; +import {DefaultTagID} from "../../../stores/room-list/models"; import * as Unread from "../../../Unread"; import RoomViewStore from "../../../stores/RoomViewStore"; @@ -161,7 +162,7 @@ export default createReactClass({ this.updateVisibleRooms(); }); - this._roomListStoreToken = RoomListStore.addListener(() => { + this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => { this._delayedRefreshRoomList(); }); @@ -521,7 +522,7 @@ export default createReactClass({ }, getTagNameForRoomId: function(roomId) { - const lists = RoomListStore.getRoomLists(); + const lists = RoomListStoreTempProxy.getRoomLists(); for (const tagName of Object.keys(lists)) { for (const room of lists[tagName]) { // Should be impossible, but guard anyways. @@ -541,7 +542,7 @@ export default createReactClass({ }, getRoomLists: function() { - const lists = RoomListStore.getRoomLists(); + const lists = RoomListStoreTempProxy.getRoomLists(); const filteredLists = {}; @@ -773,10 +774,10 @@ export default createReactClass({ incomingCall: incomingCallIfTaggedAs('m.favourite'), }, { - list: this.state.lists[TAG_DM], + list: this.state.lists[DefaultTagID.DM], label: _t('Direct Messages'), - tagName: TAG_DM, - incomingCall: incomingCallIfTaggedAs(TAG_DM), + tagName: DefaultTagID.DM, + incomingCall: incomingCallIfTaggedAs(DefaultTagID.DM), onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});}, addRoomLabel: _t("Start chat"), }, diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx new file mode 100644 index 0000000000..1790fa8cf6 --- /dev/null +++ b/src/components/views/rooms/RoomList2.tsx @@ -0,0 +1,130 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017, 2018 Vector Creations 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 * as React from "react"; +import { _t } from "../../../languageHandler"; +import { Layout } from '../../../resizer/distributors/roomsublist2'; +import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; +import { ResizeNotifier } from "../../../utils/ResizeNotifier"; +import RoomListStore from "../../../stores/room-list/RoomListStore2"; + +interface IProps { + onKeyDown: (ev: React.KeyboardEvent) => void; + onFocus: (ev: React.FocusEvent) => void; + onBlur: (ev: React.FocusEvent) => void; + resizeNotifier: ResizeNotifier; + collapsed: boolean; + searchFilter: string; +} + +interface IState { +} + +// TODO: Actually write stub +export class RoomSublist2 extends React.Component { + public setHeight(size: number) { + } +} + +export default class RoomList2 extends React.Component { + private sublistRefs: { [tagId: string]: React.RefObject } = {}; + private sublistSizes: { [tagId: string]: number } = {}; + private sublistCollapseStates: { [tagId: string]: boolean } = {}; + private unfilteredLayout: Layout; + private filteredLayout: Layout; + + constructor(props: IProps) { + super(props); + + this.loadSublistSizes(); + this.prepareLayouts(); + } + + public componentDidMount(): void { + RoomListStore.instance.addListener(() => { + console.log(RoomListStore.instance.orderedLists); + }); + } + + private loadSublistSizes() { + const sizesJson = window.localStorage.getItem("mx_roomlist_sizes"); + if (sizesJson) this.sublistSizes = JSON.parse(sizesJson); + + const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed"); + if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson); + } + + private saveSublistSizes() { + window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes)); + window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates)); + } + + private prepareLayouts() { + this.unfilteredLayout = new Layout((tagId: string, height: number) => { + const sublist = this.sublistRefs[tagId]; + if (sublist) sublist.current.setHeight(height); + + // TODO: Check overflow + + // Don't store a height for collapsed sublists + if (!this.sublistCollapseStates[tagId]) { + this.sublistSizes[tagId] = height; + this.saveSublistSizes(); + } + }, this.sublistSizes, this.sublistCollapseStates, { + allowWhitespace: false, + handleHeight: 1, + }); + + this.filteredLayout = new Layout((tagId: string, height: number) => { + const sublist = this.sublistRefs[tagId]; + if (sublist) sublist.current.setHeight(height); + }, null, null, { + allowWhitespace: false, + handleHeight: 0, + }); + } + + private collectSublistRef(tagId: string, ref: React.RefObject) { + if (!ref) { + delete this.sublistRefs[tagId]; + } else { + this.sublistRefs[tagId] = ref; + } + } + + public render() { + return ( + + {({onKeyDownHandler}) => ( +
{_t("TODO")}
+ )} +
+ ); + } +} diff --git a/src/dispatcher-types.ts b/src/dispatcher-types.ts new file mode 100644 index 0000000000..16fac0c849 --- /dev/null +++ b/src/dispatcher-types.ts @@ -0,0 +1,28 @@ +/* +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 * as flux from "flux"; +import dis from "./dispatcher"; + +// TODO: Merge this with the dispatcher and centralize types + +export interface ActionPayload { + [property: string]: any; // effectively "extends Object" + action: string; +} + +// For ease of reference in TypeScript classes +export const defaultDispatcher: flux.Dispatcher = dis; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f16a0d7755..fd474f378c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -406,6 +406,7 @@ "Render simple counters in room header": "Render simple counters in room header", "Multiple integration managers": "Multiple integration managers", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", + "Use the improved room list component (refresh to apply changes)": "Use the improved room list component (refresh to apply changes)", "Support adding custom themes": "Support adding custom themes", "Enable cross-signing to verify per-user instead of per-session": "Enable cross-signing to verify per-user instead of per-session", "Show info about bridges in room settings": "Show info about bridges in room settings", @@ -1116,6 +1117,7 @@ "Low priority": "Low priority", "Historical": "Historical", "System Alerts": "System Alerts", + "TODO": "TODO", "This room": "This room", "Joining room …": "Joining room …", "Loading …": "Loading …", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 5c6d843349..554cf6b968 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -131,6 +131,12 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_new_room_list": { + isFeature: true, + displayName: _td("Use the improved room list component (refresh to apply changes)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_custom_themes": { isFeature: true, displayName: _td("Support adding custom themes"), diff --git a/src/stores/CustomRoomTagStore.js b/src/stores/CustomRoomTagStore.js index 909282c085..bf8e970535 100644 --- a/src/stores/CustomRoomTagStore.js +++ b/src/stores/CustomRoomTagStore.js @@ -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(); diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index e217f7ea38..ccccbcc313 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -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. diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts new file mode 100644 index 0000000000..70d6d4a598 --- /dev/null +++ b/src/stores/room-list/RoomListStore2.ts @@ -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 { + 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; + } +} diff --git a/src/stores/room-list/RoomListStoreTempProxy.ts b/src/stores/room-list/RoomListStoreTempProxy.ts new file mode 100644 index 0000000000..7b12602541 --- /dev/null +++ b/src/stores/room-list/RoomListStoreTempProxy.ts @@ -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(); + } + } +} diff --git a/src/stores/room-list/algorithms/ChaoticAlgorithm.ts b/src/stores/room-list/algorithms/ChaoticAlgorithm.ts new file mode 100644 index 0000000000..4fe5125a15 --- /dev/null +++ b/src/stores/room-list/algorithms/ChaoticAlgorithm.ts @@ -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 { + 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 { + return undefined; + } + + setKnownRooms(rooms: Room[]): Promise { + 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; + } +} diff --git a/src/stores/room-list/algorithms/IAlgorithm.ts b/src/stores/room-list/algorithms/IAlgorithm.ts new file mode 100644 index 0000000000..fbe2f7a27d --- /dev/null +++ b/src/stores/room-list/algorithms/IAlgorithm.ts @@ -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; + + /** + * 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; + + /** + * 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} A promise which resolve to true or false + * depending on whether or not getOrderedRooms() should be called after + * processing. + */ + handleRoomUpdate(room: Room): Promise; +} diff --git a/src/stores/room-list/algorithms/index.ts b/src/stores/room-list/algorithms/index.ts new file mode 100644 index 0000000000..cb67d42187 --- /dev/null +++ b/src/stores/room-list/algorithms/index.ts @@ -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](); +} diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts new file mode 100644 index 0000000000..d1c915e035 --- /dev/null +++ b/src/stores/room-list/models.ts @@ -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; diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index 8dc4647920..7bcd2a8ae3 100644 --- a/test/components/views/rooms/RoomList-test.js +++ b/test/components/views/rooms/RoomList-test.js @@ -14,7 +14,7 @@ import DMRoomMap from '../../../../src/utils/DMRoomMap.js'; import GroupStore from '../../../../src/stores/GroupStore.js'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; -import {TAG_DM} from "../../../../src/stores/RoomListStore"; +import {DefaultTagID} from "../../../../src/stores/room-list/models"; function generateRoomId() { return '!' + Math.random().toString().slice(2, 10) + ':domain'; @@ -153,7 +153,7 @@ describe('RoomList', () => { // Set up the room that will be moved such that it has the correct state for a room in // the section for oldTag if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}}; - if (oldTag === TAG_DM) { + if (oldTag === DefaultTagID.DM) { // Mock inverse m.direct DMRoomMap.shared().roomToUser = { [movingRoom.roomId]: '@someotheruser:domain', @@ -180,7 +180,7 @@ describe('RoomList', () => { // TODO: Re-enable dragging tests when we support dragging again. describe.skip('does correct optimistic update when dragging from', () => { it('rooms to people', () => { - expectCorrectMove(undefined, TAG_DM); + expectCorrectMove(undefined, DefaultTagID.DM); }); it('rooms to favourites', () => { @@ -195,15 +195,15 @@ describe('RoomList', () => { // Whe running the app live, it updates when some other event occurs (likely the // m.direct arriving) that these tests do not fire. xit('people to rooms', () => { - expectCorrectMove(TAG_DM, undefined); + expectCorrectMove(DefaultTagID.DM, undefined); }); it('people to favourites', () => { - expectCorrectMove(TAG_DM, 'm.favourite'); + expectCorrectMove(DefaultTagID.DM, 'm.favourite'); }); it('people to lowpriority', () => { - expectCorrectMove(TAG_DM, 'm.lowpriority'); + expectCorrectMove(DefaultTagID.DM, 'm.lowpriority'); }); it('low priority to rooms', () => { @@ -211,7 +211,7 @@ describe('RoomList', () => { }); it('low priority to people', () => { - expectCorrectMove('m.lowpriority', TAG_DM); + expectCorrectMove('m.lowpriority', DefaultTagID.DM); }); it('low priority to low priority', () => { @@ -223,7 +223,7 @@ describe('RoomList', () => { }); it('favourites to people', () => { - expectCorrectMove('m.favourite', TAG_DM); + expectCorrectMove('m.favourite', DefaultTagID.DM); }); it('favourites to low priority', () => { diff --git a/tsconfig.json b/tsconfig.json index b87f640734..8a01ca335e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "jsx": "react", "types": [ "node", - "react" + "react", + "flux" ] }, "include": [ diff --git a/yarn.lock b/yarn.lock index b0d3816dc4..6375c745fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1218,6 +1218,19 @@ resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== +"@types/fbemitter@*": + version "2.0.32" + resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c" + integrity sha1-jtIE2g9U6cjq7DGx7skeJRMtCCw= + +"@types/flux@^3.1.9": + version "3.1.9" + resolved "https://registry.yarnpkg.com/@types/flux/-/flux-3.1.9.tgz#ddfc9641ee2e2e6cb6cd730c6a48ef82e2076711" + integrity sha512-bSbDf4tTuN9wn3LTGPnH9wnSSLtR5rV7UPWFpM00NJ1pSTBwCzeZG07XsZ9lBkxwngrqjDtM97PLt5IuIdCQUA== + dependencies: + "@types/fbemitter" "*" + "@types/react" "*" + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"