diff --git a/package.json b/package.json index 31a81f6e77b..543e24a05ff 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "@babel/runtime": "^7.8.3", + "await-lock": "^2.0.1", "blueimp-canvas-to-blob": "^3.5.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index eb9831ec471..e15e1b0c65e 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -16,7 +16,7 @@ limitations under the License. */ import { asyncAction } from './actionCreators'; -import RoomListStore, { TAG_DM } from '../stores/RoomListStore'; +import { TAG_DM } from '../stores/RoomListStore'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; @@ -24,6 +24,7 @@ import * as sdk from '../index'; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { AsyncActionPayload } from "../dispatcher/payloads"; +import { RoomListStoreTempProxy } from "../stores/room-list/RoomListStoreTempProxy"; export default class RoomListActions { /** @@ -51,7 +52,7 @@ export default class RoomListActions { // 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); diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index adba858fa32..a1b4f49c561 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 e2aa523b8c0..148d10fe8d9 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -31,7 +31,6 @@ import dis from '../../dispatcher/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 1e624f7545e..4274c938cc1 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -34,9 +34,10 @@ 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 {Action} from "../../../dispatcher/actions"; +import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy"; +import {DefaultTagID} from "../../../stores/room-list/models"; export const KIND_DM = "dm"; export const KIND_INVITE = "invite"; @@ -344,10 +345,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 1c59a5d8d03..882ca6e1e06 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 00000000000..d0c147c9530 --- /dev/null +++ b/src/components/views/rooms/RoomList2.tsx @@ -0,0 +1,246 @@ +/* +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, _td } from "../../../languageHandler"; +import { Layout } from '../../../resizer/distributors/roomsublist2'; +import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; +import { ResizeNotifier } from "../../../utils/ResizeNotifier"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2"; +import { ITagMap } from "../../../stores/room-list/algorithms/models"; +import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { Dispatcher } from "flux"; +import dis from "../../../dispatcher/dispatcher"; +import RoomSublist2 from "./RoomSublist2"; +import { ActionPayload } from "../../../dispatcher/payloads"; + +/******************************************************************* + * CAUTION * + ******************************************************************* + * This is a work in progress implementation and isn't complete or * + * even useful as a component. Please avoid using it until this * + * warning disappears. * + *******************************************************************/ + +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 { + sublists: ITagMap; +} + +const TAG_ORDER: TagID[] = [ + // -- Community Invites Placeholder -- + + DefaultTagID.Invite, + DefaultTagID.Favourite, + DefaultTagID.DM, + DefaultTagID.Untagged, + + // -- Custom Tags Placeholder -- + + DefaultTagID.LowPriority, + DefaultTagID.ServerNotice, + DefaultTagID.Archived, +]; +const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite; +const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority; +const ALWAYS_VISIBLE_TAGS: TagID[] = [ + DefaultTagID.DM, + DefaultTagID.Untagged, +]; + +interface ITagAesthetics { + sectionLabel: string; + addRoomLabel?: string; + onAddRoom?: (dispatcher: Dispatcher) => void; + isInvite: boolean; + defaultHidden: boolean; +} + +const TAG_AESTHETICS: { + // @ts-ignore - TS wants this to be a string but we know better + [tagId: TagID]: ITagAesthetics; +} = { + [DefaultTagID.Invite]: { + sectionLabel: _td("Invites"), + isInvite: true, + defaultHidden: false, + }, + [DefaultTagID.Favourite]: { + sectionLabel: _td("Favourites"), + isInvite: false, + defaultHidden: false, + }, + [DefaultTagID.DM]: { + sectionLabel: _td("Direct Messages"), + isInvite: false, + defaultHidden: false, + addRoomLabel: _td("Start chat"), + onAddRoom: (dispatcher: Dispatcher) => dispatcher.dispatch({action: 'view_create_chat'}), + }, + [DefaultTagID.Untagged]: { + sectionLabel: _td("Rooms"), + isInvite: false, + defaultHidden: false, + addRoomLabel: _td("Create room"), + onAddRoom: (dispatcher: Dispatcher) => dispatcher.dispatch({action: 'view_create_room'}), + }, + [DefaultTagID.LowPriority]: { + sectionLabel: _td("Low priority"), + isInvite: false, + defaultHidden: false, + }, + [DefaultTagID.ServerNotice]: { + sectionLabel: _td("System Alerts"), + isInvite: false, + defaultHidden: false, + }, + [DefaultTagID.Archived]: { + sectionLabel: _td("Historical"), + isInvite: false, + defaultHidden: true, + }, +}; + +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.state = {sublists: {}}; + this.loadSublistSizes(); + this.prepareLayouts(); + } + + public componentDidMount(): void { + RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => { + console.log("new lists", store.orderedLists); + this.setState({sublists: store.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() { + // TODO: Change layout engine for FTUE support + this.unfilteredLayout = new Layout((tagId: string, height: number) => { + const sublist = this.sublistRefs[tagId]; + if (sublist) sublist.current.setHeight(height); + + // TODO: Check overflow (see old impl) + + // 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 renderSublists(): React.ReactElement[] { + const components: React.ReactElement[] = []; + + for (const orderedTagId of TAG_ORDER) { + if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) { + // Populate community invites if we have the chance + // TODO + } + if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) { + // Populate custom tags if needed + // TODO + } + + const orderedRooms = this.state.sublists[orderedTagId] || []; + if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) { + continue; // skip tag - not needed + } + + const aesthetics: ITagAesthetics = TAG_AESTHETICS[orderedTagId]; + if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); + + const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; + components.push(); + } + + return components; + } + + public render() { + const sublists = this.renderSublists(); + return ( + + {({onKeyDownHandler}) => ( +
{sublists}
+ )} +
+ ); + } +} diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx new file mode 100644 index 00000000000..e2f489b9590 --- /dev/null +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -0,0 +1,226 @@ +/* +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 { createRef } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import classNames from 'classnames'; +import IndicatorScrollbar from "../../structures/IndicatorScrollbar"; +import * as RoomNotifs from '../../../RoomNotifs'; +import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; +import { _t } from "../../../languageHandler"; +import AccessibleButton from "../../views/elements/AccessibleButton"; +import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton"; +import * as FormattingUtils from '../../../utils/FormattingUtils'; +import RoomTile2 from "./RoomTile2"; + +/******************************************************************* + * CAUTION * + ******************************************************************* + * This is a work in progress implementation and isn't complete or * + * even useful as a component. Please avoid using it until this * + * warning disappears. * + *******************************************************************/ + +interface IProps { + forRooms: boolean; + rooms?: Room[]; + startAsHidden: boolean; + label: string; + onAddRoom?: () => void; + addRoomLabel: string; + isInvite: boolean; + + // TODO: Collapsed state + // TODO: Height + // TODO: Group invites + // TODO: Calls + // TODO: forceExpand? + // TODO: Header clicking + // TODO: Spinner support for historical +} + +interface IState { +} + +export default class RoomSublist2 extends React.Component { + private headerButton = createRef(); + + public setHeight(size: number) { + // TODO: Do a thing (maybe - height changes are different in FTUE) + } + + private hasTiles(): boolean { + return this.numTiles > 0; + } + + private get numTiles(): number { + // TODO: Account for group invites + return (this.props.rooms || []).length; + } + + private onAddRoom = (e) => { + e.stopPropagation(); + if (this.props.onAddRoom) this.props.onAddRoom(); + }; + + private renderTiles(): React.ReactElement[] { + const tiles: React.ReactElement[] = []; + + if (this.props.rooms) { + for (const room of this.props.rooms) { + tiles.push(); + } + } + + return tiles; + } + + private renderHeader(): React.ReactElement { + const notifications = !this.props.isInvite + ? RoomNotifs.aggregateNotificationCount(this.props.rooms) + : {count: 0, highlight: true}; + const notifCount = notifications.count; + const notifHighlight = notifications.highlight; + + // TODO: Title on collapsed + // TODO: Incoming call box + + let chevron = null; + if (this.hasTiles()) { + const chevronClasses = classNames({ + 'mx_RoomSubList_chevron': true, + 'mx_RoomSubList_chevronRight': false, // isCollapsed + 'mx_RoomSubList_chevronDown': true, // !isCollapsed + }); + chevron = (
); + } + + return ( + + {({onFocus, isActive, ref}) => { + // TODO: Use onFocus + const tabIndex = isActive ? 0 : -1; + + // TODO: Collapsed state + let badge; + if (true) { // !isCollapsed + const badgeClasses = classNames({ + 'mx_RoomSubList_badge': true, + 'mx_RoomSubList_badgeHighlight': notifHighlight, + }); + // Wrap the contents in a div and apply styles to the child div so that the browser default outline works + if (notifCount > 0) { + badge = ( + +
+ {FormattingUtils.formatCount(notifCount)} +
+
+ ); + } else if (this.props.isInvite && this.hasTiles()) { + // Render the `!` badge for invites + badge = ( + +
+ {FormattingUtils.formatCount(this.numTiles)} +
+
+ ); + } + } + + let addRoomButton = null; + if (!!this.props.onAddRoom) { + addRoomButton = ( + + ); + } + + // TODO: a11y (see old component) + return ( +
+ + {chevron} + {this.props.label} + + {badge} + {addRoomButton} +
+ ); + }} +
+ ); + } + + public render(): React.ReactElement { + // TODO: Proper rendering + // TODO: Error boundary + + const tiles = this.renderTiles(); + + const classes = classNames({ + // TODO: Proper collapse support + 'mx_RoomSubList': true, + 'mx_RoomSubList_hidden': false, // len && isCollapsed + 'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed + }); + + let content = null; + if (tiles.length > 0) { + // TODO: Lazy list rendering + // TODO: Whatever scrolling magic needs to happen here + content = ( + + {tiles} + + ) + } + + // TODO: onKeyDown support + return ( +
+ {this.renderHeader()} + {content} +
+ ); + } +} diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx new file mode 100644 index 00000000000..42b65cba87b --- /dev/null +++ b/src/components/views/rooms/RoomTile2.tsx @@ -0,0 +1,219 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019, 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 React, { createRef } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import classNames from "classnames"; +import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; +import AccessibleButton from "../../views/elements/AccessibleButton"; +import RoomAvatar from "../../views/avatars/RoomAvatar"; +import Tooltip from "../../views/elements/Tooltip"; +import dis from '../../../dispatcher/dispatcher'; +import { Key } from "../../../Keyboard"; +import * as RoomNotifs from '../../../RoomNotifs'; +import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership"; +import * as Unread from '../../../Unread'; +import * as FormattingUtils from "../../../utils/FormattingUtils"; + +/******************************************************************* + * CAUTION * + ******************************************************************* + * This is a work in progress implementation and isn't complete or * + * even useful as a component. Please avoid using it until this * + * warning disappears. * + *******************************************************************/ + +interface IProps { + room: Room; + + // TODO: Allow falsifying counts (for invites and stuff) + // TODO: Transparency? Was this ever used? + // TODO: Incoming call boxes? +} + +interface IBadgeState { + showBadge: boolean; // if numUnread > 0 && !showBadge -> bold room + numUnread: number; // used only if showBadge or showBadgeHighlight is true + hasUnread: number; // used to make the room bold + showBadgeHighlight: boolean; // make the badge red + isInvite: boolean; // show a `!` instead of a number +} + +interface IState extends IBadgeState { + hover: boolean; +} + +export default class RoomTile2 extends React.Component { + private roomTile = createRef(); + + // TODO: Custom status + // TODO: Lock icon + // TODO: Presence indicator + // TODO: e2e shields + // TODO: Handle changes to room aesthetics (name, join rules, etc) + // TODO: scrollIntoView? + // TODO: hover, badge, etc + // TODO: isSelected for hover effects + // TODO: Context menu + // TODO: a11y + + constructor(props: IProps) { + super(props); + + this.state = { + hover: false, + + ...this.getBadgeState(), + }; + } + + public componentWillUnmount() { + // TODO: Listen for changes to the badge count and update as needed + } + + private updateBadgeCount() { + this.setState({...this.getBadgeState()}); + } + + private getBadgeState(): IBadgeState { + // TODO: Make this code path faster + const highlightCount = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight'); + const numUnread = RoomNotifs.getUnreadNotificationCount(this.props.room); + const showBadge = Unread.doesRoomHaveUnreadMessages(this.props.room); + const myMembership = getEffectiveMembership(this.props.room.getMyMembership()); + const isInvite = myMembership === EffectiveMembership.Invite; + const notifState = RoomNotifs.getRoomNotifsState(this.props.room.roomId); + const shouldShowNotifBadge = RoomNotifs.shouldShowNotifBadge(notifState); + const shouldShowHighlightBadge = RoomNotifs.shouldShowMentionBadge(notifState); + + return { + showBadge: (showBadge && shouldShowNotifBadge) || isInvite, + numUnread, + hasUnread: showBadge, + showBadgeHighlight: (highlightCount > 0 && shouldShowHighlightBadge) || isInvite, + isInvite, + }; + } + + private onTileMouseEnter = () => { + this.setState({hover: true}); + }; + + private onTileMouseLeave = () => { + this.setState({hover: false}); + }; + + private onTileClick = (ev: React.KeyboardEvent) => { + dis.dispatch({ + action: 'view_room', + // TODO: Support show_room_tile in new room list + show_room_tile: true, // make sure the room is visible in the list + room_id: this.props.room.roomId, + clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), + }); + }; + + public render(): React.ReactElement { + // TODO: Collapsed state + // TODO: Invites + // TODO: a11y proper + // TODO: Render more than bare minimum + + const classes = classNames({ + 'mx_RoomTile': true, + // 'mx_RoomTile_selected': this.state.selected, + 'mx_RoomTile_unread': this.state.numUnread > 0 || this.state.hasUnread, + 'mx_RoomTile_unreadNotify': this.state.showBadge, + 'mx_RoomTile_highlight': this.state.showBadgeHighlight, + 'mx_RoomTile_invited': this.state.isInvite, + // 'mx_RoomTile_menuDisplayed': isMenuDisplayed, + 'mx_RoomTile_noBadges': !this.state.showBadge, + // 'mx_RoomTile_transparent': this.props.transparent, + // 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed, + }); + + const avatarClasses = classNames({ + 'mx_RoomTile_avatar': true, + }); + + + let badge; + if (this.state.showBadge) { + const badgeClasses = classNames({ + 'mx_RoomTile_badge': true, + 'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed + }); + const formattedCount = this.state.isInvite ? `!` : FormattingUtils.formatCount(this.state.numUnread); + badge =
{formattedCount}
; + } + + // TODO: the original RoomTile uses state for the room name. Do we need to? + let name = this.props.room.name; + if (typeof name !== 'string') name = ''; + name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon + + const nameClasses = classNames({ + 'mx_RoomTile_name': true, + 'mx_RoomTile_invite': this.state.isInvite, + 'mx_RoomTile_badgeShown': this.state.showBadge, + }); + + // TODO: Support collapsed state properly + let tooltip = null; + if (false) { // isCollapsed + if (this.state.hover) { + tooltip = + } + } + + return ( + + + {({onFocus, isActive, ref}) => + +
+
+ +
+
+
+
+
+ {name} +
+
+ {badge} +
+ {tooltip} +
+ } +
+
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4cb12e7df5e..696895e5c78 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 (in development - refresh to apply changes)": "Use the improved room list (in development - 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", + "Create room": "Create room", "This room": "This room", "Joining room …": "Joining room …", "Loading …": "Loading …", @@ -1159,6 +1161,9 @@ "Securely back up your keys to avoid losing them. Learn more.": "Securely back up your keys to avoid losing them. Learn more.", "Not now": "Not now", "Don't ask me again": "Don't ask me again", + "Jump to first unread room.": "Jump to first unread room.", + "Jump to first invite.": "Jump to first invite.", + "Add room": "Add room", "Options": "Options", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|one": "1 unread mention.", @@ -2050,9 +2055,6 @@ "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Active call": "Active call", "There's no one else here! Would you like to invite others or stop warning about the empty room?": "There's no one else here! Would you like to invite others or stop warning about the empty room?", - "Jump to first unread room.": "Jump to first unread room.", - "Jump to first invite.": "Jump to first invite.", - "Add room": "Add room", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "Search failed": "Search failed", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 5c6d843349e..cd9ec430bfc 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 (in development - 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/AsyncStore.ts b/src/stores/AsyncStore.ts new file mode 100644 index 00000000000..35190500780 --- /dev/null +++ b/src/stores/AsyncStore.ts @@ -0,0 +1,107 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventEmitter } from 'events'; +import AwaitLock from 'await-lock'; +import { Dispatcher } from "flux"; +import { ActionPayload } from "../dispatcher/payloads"; + +/** + * The event/channel to listen for in an AsyncStore. + */ +export const UPDATE_EVENT = "update"; + +/** + * Represents a minimal store which works similar to Flux stores. Instead + * of everything needing to happen in a dispatch cycle, everything can + * happen async to that cycle. + * + * The store operates by using Object.assign() to mutate state - it sends the + * state objects (current and new) through the function onto a new empty + * object. Because of this, it is recommended to break out your state to be as + * safe as possible. The state mutations are also locked, preventing concurrent + * writes. + * + * All updates to the store happen on the UPDATE_EVENT event channel with the + * one argument being the instance of the store. + * + * To update the state, use updateState() and preferably await the result to + * help prevent lock conflicts. + */ +export abstract class AsyncStore extends EventEmitter { + private storeState: T = {}; + private lock = new AwaitLock(); + private readonly dispatcherRef: string; + + /** + * Creates a new AsyncStore using the given dispatcher. + * @param {Dispatcher} dispatcher The dispatcher to rely upon. + */ + protected constructor(private dispatcher: Dispatcher) { + super(); + + this.dispatcherRef = dispatcher.register(this.onDispatch.bind(this)); + } + + /** + * The current state of the store. Cannot be mutated. + */ + protected get state(): T { + return Object.freeze(this.storeState); + } + + /** + * Stops the store's listening functions, such as the listener to the dispatcher. + */ + protected stop() { + if (this.dispatcherRef) this.dispatcher.unregister(this.dispatcherRef); + } + + /** + * Updates the state of the store. + * @param {T|*} newState The state to update in the store using Object.assign() + */ + protected async updateState(newState: T | Object) { + await this.lock.acquireAsync(); + try { + this.storeState = Object.assign({}, this.storeState, newState); + this.emit(UPDATE_EVENT, this); + } finally { + await this.lock.release(); + } + } + + /** + * Resets the store's to the provided state or an empty object. + * @param {T|*} newState The new state of the store. + * @param {boolean} quiet If true, the function will not raise an UPDATE_EVENT. + */ + protected async reset(newState: T | Object = null, quiet = false) { + await this.lock.acquireAsync(); + try { + this.storeState = (newState || {}); + if (!quiet) this.emit(UPDATE_EVENT, this); + } finally { + await this.lock.release(); + } + } + + /** + * Called when the dispatcher broadcasts a dispatch event. + * @param {ActionPayload} payload The event being dispatched. + */ + protected abstract onDispatch(payload: ActionPayload); +} diff --git a/src/stores/CustomRoomTagStore.js b/src/stores/CustomRoomTagStore.js index c67868e2c6f..48c80294b4d 100644 --- a/src/stores/CustomRoomTagStore.js +++ b/src/stores/CustomRoomTagStore.js @@ -15,10 +15,10 @@ limitations under the License. */ import dis from '../dispatcher/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 d7b6759195d..c19b2f8bc24 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -92,11 +92,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("👋 legacy room list store has been disabled"); + } + } + /** * Changes the sorting algorithm used by the RoomListStore. * @param {string} algorithm The new algorithm to use. Should be one of the ALGO_* constants. @@ -113,6 +121,8 @@ class RoomListStore extends Store { } _init() { + if (this.disabled) return; + // Initialise state const defaultLists = { "m.server_notice": [/* { room: js-sdk room, category: string } */], @@ -140,6 +150,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 @@ -156,6 +168,8 @@ class RoomListStore extends Store { } __onDispatch(payload) { + if (this.disabled) return; + const logicallyReady = this._matrixClient && this._state.ready; switch (payload.action) { case 'setting_updated': { @@ -182,6 +196,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/README.md b/src/stores/room-list/README.md new file mode 100644 index 00000000000..82a6e841db0 --- /dev/null +++ b/src/stores/room-list/README.md @@ -0,0 +1,125 @@ +# Room list sorting + +It's so complicated it needs its own README. + +## Algorithms involved + +There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting. +Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting +Algorithm respectively. The list algorithm determines the behaviour of the room list whereas the sorting +algorithm determines how rooms get ordered within tags affected by the list algorithm. + +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. + +### Tag sorting algorithm: Alphabetical + +When used, rooms in a given tag will be sorted alphabetically, where the alphabet's order is a problem +for the browser. All we do is a simple string comparison and expect the browser to return something +useful. + +### Tag sorting algorithm: Manual + +Manual sorting makes use of the `order` property present on all tags for a room, per the +[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values +of `order` cause rooms to appear closer to the top of the list. + +### Tag sorting algorithm: Recent + +Rooms get ordered by the timestamp of the most recent useful message. Usefulness is yet another algorithm +in the room list system which determines whether an event type is capable of bubbling up in the room list. +Normally events like room messages, stickers, and room security changes will be considered useful enough +to cause a shift in time. + +Note that this is reliant on the event timestamps of the most recent message. Because Matrix is eventually +consistent this means that from time to time a room might plummet or skyrocket across the tag due to the +timestamp contained within the event (generated server-side by the sender's server). + +### List ordering algorithm: Natural + +This is the easiest of the algorithms to understand because it does essentially nothing. It imposes no +behavioural changes over the tag sorting algorithm and is by far the simplest way to order a room list. +Historically, it's been the only option in Riot and extremely common in most chat applications due to +its relative deterministic behaviour. + +### List ordering algorithm: Importance + +On the other end of the spectrum, this is the most complicated algorithm which exists. There's major +behavioural changes, and the tag sorting algorithm gets selectively applied depending on circumstances. + +Each tag which is not manually ordered gets split into 4 sections or "categories". Manually ordered tags +simply get the manual sorting algorithm applied to them with no further involvement from the importance +algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off +relative (perceived) importance to the user: + +* **Red**: The room has unread mentions waiting for the user. +* **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread + messages which cause a push notification or badge count. Typically, this is the default as rooms get + set to 'All Messages'. +* **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without + a badge/notification count (or 'Mentions Only'/'Muted'). +* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user + last read it. + +Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey +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) +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. + + + +The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing. +The sticky room will remain in position on the room list regardless of other factors going on as typically +clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms +above the selected room at all times, where N is the number of rooms above the selected rooms when it was +selected. + +For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one +room above their selection at all times. If they receive another notification, and the tag ordering is +specified as Recent, they'll see the new notification go to the top position, and the one that was previously +there fall behind the sticky room. + +The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the +tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another +room, the previous sticky room gets recalculated to determine which category it needs to be in as the user +could have been scrolled up while new messages were received. + +Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what +kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user +selecting the third room (leaving 2 above it), and then having the rooms above it read on another device. +This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain +2 rooms above the sticky room. + +An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement +exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain +the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed. +The N value will never increase while selection remains unchanged: adding a bunch of rooms after having +put the sticky room in a position where it's had to decrease N will not increase N. + +## Responsibilities of the store + +The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets +an object containing the tags it needs to worry about and the rooms within. The room list component will +decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with +all kinds of filtering. + +## Class breakdowns + +The `RoomListStore` is the major coordinator of various `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 various bits throughout the room list store should also have jsdoc of some kind to help describe +what they do and how they work. diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts new file mode 100644 index 00000000000..881b8fd3cfd --- /dev/null +++ b/src/stores/room-list/RoomListStore2.ts @@ -0,0 +1,253 @@ +/* +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 { MatrixClient } from "matrix-js-sdk/src/client"; +import SettingsStore from "../../settings/SettingsStore"; +import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; +import { Algorithm } from "./algorithms/list-ordering/Algorithm"; +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 { ActionPayload } from "../../dispatcher/payloads"; +import defaultDispatcher from "../../dispatcher/dispatcher"; + +interface IState { + tagsEnabled?: boolean; + + preferredSort?: SortAlgorithm; + preferredAlgorithm?: ListAlgorithm; +} + +/** + * The event/channel which is called when the room lists have been changed. Raised + * with one argument: the instance of the store. + */ +export const LISTS_UPDATE_EVENT = "lists_update"; + +class _RoomListStore extends AsyncStore { + private matrixClient: MatrixClient; + private initialListsGenerated = false; + private enabled = false; + private algorithm: Algorithm; + + private readonly watchedSettings = [ + 'RoomList.orderAlphabetically', + 'RoomList.orderByImportance', + 'feature_custom_tags', + ]; + + constructor() { + super(defaultDispatcher); + + this.checkEnabled(); + 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("⚡ new room list store engaged"); + } + } + + 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(); + } + + protected async onDispatch(payload: ActionPayload) { + if (payload.action === 'MatrixActions.sync') { + // Filter out anything that isn't the first PREPARED sync. + if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) { + return; + } + + // TODO: Remove this once the RoomListStore becomes default + this.checkEnabled(); + if (!this.enabled) return; + + this.matrixClient = payload.matrixClient; + + // Update any settings here, as some may have happened before we were logically ready. + console.log("Regenerating room lists: Startup"); + await this.readAndCacheSettingsFromStore(); + await 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(null, true); + 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)) { + console.log("Regenerating room lists: Settings changed"); + await this.readAndCacheSettingsFromStore(); + + await this.regenerateAllLists(); // regenerate the lists now + } + } + + if (!this.algorithm) { + // This shouldn't happen because `initialListsGenerated` implies we have an algorithm. + throw new Error("Room list store has no algorithm to process dispatcher update with"); + } + + 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). + // noinspection JSObjectNullOrUndefined - this.matrixClient can't be null by this point in the lifecycle + 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 + console.log(payload); + return; + } + } + } else if (payload.action === 'MatrixActions.Room.tags') { + // TODO: Update room from tags + console.log(payload); + } else if (payload.action === 'MatrixActions.Room.timeline') { + const eventPayload = (payload); // TODO: Type out the dispatcher types + + // Ignore non-live events (backfill) + if (!eventPayload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent) return; + + const roomId = eventPayload.event.getRoomId(); + const room = this.matrixClient.getRoom(roomId); + console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${roomId}`); + await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); + } else if (payload.action === 'MatrixActions.Event.decrypted') { + const eventPayload = (payload); // TODO: Type out the dispatcher types + const roomId = eventPayload.event.getRoomId(); + const room = this.matrixClient.getRoom(roomId); + if (!room) { + console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`); + return; + } + console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`); + // TODO: Check that e2e rooms are calculated correctly on initial load. + // It seems like when viewing the room the timeline is decrypted, rather than at startup. This could + // cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :( + await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); + } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') { + // TODO: Update DMs + console.log(payload); + } else if (payload.action === 'MatrixActions.Room.myMembership') { + // TODO: Update room from membership change + console.log(payload); + } else if (payload.action === 'MatrixActions.Room') { + // TODO: Update room from creation/join + console.log(payload); + } else if (payload.action === 'view_room') { + // TODO: Update sticky room + console.log(payload); + } + } + + private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { + const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); + if (shouldUpdate) { + console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`); + this.emit(LISTS_UPDATE_EVENT, this); + } + } + + 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; + } + } + + protected async updateState(newState: IState) { + if (!this.enabled) return; + + await super.updateState(newState); + } + + private setAlgorithmClass() { + this.algorithm = getListAlgorithmInstance(this.state.preferredAlgorithm); + } + + private async regenerateAllLists() { + console.warn("Regenerating all room lists"); + 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 (this doesn't work) + const roomTags = TagOrderStore.getOrderedTags() || []; + console.log("rtags", roomTags); + } + + await this.algorithm.populateTags(tags); + await this.algorithm.setKnownRooms(this.matrixClient.getRooms()); + + this.initialListsGenerated = true; + + this.emit(LISTS_UPDATE_EVENT, this); + } +} + +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 00000000000..0268cf0a465 --- /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 SettingsStore from "../../settings/SettingsStore"; +import RoomListStore from "./RoomListStore2"; +import OldRoomListStore from "../RoomListStore"; +import { UPDATE_EVENT } from "../AsyncStore"; +import { ITagMap } from "./algorithms/models"; + +/** + * 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.on(UPDATE_EVENT, handler); + } else { + return OldRoomListStore.addListener(handler); + } + } + + public static getRoomLists(): ITagMap { + if (RoomListStoreTempProxy.isUsingNewStore()) { + return RoomListStore.instance.orderedLists; + } else { + return OldRoomListStore.getRoomLists(); + } + } +} diff --git a/src/stores/room-list/algorithms/list-ordering/Algorithm.ts b/src/stores/room-list/algorithms/list-ordering/Algorithm.ts new file mode 100644 index 00000000000..e154847847e --- /dev/null +++ b/src/stores/room-list/algorithms/list-ordering/Algorithm.ts @@ -0,0 +1,177 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { DefaultTagID, 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"; + +// TODO: Add locking support to avoid concurrent writes? +// TODO: EventEmitter support? Might not be needed. + +/** + * Represents a list ordering algorithm. This class will take care of tag + * management (which rooms go in which tags) and ask the implementation to + * deal with ordering mechanics. + */ +export abstract class Algorithm { + protected cached: ITagMap = {}; + protected sortAlgorithms: ITagSortingMap; + protected rooms: Room[] = []; + protected roomIdsToTags: { + [roomId: string]: TagID[]; + } = {}; + + protected constructor() { + } + + /** + * Asks the Algorithm to regenerate all lists, using the tags given + * as reference for which lists to generate and which way to generate + * them. + * @param {ITagSortingMap} tagSortingMap The tags to generate. + * @returns {Promise<*>} A promise which resolves when complete. + */ + public async populateTags(tagSortingMap: ITagSortingMap): Promise { + if (!tagSortingMap) throw new Error(`Map cannot be null or empty`); + this.sortAlgorithms = tagSortingMap; + return this.setKnownRooms(this.rooms); + } + + /** + * Gets an ordered set of rooms for the all known tags. + * @returns {ITagMap} The cached list of rooms, ordered, + * for each tag. May be empty, but never null/undefined. + */ + public getOrderedRooms(): ITagMap { + return this.cached; + } + + /** + * Seeds the Algorithm with a set of rooms. The algorithm will discard all + * previously known information and instead use these rooms instead. + * @param {Room[]} rooms The rooms to force the algorithm to use. + * @returns {Promise<*>} A promise which resolves when complete. + */ + public async setKnownRooms(rooms: Room[]): Promise { + if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`); + if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`); + + this.rooms = rooms; + + const newTags: ITagMap = {}; + for (const tagId in this.sortAlgorithms) { + // noinspection JSUnfilteredForInLoop + newTags[tagId] = []; + } + + // If we can avoid doing work, do so. + if (!rooms.length) { + await this.generateFreshTags(newTags); // just in case it wants to do something + this.cached = newTags; + return; + } + + // Split out the easy rooms first (leave and invite) + const memberships = splitRoomsByMembership(rooms); + for (const room of memberships[EffectiveMembership.Invite]) { + console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`); + newTags[DefaultTagID.Invite].push(room); + } + for (const room of memberships[EffectiveMembership.Leave]) { + console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`); + newTags[DefaultTagID.Archived].push(room); + } + + // Now process all the joined rooms. This is a bit more complicated + for (const room of memberships[EffectiveMembership.Join]) { + let tags = Object.keys(room.tags || {}); + + if (tags.length === 0) { + // Check to see if it's a DM if it isn't anything else + if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + tags = [DefaultTagID.DM]; + } + } + + let inTag = false; + if (tags.length > 0) { + for (const tag of tags) { + console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`); + if (!isNullOrUndefined(newTags[tag])) { + console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`); + newTags[tag].push(room); + inTag = true; + } + } + } + + if (!inTag) { + // TODO: Determine if DM and push there instead + newTags[DefaultTagID.Untagged].push(room); + console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`); + } + } + + await this.generateFreshTags(newTags); + + this.cached = newTags; + this.updateTagsFromCache(); + } + + /** + * Updates the roomsToTags map + */ + protected updateTagsFromCache() { + const newMap = {}; + + const tags = Object.keys(this.cached); + for (const tagId of tags) { + const rooms = this.cached[tagId]; + for (const room of rooms) { + if (!newMap[room.roomId]) newMap[room.roomId] = []; + newMap[room.roomId].push(tagId); + } + } + + this.roomIdsToTags = newMap; + } + + /** + * Called when the Algorithm believes a complete regeneration of the existing + * lists is needed. + * @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag + * will already have the rooms which belong to it - they just need ordering. Must + * be mutated in place. + * @returns {Promise<*>} A promise which resolves when complete. + */ + protected abstract generateFreshTags(updatedTagMap: ITagMap): Promise; + + /** + * 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. + * @param {RoomUpdateCause} cause The reason for the update being triggered. + * @returns {Promise} A promise which resolve to true or false + * depending on whether or not getOrderedRooms() should be called after + * processing. + */ + public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise; +} diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts new file mode 100644 index 00000000000..c72cdc2e1ca --- /dev/null +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -0,0 +1,298 @@ +/* +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 { Algorithm } from "./Algorithm"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomUpdateCause, TagID } from "../../models"; +import { ITagMap, SortAlgorithm } from "../models"; +import { sortRoomsWithAlgorithm } from "../tag-sorting"; +import * as Unread from '../../../../Unread'; + +/** + * The determined category of a room. + */ +export enum Category { + /** + * The room has unread mentions within. + */ + Red = "RED", + /** + * The room has unread notifications within. Note that these are not unread + * mentions - they are simply messages which the user has asked to cause a + * badge count update or push notification. + */ + Grey = "GREY", + /** + * The room has unread messages within (grey without the badge). + */ + Bold = "BOLD", + /** + * The room has no relevant unread messages within. + */ + Idle = "IDLE", +} + +interface ICategorizedRoomMap { + // @ts-ignore - TS wants this to be a string, but we know better + [category: Category]: Room[]; +} + +interface ICategoryIndex { + // @ts-ignore - TS wants this to be a string, but we know better + [category: Category]: number; // integer +} + +// Caution: changing this means you'll need to update a bunch of assumptions and +// comments! Check the usage of Category carefully to figure out what needs changing +// if you're going to change this array's order. +const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idle]; + +/** + * An implementation of the "importance" algorithm for room list sorting. Where + * the tag sorting algorithm does not interfere, rooms will be ordered into + * categories of varying importance to the user. Alphabetical sorting does not + * interfere with this algorithm, however manual ordering does. + * + * The importance of a room is defined by the kind of notifications, if any, are + * present on the room. These are classified internally as Red, Grey, Bold, and + * Idle. Red rooms have mentions, grey have unread messages, bold is a less noisy + * version of grey, and idle means all activity has been seen by the user. + * + * The algorithm works by monitoring all room changes, including new messages in + * tracked rooms, to determine if it needs a new category or different placement + * within the same category. For more information, see the comments contained + * within the class. + */ +export class ImportanceAlgorithm extends Algorithm { + + // HOW THIS WORKS + // -------------- + // + // This block of comments assumes you've read the README one level higher. + // You should do that if you haven't already. + // + // Tags are fed into the algorithmic functions from the 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]` and the sticky room information + // from `this.stickyRoom`. + // + // The room list store is always provided with the `this.cached` results, which are + // updated as needed and not recalculated often. For example, when a room needs to + // move within a tag, the array in `this.cached` will be spliced instead of iterated. + // The `indices` help track the positions of each category to make splicing easier. + + private indices: { + // @ts-ignore - TS wants this to be a string but we know better than it + [tag: TagID]: ICategoryIndex; + } = {}; + + // TODO: Use this (see docs above) + private stickyRoom: { + roomId: string; + tag: TagID; + fromTop: number; + } = { + roomId: null, + tag: null, + fromTop: 0, + }; + + constructor() { + super(); + console.log("Constructed an ImportanceAlgorithm"); + } + + // noinspection JSMethodCanBeStatic + private categorizeRooms(rooms: Room[]): ICategorizedRoomMap { + const map: ICategorizedRoomMap = { + [Category.Red]: [], + [Category.Grey]: [], + [Category.Bold]: [], + [Category.Idle]: [], + }; + for (const room of rooms) { + const category = this.getRoomCategory(room); + map[category].push(room); + } + return map; + } + + // noinspection JSMethodCanBeStatic + private getRoomCategory(room: Room): Category { + // Function implementation borrowed from old RoomListStore + + const mentions = room.getUnreadNotificationCount('highlight') > 0; + if (mentions) { + return Category.Red; + } + + let unread = room.getUnreadNotificationCount() > 0; + if (unread) { + return Category.Grey; + } + + unread = Unread.doesRoomHaveUnreadMessages(room); + if (unread) { + return Category.Bold; + } + + return Category.Idle; + } + + protected async generateFreshTags(updatedTagMap: ITagMap): Promise { + 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 handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { + const 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.cached[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 + changed = true; + } + return changed; + } + + private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category { + for (let i = 0; i < CATEGORY_ORDER.length; i++) { + const category = CATEGORY_ORDER[i]; + const isLast = i === (CATEGORY_ORDER.length - 1); + const startIdx = indices[category]; + const endIdx = isLast ? Number.MAX_SAFE_INTEGER : indices[CATEGORY_ORDER[i + 1]]; + if (index >= startIdx && index < endIdx) { + return category; + } + } + + // "Should never happen" disclaimer goes here + throw new Error("Programming error: somehow you've ended up with an index that isn't in a category"); + } + + 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 + // categories, the next category's index will change - not the category we're modifying. + // We also need to update subsequent categories as they'll all shift by nRooms, so we + // loop over the order to achieve that. + + for (let i = CATEGORY_ORDER.indexOf(fromCategory) + 1; i < CATEGORY_ORDER.length; i++) { + const nextCategory = CATEGORY_ORDER[i]; + indices[nextCategory] -= nRooms; + } + + for (let i = CATEGORY_ORDER.indexOf(toCategory) + 1; i < CATEGORY_ORDER.length; i++) { + const nextCategory = CATEGORY_ORDER[i]; + indices[nextCategory] += nRooms; + } + + // Do a quick check to see if we've completely broken the index + for (let i = 1; i <= CATEGORY_ORDER.length; i++) { + const lastCat = CATEGORY_ORDER[i - 1]; + const thisCat = CATEGORY_ORDER[i]; + + if (indices[lastCat] > indices[thisCat]) { + // "should never happen" disclaimer goes here + console.warn(`!! Room list index corruption: ${lastCat} (i:${indices[lastCat]}) is greater than ${thisCat} (i:${indices[thisCat]}) - category indices are likely desynced from reality`); + + // TODO: Regenerate index when this happens + } + } + } +} diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts new file mode 100644 index 00000000000..44a501e592b --- /dev/null +++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts @@ -0,0 +1,56 @@ +/* +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 { Algorithm } from "./Algorithm"; +import { ITagMap } from "../models"; +import { sortRoomsWithAlgorithm } from "../tag-sorting"; + +/** + * Uses the natural tag sorting algorithm order to determine tag ordering. No + * additional behavioural changes are present. + */ +export class NaturalAlgorithm extends Algorithm { + + constructor() { + super(); + console.log("Constructed a NaturalAlgorithm"); + } + + protected async generateFreshTags(updatedTagMap: ITagMap): Promise { + 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 handleRoomUpdate(room, cause): Promise { + const tags = this.roomIdsToTags[room.roomId]; + if (!tags) { + console.warn(`No tags known for "${room.name}" (${room.roomId})`); + return 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.cached[tag] = await sortRoomsWithAlgorithm(this.cached[tag], tag, this.sortAlgorithms[tag]); + } + return true; // assume we changed something + } +} diff --git a/src/stores/room-list/algorithms/list-ordering/index.ts b/src/stores/room-list/algorithms/list-ordering/index.ts new file mode 100644 index 00000000000..bcccd150cd3 --- /dev/null +++ b/src/stores/room-list/algorithms/list-ordering/index.ts @@ -0,0 +1,38 @@ +/* +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 { Algorithm } from "./Algorithm"; +import { ImportanceAlgorithm } from "./ImportanceAlgorithm"; +import { ListAlgorithm } from "../models"; +import { NaturalAlgorithm } from "./NaturalAlgorithm"; + +const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => Algorithm } = { + [ListAlgorithm.Natural]: () => new NaturalAlgorithm(), + [ListAlgorithm.Importance]: () => new ImportanceAlgorithm(), +}; + +/** + * Gets an instance of the defined algorithm + * @param {ListAlgorithm} algorithm The algorithm to get an instance of. + * @returns {Algorithm} The algorithm instance. + */ +export function getListAlgorithmInstance(algorithm: ListAlgorithm): Algorithm { + if (!ALGORITHM_FACTORIES[algorithm]) { + throw new Error(`${algorithm} is not a known algorithm`); + } + + return ALGORITHM_FACTORIES[algorithm](); +} diff --git a/src/stores/room-list/algorithms/models.ts b/src/stores/room-list/algorithms/models.ts new file mode 100644 index 00000000000..284600a7761 --- /dev/null +++ b/src/stores/room-list/algorithms/models.ts @@ -0,0 +1,42 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TagID } from "../models"; +import { Room } from "matrix-js-sdk/src/models/room"; + +export enum SortAlgorithm { + Manual = "MANUAL", + Alphabetic = "ALPHABETIC", + Recent = "RECENT", +} + +export enum ListAlgorithm { + // Orders Red > Grey > Bold > Idle + Importance = "IMPORTANCE", + + // Orders however the SortAlgorithm decides + Natural = "NATURAL", +} + +export interface ITagSortingMap { + // @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better. + [tagId: TagID]: SortAlgorithm; +} + +export interface ITagMap { + // @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better. + [tagId: TagID]: Room[]; +} diff --git a/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts new file mode 100644 index 00000000000..8d74ebd11e4 --- /dev/null +++ b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts @@ -0,0 +1,32 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/models/room"; +import { TagID } from "../../models"; +import { IAlgorithm } from "./IAlgorithm"; +import { MatrixClientPeg } from "../../../../MatrixClientPeg"; +import * as Unread from "../../../../Unread"; + +/** + * Sorts rooms according to the browser's determination of alphabetic. + */ +export class AlphabeticAlgorithm implements IAlgorithm { + public async sortRooms(rooms: Room[], tagId: TagID): Promise { + return rooms.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + } +} diff --git a/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts new file mode 100644 index 00000000000..6c22ee0c9c3 --- /dev/null +++ b/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts @@ -0,0 +1,31 @@ +/* +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 { TagID } from "../../models"; + +/** + * Represents a tag sorting algorithm. + */ +export interface IAlgorithm { + /** + * Sorts the given rooms according to the sorting rules of the algorithm. + * @param {Room[]} rooms The rooms to sort. + * @param {TagID} tagId The tag ID in which the rooms are being sorted. + * @returns {Promise} Resolves to the sorted rooms. + */ + sortRooms(rooms: Room[], tagId: TagID): Promise; +} diff --git a/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts new file mode 100644 index 00000000000..b8c03576330 --- /dev/null +++ b/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts @@ -0,0 +1,31 @@ +/* +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 { TagID } from "../../models"; +import { IAlgorithm } from "./IAlgorithm"; + +/** + * Sorts rooms according to the tag's `order` property on the room. + */ +export class ManualAlgorithm implements IAlgorithm { + public async sortRooms(rooms: Room[], tagId: TagID): Promise { + const getOrderProp = (r: Room) => r.tags[tagId].order || 0; + return rooms.sort((a, b) => { + return getOrderProp(a) - getOrderProp(b); + }); + } +} diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts new file mode 100644 index 00000000000..df84c051f07 --- /dev/null +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -0,0 +1,81 @@ +/* +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 { TagID } from "../../models"; +import { IAlgorithm } from "./IAlgorithm"; +import { MatrixClientPeg } from "../../../../MatrixClientPeg"; +import * as Unread from "../../../../Unread"; + +/** + * Sorts rooms according to the last event's timestamp in each room that seems + * useful to the user. + */ +export class RecentAlgorithm implements IAlgorithm { + public async sortRooms(rooms: Room[], tagId: TagID): Promise { + // We cache the timestamp lookup to avoid iterating forever on the timeline + // of events. This cache only survives a single sort though. + // We wouldn't need this if `.sort()` didn't constantly try and compare all + // of the rooms to each other. + + // TODO: We could probably improve the sorting algorithm here by finding changes. + // For example, if we spent a little bit of time to determine which elements have + // actually changed (probably needs to be done higher up?) then we could do an + // insertion sort or similar on the limited set of changes. + + const tsCache: { [roomId: string]: number } = {}; + const getLastTs = (r: Room) => { + if (tsCache[r.roomId]) { + return tsCache[r.roomId]; + } + + const ts = (() => { + // Apparently we can have rooms without timelines, at least under testing + // environments. Just return MAX_INT when this happens. + if (!r || !r.timeline) { + return Number.MAX_SAFE_INTEGER; + } + + for (let i = r.timeline.length - 1; i >= 0; --i) { + const ev = r.timeline[i]; + if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) + + // TODO: Don't assume we're using the same client as the peg + if (ev.getSender() === MatrixClientPeg.get().getUserId() + || Unread.eventTriggersUnreadCount(ev)) { + return ev.getTs(); + } + } + + // we might only have events that don't trigger the unread indicator, + // in which case use the oldest event even if normally it wouldn't count. + // This is better than just assuming the last event was forever ago. + if (r.timeline.length && r.timeline[0].getTs()) { + return r.timeline[0].getTs(); + } else { + return Number.MAX_SAFE_INTEGER; + } + })(); + + tsCache[r.roomId] = ts; + return ts; + }; + + return rooms.sort((a, b) => { + return getLastTs(a) - getLastTs(b); + }); + } +} diff --git a/src/stores/room-list/algorithms/tag-sorting/index.ts b/src/stores/room-list/algorithms/tag-sorting/index.ts new file mode 100644 index 00000000000..c22865f5ba1 --- /dev/null +++ b/src/stores/room-list/algorithms/tag-sorting/index.ts @@ -0,0 +1,53 @@ +/* +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 { SortAlgorithm } from "../models"; +import { ManualAlgorithm } from "./ManualAlgorithm"; +import { IAlgorithm } from "./IAlgorithm"; +import { TagID } from "../../models"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RecentAlgorithm } from "./RecentAlgorithm"; +import { AlphabeticAlgorithm } from "./AlphabeticAlgorithm"; + +const ALGORITHM_INSTANCES: { [algorithm in SortAlgorithm]: IAlgorithm } = { + [SortAlgorithm.Recent]: new RecentAlgorithm(), + [SortAlgorithm.Alphabetic]: new AlphabeticAlgorithm(), + [SortAlgorithm.Manual]: new ManualAlgorithm(), +}; + +/** + * Gets an instance of the defined algorithm + * @param {SortAlgorithm} algorithm The algorithm to get an instance of. + * @returns {IAlgorithm} The algorithm instance. + */ +export function getSortingAlgorithmInstance(algorithm: SortAlgorithm): IAlgorithm { + if (!ALGORITHM_INSTANCES[algorithm]) { + throw new Error(`${algorithm} is not a known algorithm`); + } + + return ALGORITHM_INSTANCES[algorithm]; +} + +/** + * Sorts rooms in a given tag according to the algorithm given. + * @param {Room[]} rooms The rooms to sort. + * @param {TagID} tagId The tag in which the sorting is occurring. + * @param {SortAlgorithm} algorithm The algorithm to use for sorting. + * @returns {Promise} Resolves to the sorted rooms. + */ +export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Promise { + return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId); +} diff --git a/src/stores/room-list/membership.ts b/src/stores/room-list/membership.ts new file mode 100644 index 00000000000..3cb4bf146c9 --- /dev/null +++ b/src/stores/room-list/membership.ts @@ -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"; + +/** + * Approximation of a membership status for a given room. + */ +export enum EffectiveMembership { + /** + * The user is effectively joined to the room. For example, actually joined + * or knocking on the room (when that becomes possible). + */ + Join = "JOIN", + + /** + * The user is effectively invited to the room. Currently this is a direct map + * to the invite membership as no other membership states are effectively + * invites. + */ + Invite = "INVITE", + + /** + * The user is effectively no longer in the room. For example, kicked, + * banned, or voluntarily left. + */ + Leave = "LEAVE", +} + +export interface MembershipSplit { + // @ts-ignore - TS wants this to be a string key, but we know better. + [state: EffectiveMembership]: Room[]; +} + +export function splitRoomsByMembership(rooms: Room[]): MembershipSplit { + const split: MembershipSplit = { + [EffectiveMembership.Invite]: [], + [EffectiveMembership.Join]: [], + [EffectiveMembership.Leave]: [], + }; + + for (const room of rooms) { + split[getEffectiveMembership(room.getMyMembership())].push(room); + } + + return split; +} + +export function getEffectiveMembership(membership: string): EffectiveMembership { + if (membership === 'invite') { + return EffectiveMembership.Invite; + } else if (membership === 'join') { + // TODO: Do the same for knock? Update docs as needed in the enum. + return EffectiveMembership.Join; + } else { + // Probably a leave, kick, or ban + return EffectiveMembership.Leave; + } +} diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts new file mode 100644 index 00000000000..a0c26210772 --- /dev/null +++ b/src/stores/room-list/models.ts @@ -0,0 +1,42 @@ +/* +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; + +export enum RoomUpdateCause { + Timeline = "TIMELINE", + RoomRead = "ROOM_READ", // TODO: Use this. +} diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index 235ed610164..d0694a84371 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/yarn.lock b/yarn.lock index a2db7da72e5..34bdc0be4a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1847,6 +1847,11 @@ autoprefixer@^9.0.0: postcss "^7.0.27" postcss-value-parser "^4.0.3" +await-lock@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.0.1.tgz#b3f65fdf66e08f7538260f79b46c15bcfc18cadd" + integrity sha512-ntLi9fzlMT/vWjC1wwVI11/cSRJ3nTS35qVekNc9WnaoMOP2eWH0RvIqwLQkDjX4a4YynsKEv+Ere2VONp9wxg== + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"