- {groupFilterPanel}
+ {leftLeftPanel}
{this.renderHeader()}
{this.renderSearchExplore()}
diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx
index 0e16d17da9..952b9d4cb6 100644
--- a/src/components/views/avatars/RoomAvatar.tsx
+++ b/src/components/views/avatars/RoomAvatar.tsx
@@ -24,7 +24,7 @@ import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
-interface IProps extends Omit, "name" | "idName" | "url" | "onClick">{
+interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx
new file mode 100644
index 0000000000..bc9cd5c9fd
--- /dev/null
+++ b/src/components/views/spaces/SpacePanel.tsx
@@ -0,0 +1,212 @@
+/*
+Copyright 2021 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, {useState} from "react";
+import classNames from "classnames";
+import {Room} from "matrix-js-sdk/src/models/room";
+
+import {_t} from "../../../languageHandler";
+import RoomAvatar from "../avatars/RoomAvatar";
+import {SpaceItem} from "./SpaceTreeLevel";
+import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
+import {useEventEmitter} from "../../../hooks/useEventEmitter";
+import SpaceStore, {HOME_SPACE, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../../stores/SpaceStore";
+import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
+import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState";
+import NotificationBadge from "../rooms/NotificationBadge";
+import {
+ RovingAccessibleButton,
+ RovingAccessibleTooltipButton,
+ RovingTabIndexProvider,
+} from "../../../accessibility/RovingTabIndex";
+import {Key} from "../../../Keyboard";
+
+interface IButtonProps {
+ space?: Room;
+ className?: string;
+ selected?: boolean;
+ tooltip?: string;
+ notificationState?: SpaceNotificationState;
+ isNarrow?: boolean;
+ onClick(): void;
+}
+
+const SpaceButton: React.FC = ({
+ space,
+ className,
+ selected,
+ onClick,
+ tooltip,
+ notificationState,
+ isNarrow,
+ children,
+}) => {
+ const classes = classNames("mx_SpaceButton", className, {
+ mx_SpaceButton_active: selected,
+ });
+
+ let avatar =
;
+ if (space) {
+ avatar = ;
+ }
+
+ let notifBadge;
+ if (notificationState) {
+ notifBadge =
+
+
;
+ }
+
+ let button;
+ if (isNarrow) {
+ button = (
+
+ { avatar }
+ { notifBadge }
+ { children }
+
+ );
+ } else {
+ button = (
+
+ { avatar }
+ { tooltip }
+ { notifBadge }
+ { children }
+
+ );
+ }
+
+ return
+ { button }
+ ;
+}
+
+const useSpaces = (): [Room[], Room | null] => {
+ const [spaces, setSpaces] = useState(SpaceStore.instance.spacePanelSpaces);
+ useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
+ const [activeSpace, setActiveSpace] = useState(SpaceStore.instance.activeSpace);
+ useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace);
+ return [spaces, activeSpace];
+};
+
+const SpacePanel = () => {
+ const [spaces, activeSpace] = useSpaces();
+ const [isPanelCollapsed, setPanelCollapsed] = useState(true);
+
+ const onKeyDown = (ev: React.KeyboardEvent) => {
+ let handled = true;
+
+ switch (ev.key) {
+ case Key.ARROW_UP:
+ onMoveFocus(ev.target as Element, true);
+ break;
+ case Key.ARROW_DOWN:
+ onMoveFocus(ev.target as Element, false);
+ break;
+ default:
+ handled = false;
+ }
+
+ if (handled) {
+ // consume all other keys in context menu
+ ev.stopPropagation();
+ ev.preventDefault();
+ }
+ };
+
+ const onMoveFocus = (element: Element, up: boolean) => {
+ let descending = false; // are we currently descending or ascending through the DOM tree?
+ let classes: DOMTokenList;
+
+ do {
+ const child = up ? element.lastElementChild : element.firstElementChild;
+ const sibling = up ? element.previousElementSibling : element.nextElementSibling;
+
+ if (descending) {
+ if (child) {
+ element = child;
+ } else if (sibling) {
+ element = sibling;
+ } else {
+ descending = false;
+ element = element.parentElement;
+ }
+ } else {
+ if (sibling) {
+ element = sibling;
+ descending = true;
+ } else {
+ element = element.parentElement;
+ }
+ }
+
+ if (element) {
+ if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
+ element = up ? element.lastElementChild : element.firstElementChild;
+ descending = true;
+ }
+ classes = element.classList;
+ }
+ } while (element && !classes.contains("mx_SpaceButton"));
+
+ if (element) {
+ (element as HTMLElement).focus();
+ }
+ };
+
+ const activeSpaces = activeSpace ? [activeSpace] : [];
+ const expandCollapseButtonTitle = isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel");
+ // TODO drag and drop for re-arranging order
+ return
+ {({onKeyDownHandler}) => (
+
+ )}
+
+};
+
+export default SpacePanel;
diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx
new file mode 100644
index 0000000000..14fe68ff66
--- /dev/null
+++ b/src/components/views/spaces/SpaceTreeLevel.tsx
@@ -0,0 +1,184 @@
+/*
+Copyright 2021 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 from "react";
+import classNames from "classnames";
+import {Room} from "matrix-js-sdk/src/models/room";
+
+import RoomAvatar from "../avatars/RoomAvatar";
+import SpaceStore from "../../../stores/SpaceStore";
+import NotificationBadge from "../rooms/NotificationBadge";
+import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton";
+import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton";
+import MatrixClientContext from "../../../contexts/MatrixClientContext";
+
+interface IItemProps {
+ space?: Room;
+ activeSpaces: Room[];
+ isNested?: boolean;
+ isPanelCollapsed?: boolean;
+ onExpand?: Function;
+}
+
+interface IItemState {
+ collapsed: boolean;
+ contextMenuPosition: Pick;
+}
+
+export class SpaceItem extends React.PureComponent {
+ static contextType = MatrixClientContext;
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ collapsed: !props.isNested, // default to collapsed for root items
+ contextMenuPosition: null,
+ };
+ }
+
+ private toggleCollapse(evt) {
+ if (this.props.onExpand && this.state.collapsed) {
+ this.props.onExpand();
+ }
+ this.setState({collapsed: !this.state.collapsed});
+ // don't bubble up so encapsulating button for space
+ // doesn't get triggered
+ evt.stopPropagation();
+ }
+
+ private onContextMenu = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.setState({
+ contextMenuPosition: {
+ right: ev.clientX,
+ top: ev.clientY,
+ height: 0,
+ },
+ });
+ }
+
+ private onClick = (ev: React.MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ SpaceStore.instance.setActiveSpace(this.props.space);
+ };
+
+ render() {
+ const {space, activeSpaces, isNested} = this.props;
+
+ const forceCollapsed = this.props.isPanelCollapsed;
+ const isNarrow = this.props.isPanelCollapsed;
+ const collapsed = this.state.collapsed || forceCollapsed;
+
+ const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId);
+ const isActive = activeSpaces.includes(space);
+ const itemClasses = classNames({
+ "mx_SpaceItem": true,
+ "collapsed": collapsed,
+ "hasSubSpaces": childSpaces && childSpaces.length,
+ });
+ const classes = classNames("mx_SpaceButton", {
+ mx_SpaceButton_active: isActive,
+ mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
+ });
+ const notificationState = SpaceStore.instance.getNotificationState(space.roomId);
+ const childItems = childSpaces && !collapsed ? : null;
+ let notifBadge;
+ if (notificationState) {
+ notifBadge =
+
+
;
+ }
+
+ const avatarSize = isNested ? 24 : 32;
+
+ const toggleCollapseButton = childSpaces && childSpaces.length ?
+ this.toggleCollapse(evt)}
+ /> : null;
+
+ let button;
+ if (isNarrow) {
+ button = (
+
+ { toggleCollapseButton }
+
+ { notifBadge }
+
+ );
+ } else {
+ button = (
+
+ { toggleCollapseButton }
+
+ { space.name }
+ { notifBadge }
+
+ );
+ }
+
+ return (
+
+ { button }
+ { childItems }
+
+ );
+ }
+}
+
+interface ITreeLevelProps {
+ spaces: Room[];
+ activeSpaces: Room[];
+ isNested?: boolean;
+}
+
+const SpaceTreeLevel: React.FC = ({
+ spaces,
+ activeSpaces,
+ isNested,
+}) => {
+ return
+ {spaces.map(s => {
+ return ( );
+ })}
+ ;
+}
+
+export default SpaceTreeLevel;
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 566db0af5e..38460a5f6e 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -978,6 +978,9 @@
"From %(deviceName)s (%(deviceId)s)": "From %(deviceName)s (%(deviceId)s)",
"Decline (%(counter)s)": "Decline (%(counter)s)",
"Accept to continue:": "Accept to continue:",
+ "Expand space panel": "Expand space panel",
+ "Collapse space panel": "Collapse space panel",
+ "Home": "Home",
"Remove": "Remove",
"Upload": "Upload",
"This bridge was provisioned by .": "This bridge was provisioned by .",
@@ -1941,7 +1944,6 @@
"Continue with %(provider)s": "Continue with %(provider)s",
"Sign in with single sign-on": "Sign in with single sign-on",
"And %(count)s more...|other": "And %(count)s more...",
- "Home": "Home",
"Enter a server name": "Enter a server name",
"Looks good": "Looks good",
"Can't find this server or its room list": "Can't find this server or its room list",
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
new file mode 100644
index 0000000000..d675879138
--- /dev/null
+++ b/src/stores/SpaceStore.tsx
@@ -0,0 +1,462 @@
+/*
+Copyright 2021 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 {throttle, sortBy} from "lodash";
+import {EventType} from "matrix-js-sdk/src/@types/event";
+import {Room} from "matrix-js-sdk/src/models/room";
+import {MatrixEvent} from "matrix-js-sdk/src/models/event";
+
+import {AsyncStoreWithClient} from "./AsyncStoreWithClient";
+import defaultDispatcher from "../dispatcher/dispatcher";
+import {ActionPayload} from "../dispatcher/payloads";
+import RoomListStore from "./room-list/RoomListStore";
+import SettingsStore from "../settings/SettingsStore";
+import DMRoomMap from "../utils/DMRoomMap";
+import {FetchRoomFn} from "./notifications/ListNotificationState";
+import {SpaceNotificationState} from "./notifications/SpaceNotificationState";
+import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateStore";
+import {DefaultTagID} from "./room-list/models";
+import {EnhancedMap, mapDiff} from "../utils/maps";
+import {setHasDiff} from "../utils/sets";
+import {objectDiff} from "../utils/objects";
+import {arrayHasDiff} from "../utils/arrays";
+
+type SpaceKey = string | symbol;
+
+interface IState {}
+
+const ACTIVE_SPACE_LS_KEY = "mx_active_space";
+
+export const HOME_SPACE = Symbol("home-space");
+
+export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
+export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
+// Space Room ID/HOME_SPACE will be emitted when a Space's children change
+
+const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
+ return arr.reduce((result, room: Room) => {
+ result[room.isSpaceRoom() ? 0 : 1].push(room);
+ return result;
+ }, [[], []]);
+};
+
+const getOrder = (ev: MatrixEvent): string | null => {
+ const content = ev.getContent();
+ if (typeof content.order === "string" && Array.from(content.order).every((c: string) => {
+ const charCode = c.charCodeAt(0);
+ return charCode >= 0x20 && charCode <= 0x7F;
+ })) {
+ return content.order;
+ }
+ return null;
+}
+
+const getRoomFn: FetchRoomFn = (room: Room) => {
+ return RoomNotificationStateStore.instance.getRoomState(room);
+};
+
+export class SpaceStoreClass extends AsyncStoreWithClient {
+ constructor() {
+ super(defaultDispatcher, {});
+ }
+
+ // The spaces representing the roots of the various tree-like hierarchies
+ private rootSpaces: Room[] = [];
+ // The list of rooms not present in any currently joined spaces
+ private orphanedRooms = new Set();
+ // Map from room ID to set of spaces which list it as a child
+ private parentMap = new EnhancedMap>();
+ // Map from space key to SpaceNotificationState instance representing that space
+ private notificationStateMap = new Map();
+ // Map from space key to Set of room IDs that should be shown as part of that space's filter
+ private spaceFilteredRooms = new Map>();
+ // The space currently selected in the Space Panel - if null then `Home` is selected
+ private _activeSpace?: Room = null;
+
+ public get spacePanelSpaces(): Room[] {
+ return this.rootSpaces;
+ }
+
+ public get activeSpace(): Room | null {
+ return this._activeSpace || null;
+ }
+
+ public setActiveSpace(space: Room | null) {
+ if (space === this.activeSpace) return;
+
+ this._activeSpace = space;
+ this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);
+
+ // persist space selected
+ if (space) {
+ window.localStorage.setItem(ACTIVE_SPACE_LS_KEY, space.roomId);
+ } else {
+ window.localStorage.removeItem(ACTIVE_SPACE_LS_KEY);
+ }
+ }
+
+ public addRoomToSpace(space: Room, roomId: string, via: string[], autoJoin = false) {
+ return this.matrixClient.sendStateEvent(space.roomId, EventType.SpaceChild, {
+ via,
+ auto_join: autoJoin,
+ }, roomId);
+ }
+
+ private getChildren(spaceId: string): Room[] {
+ const room = this.matrixClient?.getRoom(spaceId);
+ const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via);
+ return sortBy(childEvents, getOrder)
+ .map(ev => this.matrixClient.getRoom(ev.getStateKey()))
+ .filter(room => room?.getMyMembership() === "join") || [];
+ }
+
+ public getChildRooms(spaceId: string): Room[] {
+ return this.getChildren(spaceId).filter(r => !r.isSpaceRoom());
+ }
+
+ public getChildSpaces(spaceId: string): Room[] {
+ return this.getChildren(spaceId).filter(r => r.isSpaceRoom());
+ }
+
+ public getParents(roomId: string, canonicalOnly = false): Room[] {
+ const room = this.matrixClient?.getRoom(roomId);
+ return room?.currentState.getStateEvents(EventType.SpaceParent)
+ .filter(ev => {
+ const content = ev.getContent();
+ if (!content?.via) return false;
+ // TODO apply permissions check to verify that the parent mapping is valid
+ if (canonicalOnly && !content?.canonical) return false;
+ return true;
+ })
+ .map(ev => this.matrixClient.getRoom(ev.getStateKey()))
+ .filter(Boolean) || [];
+ }
+
+ public getCanonicalParent(roomId: string): Room | null {
+ const parents = this.getParents(roomId, true);
+ return sortBy(parents, r => r.roomId)?.[0] || null;
+ }
+
+ public getSpaces = () => {
+ return this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "join");
+ };
+
+ public getSpaceFilteredRoomIds = (space: Room | null): Set => {
+ return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
+ };
+
+ public rebuild = throttle(() => { // exported for tests
+ const visibleRooms = this.matrixClient.getVisibleRooms();
+
+ // Sort spaces by room ID to force the loop breaking to be deterministic
+ const spaces = sortBy(this.getSpaces(), space => space.roomId);
+ const unseenChildren = new Set([...visibleRooms, ...spaces]);
+
+ const backrefs = new EnhancedMap>();
+
+ // TODO handle cleaning up links when a Space is removed
+ spaces.forEach(space => {
+ const children = this.getChildren(space.roomId);
+ children.forEach(child => {
+ unseenChildren.delete(child);
+
+ backrefs.getOrCreate(child.roomId, new Set()).add(space.roomId);
+ });
+ });
+
+ const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren));
+
+ // untested algorithm to handle full-cycles
+ const detachedNodes = new Set(spaces);
+
+ const markTreeChildren = (rootSpace: Room, unseen: Set) => {
+ const stack = [rootSpace];
+ while (stack.length) {
+ const op = stack.pop();
+ unseen.delete(op);
+ this.getChildSpaces(op.roomId).forEach(space => {
+ if (unseen.has(space)) {
+ stack.push(space);
+ }
+ });
+ }
+ };
+
+ rootSpaces.forEach(rootSpace => {
+ markTreeChildren(rootSpace, detachedNodes);
+ });
+
+ // Handle spaces forming fully cyclical relationships.
+ // In order, assume each detachedNode is a root unless it has already
+ // been claimed as the child of prior detached node.
+ // Work from a copy of the detachedNodes set as it will be mutated as part of this operation.
+ Array.from(detachedNodes).forEach(detachedNode => {
+ if (!detachedNodes.has(detachedNode)) return;
+ // declare this detached node a new root, find its children, without ever looping back to it
+ detachedNodes.delete(detachedNode);
+ rootSpaces.push(detachedNode);
+ markTreeChildren(detachedNode, detachedNodes);
+
+ // TODO only consider a detached node a root space if it has no *parents other than the ones forming cycles
+ });
+
+ // TODO neither of these handle an A->B->C->A with an additional C->D
+ // detachedNodes.forEach(space => {
+ // rootSpaces.push(space);
+ // });
+
+ this.orphanedRooms = new Set(orphanedRooms);
+ this.rootSpaces = rootSpaces;
+ this.parentMap = backrefs;
+
+ // if the currently selected space no longer exists, remove its selection
+ if (this._activeSpace && detachedNodes.has(this._activeSpace)) {
+ this.setActiveSpace(null);
+ }
+
+ this.onRoomsUpdate(); // TODO only do this if a change has happened
+ this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces);
+ }, 100, {trailing: true, leading: true});
+
+ onSpaceUpdate = () => {
+ this.rebuild();
+ }
+
+ private showInHomeSpace = (room: Room) => {
+ return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space
+ || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
+ || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites
+ };
+
+ // Update a given room due to its tag changing (e.g DM-ness or Fav-ness)
+ // This can only change whether it shows up in the HOME_SPACE or not
+ private onRoomUpdate = (room: Room) => {
+ if (this.showInHomeSpace(room)) {
+ this.spaceFilteredRooms.get(HOME_SPACE)?.add(room.roomId);
+ this.emit(HOME_SPACE);
+ } else if (!this.orphanedRooms.has(room.roomId)) {
+ this.spaceFilteredRooms.get(HOME_SPACE)?.delete(room.roomId);
+ this.emit(HOME_SPACE);
+ }
+ };
+
+ private onRoomsUpdate = throttle(() => {
+ // TODO resolve some updates as deltas
+ const visibleRooms = this.matrixClient.getVisibleRooms();
+
+ const oldFilteredRooms = this.spaceFilteredRooms;
+ this.spaceFilteredRooms = new Map();
+
+ // put all invites (rooms & spaces) in the Home Space
+ const invites = this.matrixClient.getRooms().filter(r => r.getMyMembership() === "invite");
+ this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId)));
+
+ visibleRooms.forEach(room => {
+ if (this.showInHomeSpace(room)) {
+ this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId);
+ }
+ });
+
+ this.rootSpaces.forEach(s => {
+ // traverse each space tree in DFS to build up the supersets as you go up,
+ // reusing results from like subtrees.
+ const fn = (spaceId: string, parentPath: Set): Set => {
+ if (parentPath.has(spaceId)) return; // prevent cycles
+
+ // reuse existing results if multiple similar branches exist
+ if (this.spaceFilteredRooms.has(spaceId)) {
+ return this.spaceFilteredRooms.get(spaceId);
+ }
+
+ const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId));
+ const roomIds = new Set(childRooms.map(r => r.roomId));
+ const space = this.matrixClient?.getRoom(spaceId);
+
+ // Add relevant DMs
+ space?.getJoinedMembers().forEach(member => {
+ DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => {
+ roomIds.add(roomId);
+ });
+ });
+
+ const newPath = new Set(parentPath).add(spaceId);
+ childSpaces.forEach(childSpace => {
+ fn(childSpace.roomId, newPath)?.forEach(roomId => {
+ roomIds.add(roomId);
+ });
+ });
+ this.spaceFilteredRooms.set(spaceId, roomIds);
+ return roomIds;
+ };
+
+ fn(s.roomId, new Set());
+ });
+
+ const diff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms);
+ // filter out keys which changed by reference only by checking whether the sets differ
+ const changed = diff.changed.filter(k => setHasDiff(oldFilteredRooms.get(k), this.spaceFilteredRooms.get(k)));
+ [...diff.added, ...diff.removed, ...changed].forEach(k => {
+ this.emit(k);
+ });
+
+ this.spaceFilteredRooms.forEach((roomIds, s) => {
+ // Update NotificationStates
+ const rooms = this.matrixClient.getRooms().filter(room => roomIds.has(room.roomId));
+ this.getNotificationState(s)?.setRooms(rooms);
+ });
+ }, 100, {trailing: true, leading: true});
+
+ private onRoom = (room: Room) => {
+ if (room?.isSpaceRoom()) {
+ this.onSpaceUpdate();
+ this.emit(room.roomId);
+ } else {
+ // this.onRoomUpdate(room);
+ this.onRoomsUpdate();
+ }
+ };
+
+ private onRoomState = (ev: MatrixEvent) => {
+ const room = this.matrixClient.getRoom(ev.getRoomId());
+ if (!room) return;
+
+ if (ev.getType() === EventType.SpaceChild && room.isSpaceRoom()) {
+ this.onSpaceUpdate();
+ this.emit(room.roomId);
+ } else if (ev.getType() === EventType.SpaceParent) {
+ // TODO rebuild the space parent and not the room - check permissions?
+ // TODO confirm this after implementing parenting behaviour
+ if (room.isSpaceRoom()) {
+ this.onSpaceUpdate();
+ } else {
+ this.onRoomUpdate(room);
+ }
+ this.emit(room.roomId);
+ }
+ };
+
+ private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent: MatrixEvent) => {
+ if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) {
+ // If the room was in favourites and now isn't or the opposite then update its position in the trees
+ if (!!ev.getContent()[DefaultTagID.Favourite] !== !!lastEvent.getContent()[DefaultTagID.Favourite]) {
+ this.onRoomUpdate(room);
+ }
+ }
+ }
+
+ private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => {
+ if (ev.getType() === EventType.Direct) {
+ const lastContent = lastEvent.getContent();
+ const content = ev.getContent();
+
+ const diff = objectDiff>(lastContent, content);
+ // filter out keys which changed by reference only by checking whether the sets differ
+ const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k]));
+ // DM tag changes, refresh relevant rooms
+ new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => {
+ const room = this.matrixClient?.getRoom(roomId);
+ if (room) {
+ this.onRoomUpdate(room);
+ }
+ });
+ }
+ };
+
+ protected async onNotReady() {
+ if (!SettingsStore.getValue("feature_spaces")) return;
+ if (this.matrixClient) {
+ this.matrixClient.removeListener("Room", this.onRoom);
+ this.matrixClient.removeListener("Room.myMembership", this.onRoom);
+ this.matrixClient.removeListener("RoomState.events", this.onRoomState);
+ this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
+ this.matrixClient.removeListener("accountData", this.onAccountData);
+ }
+ await this.reset({});
+ }
+
+ protected async onReady() {
+ if (!SettingsStore.getValue("feature_spaces")) return;
+ this.matrixClient.on("Room", this.onRoom);
+ this.matrixClient.on("Room.myMembership", this.onRoom);
+ this.matrixClient.on("RoomState.events", this.onRoomState);
+ this.matrixClient.on("Room.accountData", this.onRoomAccountData);
+ this.matrixClient.on("accountData", this.onAccountData);
+
+ await this.onSpaceUpdate(); // trigger an initial update
+
+ // restore selected state from last session if any and still valid
+ const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY);
+ if (lastSpaceId) {
+ const space = this.rootSpaces.find(s => s.roomId === lastSpaceId);
+ if (space) {
+ this.setActiveSpace(space);
+ }
+ }
+ }
+
+ protected async onAction(payload: ActionPayload) {
+ switch (payload.action) {
+ case "view_room": {
+ const room = this.matrixClient?.getRoom(payload.room_id);
+
+ if (room?.getMyMembership() === "join") {
+ if (room.isSpaceRoom()) {
+ this.setActiveSpace(room);
+ } else if (!this.spaceFilteredRooms.get(this._activeSpace?.roomId || HOME_SPACE).has(room.roomId)) {
+ // TODO maybe reverse these first 2 clauses once space panel active is fixed
+ let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
+ if (!parent) {
+ parent = this.getCanonicalParent(room.roomId);
+ }
+ if (!parent) {
+ const parents = Array.from(this.parentMap.get(room.roomId) || []);
+ parent = parents.find(p => this.matrixClient.getRoom(p));
+ }
+ if (parent) {
+ this.setActiveSpace(parent);
+ }
+ }
+ }
+ break;
+ }
+ case "after_leave_room":
+ if (this._activeSpace && payload.room_id === this._activeSpace.roomId) {
+ this.setActiveSpace(null);
+ }
+ break;
+ }
+ }
+
+ public getNotificationState(key: SpaceKey): SpaceNotificationState {
+ if (this.notificationStateMap.has(key)) {
+ return this.notificationStateMap.get(key);
+ }
+
+ const state = new SpaceNotificationState(key, getRoomFn);
+ this.notificationStateMap.set(key, state);
+ return state;
+ }
+}
+
+export default class SpaceStore {
+ private static internalInstance = new SpaceStoreClass();
+
+ public static get instance(): SpaceStoreClass {
+ return SpaceStore.internalInstance;
+ }
+}
+
+window.mxSpaceStore = SpaceStore.instance;
diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts
new file mode 100644
index 0000000000..61a9701a07
--- /dev/null
+++ b/src/stores/notifications/SpaceNotificationState.ts
@@ -0,0 +1,82 @@
+/*
+Copyright 2021 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 { NotificationColor } from "./NotificationColor";
+import { arrayDiff } from "../../utils/arrays";
+import { RoomNotificationState } from "./RoomNotificationState";
+import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState";
+import { FetchRoomFn } from "./ListNotificationState";
+
+export class SpaceNotificationState extends NotificationState {
+ private rooms: Room[] = [];
+ private states: { [spaceId: string]: RoomNotificationState } = {};
+
+ constructor(private spaceId: string | symbol, private getRoomFn: FetchRoomFn) {
+ super();
+ }
+
+ public get symbol(): string {
+ return null; // This notification state doesn't support symbols
+ }
+
+ public setRooms(rooms: Room[]) {
+ const oldRooms = this.rooms;
+ const diff = arrayDiff(oldRooms, rooms);
+ this.rooms = rooms;
+ for (const oldRoom of diff.removed) {
+ const state = this.states[oldRoom.roomId];
+ if (!state) continue; // We likely just didn't have a badge (race condition)
+ delete this.states[oldRoom.roomId];
+ state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
+ }
+ for (const newRoom of diff.added) {
+ const state = this.getRoomFn(newRoom);
+ state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
+ this.states[newRoom.roomId] = state;
+ }
+
+ this.calculateTotalState();
+ }
+
+ public destroy() {
+ super.destroy();
+ for (const state of Object.values(this.states)) {
+ state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
+ }
+ this.states = {};
+ }
+
+ private onRoomNotificationStateUpdate = () => {
+ this.calculateTotalState();
+ };
+
+ private calculateTotalState() {
+ const snapshot = this.snapshot();
+
+ this._count = 0;
+ this._color = NotificationColor.None;
+ for (const state of Object.values(this.states)) {
+ this._count += state.count;
+ this._color = Math.max(this.color, state.color);
+ }
+
+ // finally, publish an update if needed
+ this.emitIfUpdated(snapshot);
+ }
+}
+
diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts
index 667d9de64d..60a960261c 100644
--- a/src/stores/room-list/RoomListStore.ts
+++ b/src/stores/room-list/RoomListStore.ts
@@ -35,6 +35,7 @@ import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { NameFilterCondition } from "./filters/NameFilterCondition";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { VisibilityProvider } from "./filters/VisibilityProvider";
+import { SpaceWatcher } from "./SpaceWatcher";
interface IState {
tagsEnabled?: boolean;
@@ -56,7 +57,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient {
private initialListsGenerated = false;
private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = [];
- private tagWatcher = new TagWatcher(this);
+ private tagWatcher: TagWatcher;
+ private spaceWatcher: SpaceWatcher;
private updateFn = new MarkedExecution(() => {
for (const tagId of Object.keys(this.orderedLists)) {
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.orderedLists[tagId]);
@@ -77,6 +79,15 @@ export class RoomListStoreClass extends AsyncStoreWithClient {
RoomViewStore.addListener(() => this.handleRVSUpdate({}));
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
+ this.setupWatchers();
+ }
+
+ private setupWatchers() {
+ if (SettingsStore.getValue("feature_spaces")) {
+ this.spaceWatcher = new SpaceWatcher(this);
+ } else {
+ this.tagWatcher = new TagWatcher(this);
+ }
}
public get unfilteredLists(): ITagMap {
@@ -92,9 +103,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient {
// Intended for test usage
public async resetStore() {
await this.reset();
- this.tagWatcher = new TagWatcher(this);
this.filterConditions = [];
this.initialListsGenerated = false;
+ this.setupWatchers();
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
@@ -554,8 +565,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient {
public async regenerateAllLists({trigger = true}) {
console.warn("Regenerating all room lists");
- const rooms = this.matrixClient.getVisibleRooms()
- .filter(r => VisibilityProvider.instance.isRoomVisible(r));
+ const rooms = [
+ ...this.matrixClient.getVisibleRooms(),
+ // also show space invites in the room list
+ ...this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "invite"),
+ ].filter(r => VisibilityProvider.instance.isRoomVisible(r));
+
const customTags = new Set();
if (this.state.tagsEnabled) {
for (const room of rooms) {
diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts
new file mode 100644
index 0000000000..d26f563a91
--- /dev/null
+++ b/src/stores/room-list/SpaceWatcher.ts
@@ -0,0 +1,39 @@
+/*
+Copyright 2021 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 { RoomListStoreClass } from "./RoomListStore";
+import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
+import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore";
+
+/**
+ * Watches for changes in spaces to manage the filter on the provided RoomListStore
+ */
+export class SpaceWatcher {
+ private filter = new SpaceFilterCondition();
+ private activeSpace: Room = SpaceStore.instance.activeSpace;
+
+ constructor(private store: RoomListStoreClass) {
+ this.filter.updateSpace(this.activeSpace); // get the filter into a consistent state
+ store.addFilter(this.filter);
+ SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
+ }
+
+ private onSelectedSpaceUpdated = (activeSpace) => {
+ this.filter.updateSpace(this.activeSpace = activeSpace);
+ };
+}
diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts
index f709fc3ccb..fed3099325 100644
--- a/src/stores/room-list/algorithms/Algorithm.ts
+++ b/src/stores/room-list/algorithms/Algorithm.ts
@@ -186,6 +186,9 @@ export class Algorithm extends EventEmitter {
}
private async doUpdateStickyRoom(val: Room) {
+ // no-op sticky rooms for spaces - they're effectively virtual rooms
+ if (val?.isSpaceRoom() && val.getMyMembership() !== "invite") val = null;
+
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms.
diff --git a/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts
new file mode 100644
index 0000000000..49c58c9d1d
--- /dev/null
+++ b/src/stores/room-list/filters/SpaceFilterCondition.ts
@@ -0,0 +1,69 @@
+/*
+Copyright 2021 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 { Room } from "matrix-js-sdk/src/models/room";
+
+import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
+import { IDestroyable } from "../../../utils/IDestroyable";
+import SpaceStore, {HOME_SPACE} from "../../SpaceStore";
+import { setHasDiff } from "../../../utils/sets";
+
+/**
+ * A filter condition for the room list which reveals rooms which
+ * are a member of a given space or if no space is selected shows:
+ * + Orphaned rooms (ones not in any space you are a part of)
+ * + All DMs
+ */
+export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
+ private roomIds = new Set();
+ private space: Room = null;
+
+ public get relativePriority(): FilterPriority {
+ // Lowest priority so we can coarsely find rooms.
+ return FilterPriority.Lowest;
+ }
+
+ public isVisible(room: Room): boolean {
+ return this.roomIds.has(room.roomId);
+ }
+
+ private onStoreUpdate = async (): Promise => {
+ const beforeRoomIds = this.roomIds;
+ this.roomIds = SpaceStore.instance.getSpaceFilteredRoomIds(this.space);
+
+ if (setHasDiff(beforeRoomIds, this.roomIds)) {
+ // XXX: Room List Store has a bug where rooms which are synced after the filter is set
+ // are excluded from the filter, this is a workaround for it.
+ this.emit(FILTER_CHANGED);
+ setTimeout(() => {
+ this.emit(FILTER_CHANGED);
+ }, 500);
+ }
+ };
+
+ private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE;
+
+ public updateSpace(space: Room) {
+ SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
+ SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate);
+ this.onStoreUpdate(); // initial update from the change to the space
+ }
+
+ public destroy(): void {
+ SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
+ }
+}