- {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 f443c4961b..c946310496 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/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..40fdae5ae4 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
+ 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);
+ }
+}