Merge pull request #5689 from matrix-org/t3chguy/spaces2
Space Store and Space Panel for Room List filtering
This commit is contained in:
2
src/@types/global.d.ts
vendored
2
src/@types/global.d.ts
vendored
@@ -38,6 +38,7 @@ import UserActivity from "../UserActivity";
|
||||
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
|
||||
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
|
||||
import VoipUserMapper from "../VoipUserMapper";
|
||||
import {SpaceStoreClass} from "../stores/SpaceStore";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -68,6 +69,7 @@ declare global {
|
||||
mxUserActivity: UserActivity;
|
||||
mxModalWidgetStore: ModalWidgetStore;
|
||||
mxVoipUserMapper: VoipUserMapper;
|
||||
mxSpaceStore: SpaceStoreClass;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
|
||||
@@ -39,6 +39,7 @@ import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||
import LeftPanelWidget from "./LeftPanelWidget";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
@@ -388,12 +389,19 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const groupFilterPanel = !this.state.showGroupFilterPanel ? null : (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
let leftLeftPanel;
|
||||
// Currently TagPanel.enableTagPanel is disabled when Legacy Communities are disabled so for now
|
||||
// ignore it and force the rendering of SpacePanel if that Labs flag is enabled.
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
leftLeftPanel = <SpacePanel />;
|
||||
} else if (this.state.showGroupFilterPanel) {
|
||||
leftLeftPanel = (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
@@ -406,7 +414,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel": true,
|
||||
"mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel,
|
||||
"mx_LeftPanel_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
@@ -417,7 +424,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{groupFilterPanel}
|
||||
{leftLeftPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
|
||||
@@ -24,7 +24,7 @@ import Modal from '../../../Modal';
|
||||
import * as Avatar from '../../../Avatar';
|
||||
import {ResizeMethod} from "../../../Avatar";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick">{
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "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)
|
||||
|
||||
212
src/components/views/spaces/SpacePanel.tsx
Normal file
212
src/components/views/spaces/SpacePanel.tsx
Normal file
@@ -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<IButtonProps> = ({
|
||||
space,
|
||||
className,
|
||||
selected,
|
||||
onClick,
|
||||
tooltip,
|
||||
notificationState,
|
||||
isNarrow,
|
||||
children,
|
||||
}) => {
|
||||
const classes = classNames("mx_SpaceButton", className, {
|
||||
mx_SpaceButton_active: selected,
|
||||
});
|
||||
|
||||
let avatar = <div className="mx_SpaceButton_avatarPlaceholder" />;
|
||||
if (space) {
|
||||
avatar = <RoomAvatar width={32} height={32} room={space} />;
|
||||
}
|
||||
|
||||
let notifBadge;
|
||||
if (notificationState) {
|
||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||
<NotificationBadge forceCount={false} notification={notificationState} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
let button;
|
||||
if (isNarrow) {
|
||||
button = (
|
||||
<RovingAccessibleTooltipButton className={classes} title={tooltip} onClick={onClick} role="treeitem">
|
||||
{ avatar }
|
||||
{ notifBadge }
|
||||
{ children }
|
||||
</RovingAccessibleTooltipButton>
|
||||
);
|
||||
} else {
|
||||
button = (
|
||||
<RovingAccessibleButton className={classes} onClick={onClick} role="treeitem">
|
||||
{ avatar }
|
||||
<span className="mx_SpaceButton_name">{ tooltip }</span>
|
||||
{ notifBadge }
|
||||
{ children }
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return <li className={classNames({
|
||||
"mx_SpaceItem": true,
|
||||
"collapsed": isNarrow,
|
||||
})}>
|
||||
{ button }
|
||||
</li>;
|
||||
}
|
||||
|
||||
const useSpaces = (): [Room[], Room | null] => {
|
||||
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces);
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
|
||||
const [activeSpace, setActiveSpace] = useState<Room>(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 <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
|
||||
{({onKeyDownHandler}) => (
|
||||
<ul
|
||||
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
>
|
||||
<AutoHideScrollbar className="mx_SpacePanel_spaceTreeWrapper">
|
||||
<div className="mx_SpaceTreeLevel">
|
||||
<SpaceButton
|
||||
className="mx_SpaceButton_home"
|
||||
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
||||
selected={!activeSpace}
|
||||
tooltip={_t("Home")}
|
||||
notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)}
|
||||
isNarrow={isPanelCollapsed}
|
||||
/>
|
||||
{ spaces.map(s => <SpaceItem
|
||||
key={s.roomId}
|
||||
space={s}
|
||||
activeSpaces={activeSpaces}
|
||||
isPanelCollapsed={isPanelCollapsed}
|
||||
onExpand={() => setPanelCollapsed(false)}
|
||||
/>) }
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
<AccessibleTooltipButton
|
||||
className={classNames("mx_SpacePanel_toggleCollapse", {expanded: !isPanelCollapsed})}
|
||||
onClick={evt => setPanelCollapsed(!isPanelCollapsed)}
|
||||
title={expandCollapseButtonTitle}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
};
|
||||
|
||||
export default SpacePanel;
|
||||
184
src/components/views/spaces/SpaceTreeLevel.tsx
Normal file
184
src/components/views/spaces/SpaceTreeLevel.tsx
Normal file
@@ -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<DOMRect, "right" | "top" | "height">;
|
||||
}
|
||||
|
||||
export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||
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 ? <SpaceTreeLevel
|
||||
spaces={childSpaces}
|
||||
activeSpaces={activeSpaces}
|
||||
isNested={true}
|
||||
/> : null;
|
||||
let notifBadge;
|
||||
if (notificationState) {
|
||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||
<NotificationBadge forceCount={false} notification={notificationState} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
const avatarSize = isNested ? 24 : 32;
|
||||
|
||||
const toggleCollapseButton = childSpaces && childSpaces.length ?
|
||||
<button
|
||||
className="mx_SpaceButton_toggleCollapse"
|
||||
onClick={evt => this.toggleCollapse(evt)}
|
||||
/> : null;
|
||||
|
||||
let button;
|
||||
if (isNarrow) {
|
||||
button = (
|
||||
<RovingAccessibleTooltipButton
|
||||
className={classes}
|
||||
title={space.name}
|
||||
onClick={this.onClick}
|
||||
onContextMenu={this.onContextMenu}
|
||||
forceHide={!!this.state.contextMenuPosition}
|
||||
role="treeitem"
|
||||
>
|
||||
{ toggleCollapseButton }
|
||||
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
|
||||
{ notifBadge }
|
||||
</RovingAccessibleTooltipButton>
|
||||
);
|
||||
} else {
|
||||
button = (
|
||||
<RovingAccessibleButton
|
||||
className={classes}
|
||||
onClick={this.onClick}
|
||||
onContextMenu={this.onContextMenu}
|
||||
role="treeitem"
|
||||
>
|
||||
{ toggleCollapseButton }
|
||||
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
|
||||
<span className="mx_SpaceButton_name">{ space.name }</span>
|
||||
{ notifBadge }
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={itemClasses}>
|
||||
{ button }
|
||||
{ childItems }
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface ITreeLevelProps {
|
||||
spaces: Room[];
|
||||
activeSpaces: Room[];
|
||||
isNested?: boolean;
|
||||
}
|
||||
|
||||
const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
|
||||
spaces,
|
||||
activeSpaces,
|
||||
isNested,
|
||||
}) => {
|
||||
return <ul className="mx_SpaceTreeLevel">
|
||||
{spaces.map(s => {
|
||||
return (<SpaceItem
|
||||
key={s.roomId}
|
||||
activeSpaces={activeSpaces}
|
||||
space={s}
|
||||
isNested={isNested}
|
||||
/>);
|
||||
})}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
export default SpaceTreeLevel;
|
||||
@@ -978,6 +978,9 @@
|
||||
"From %(deviceName)s (%(deviceId)s)": "From %(deviceName)s (%(deviceId)s)",
|
||||
"Decline (%(counter)s)": "Decline (%(counter)s)",
|
||||
"Accept <policyLink /> to continue:": "Accept <policyLink /> 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 <user />.": "This bridge was provisioned by <user />.",
|
||||
@@ -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",
|
||||
|
||||
462
src/stores/SpaceStore.tsx
Normal file
462
src/stores/SpaceStore.tsx
Normal file
@@ -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<IState> {
|
||||
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<string>();
|
||||
// Map from room ID to set of spaces which list it as a child
|
||||
private parentMap = new EnhancedMap<string, Set<string>>();
|
||||
// Map from space key to SpaceNotificationState instance representing that space
|
||||
private notificationStateMap = new Map<SpaceKey, SpaceNotificationState>();
|
||||
// Map from space key to Set of room IDs that should be shown as part of that space's filter
|
||||
private spaceFilteredRooms = new Map<string | symbol, Set<string>>();
|
||||
// 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<string> => {
|
||||
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<Room>([...visibleRooms, ...spaces]);
|
||||
|
||||
const backrefs = new EnhancedMap<string, Set<string>>();
|
||||
|
||||
// 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<Room>(spaces);
|
||||
|
||||
const markTreeChildren = (rootSpace: Room, unseen: Set<Room>) => {
|
||||
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<string>(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<string>): Set<string> => {
|
||||
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<Record<string, string[]>>(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;
|
||||
82
src/stores/notifications/SpaceNotificationState.ts
Normal file
82
src/stores/notifications/SpaceNotificationState.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IState> {
|
||||
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<IState> {
|
||||
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<IState> {
|
||||
// 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<IState> {
|
||||
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<TagID>();
|
||||
if (this.state.tagsEnabled) {
|
||||
for (const room of rooms) {
|
||||
|
||||
39
src/stores/room-list/SpaceWatcher.ts
Normal file
39
src/stores/room-list/SpaceWatcher.ts
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
69
src/stores/room-list/filters/SpaceFilterCondition.ts
Normal file
69
src/stores/room-list/filters/SpaceFilterCondition.ts
Normal file
@@ -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<Room>();
|
||||
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<void> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user