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}) =>
+
+
+
+ {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"