diff --git a/res/css/_components.scss b/res/css/_components.scss index 674c6487784..346a948abdb 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -112,6 +112,7 @@ @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; +@import "./views/dialogs/_SpacePreferencesDialog.scss"; @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_SpotlightDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 1f2f5f4a58c..547a27ad78b 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -396,6 +396,10 @@ $activeBorderColor: $primary-content; mask-image: url('$(res)/img/element-icons/roomlist/search.svg'); } + .mx_SpacePanel_iconPreferences::before { + mask-image: url('$(res)/img/element-icons/settings/preference.svg'); + } + .mx_SpacePanel_noIcon { display: none; diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index 59aed520fd7..e982b6245fa 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -15,7 +15,10 @@ limitations under the License. */ // Not actually a component but things shared by settings components -.mx_UserSettingsDialog, .mx_RoomSettingsDialog, .mx_SpaceSettingsDialog { +.mx_UserSettingsDialog, +.mx_RoomSettingsDialog, +.mx_SpaceSettingsDialog, +.mx_SpacePreferencesDialog { width: 90vw; max-width: 1000px; // set the height too since tabbed view scrolls itself. diff --git a/res/css/views/dialogs/_SpacePreferencesDialog.scss b/res/css/views/dialogs/_SpacePreferencesDialog.scss new file mode 100644 index 00000000000..370c0f845b1 --- /dev/null +++ b/res/css/views/dialogs/_SpacePreferencesDialog.scss @@ -0,0 +1,34 @@ +/* +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. +*/ + +.mx_SpacePreferencesDialog { + width: 700px; + height: 400px; + + .mx_TabbedView .mx_SettingsTab { + min-width: unset; + + .mx_SettingsTab_section { + font-size: $font-15px; + line-height: $font-24px; + + .mx_Checkbox + p { + color: $secondary-content; + margin: 0 20px 0 24px; + } + } + } +} diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index d143ddfd98c..6c7d6f6597f 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -29,6 +29,7 @@ import { showCreateNewRoom, showCreateNewSubspace, showSpaceInvite, + showSpacePreferences, showSpaceSettings, } from "../../../utils/space"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; @@ -166,6 +167,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = ; } + const onPreferencesClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpacePreferences(space); + onFinished(); + }; + const onExploreRoomsClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -193,6 +202,11 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")} onClick={onExploreRoomsClick} /> + { settingsOption } { leaveOption } { devtoolsOption } diff --git a/src/components/views/dialogs/SpacePreferencesDialog.tsx b/src/components/views/dialogs/SpacePreferencesDialog.tsx new file mode 100644 index 00000000000..dc047245ac4 --- /dev/null +++ b/src/components/views/dialogs/SpacePreferencesDialog.tsx @@ -0,0 +1,99 @@ +/* +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, { ChangeEvent } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { _t, _td } from '../../../languageHandler'; +import BaseDialog from "../dialogs/BaseDialog"; +import { IDialogProps } from "./IDialogProps"; +import TabbedView, { Tab } from "../../structures/TabbedView"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import { useSettingValue } from "../../../hooks/useSettings"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import RoomName from "../elements/RoomName"; + +export enum SpacePreferenceTab { + Appearance = "SPACE_PREFERENCE_APPEARANCE_TAB", +} + +interface IProps extends IDialogProps { + space: Room; + initialTabId?: SpacePreferenceTab; +} + +const SpacePreferencesAppearanceTab = ({ space }: Pick) => { + const showPeople = useSettingValue("Spaces.showPeopleInSpace", space.roomId); + + return ( +
+
{ _t("Sections to show") }
+ +
+ ) => { + SettingsStore.setValue( + "Spaces.showPeopleInSpace", + space.roomId, + SettingLevel.ROOM_ACCOUNT, + !showPeople, + ); + }} + > + { _t("People") } + +

+ { _t("This groups your chats with members of this space. " + + "Turning this off will hide those chats from your view of %(spaceName)s.", { + spaceName: space.name, + }) } +

+
+
+ ); +}; + +const SpacePreferencesDialog: React.FC = ({ space, initialTabId, onFinished }) => { + const tabs = [ + new Tab( + SpacePreferenceTab.Appearance, + _td("Appearance"), + "mx_RoomSettingsDialog_notificationsIcon", + , + ), + ]; + + return ( + +

+ +

+
+ +
+
+ ); +}; + +export default SpacePreferencesDialog; diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index 4cf08ca24e1..e50c12e6fbe 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -52,7 +52,7 @@ export enum UserTab { } interface IProps extends IDialogProps { - initialTabId?: string; + initialTabId?: UserTab; } interface IState { diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 100b1ca4350..6031fcca7e5 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -129,7 +129,7 @@ const NewRoomIntro = () => { let parentSpace: Room; if ( SpaceStore.instance.activeSpaceRoom?.canInvite(cli.getUserId()) && - SpaceStore.instance.getSpaceFilteredRoomIds(SpaceStore.instance.activeSpace).has(room.roomId) + SpaceStore.instance.isRoomInSpace(SpaceStore.instance.activeSpace, room.roomId) ) { parentSpace = SpaceStore.instance.activeSpaceRoom; } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 8e29977786a..c36df0546d2 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -52,6 +52,7 @@ import { SpaceKey, UPDATE_SUGGESTED_ROOMS, UPDATE_SELECTED_SPACE, + isMetaSpace, } from "../../../stores/spaces"; import { shouldShowSpaceInvite, showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -62,6 +63,7 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -493,10 +495,10 @@ export default class RoomList extends React.PureComponent { }; private onExplore = () => { - if (this.props.activeSpace[0] === "!") { + if (!isMetaSpace(this.props.activeSpace)) { defaultDispatcher.dispatch({ action: "view_room", - room_id: SpaceStore.instance.activeSpace, + room_id: this.props.activeSpace, }); } else { const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search; @@ -611,7 +613,12 @@ export default class RoomList extends React.PureComponent { if ( (this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) || (this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) || - (this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) + (this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) || + ( + !isMetaSpace(this.props.activeSpace) && + orderedTagId === DefaultTagID.DM && + !SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace) + ) ) { alwaysVisible = false; } @@ -668,7 +675,7 @@ export default class RoomList extends React.PureComponent { kind="link" onClick={this.onExplore} > - { this.props.activeSpace[0] === "!" ? _t("Explore rooms") : _t("Explore all public rooms") } + { !isMetaSpace(this.props.activeSpace) ? _t("Explore rooms") : _t("Explore all public rooms") } ; } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index e8d090644ac..7a0db1171d7 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -176,7 +176,7 @@ export class SpaceItem extends React.PureComponent { ); this.state = { - collapsed: collapsed, + collapsed, childSpaces: this.childSpaces, }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2851a8ce5f2..5d7d5f0787e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2727,6 +2727,8 @@ "Link to selected message": "Link to selected message", "Link to room": "Link to room", "Command Help": "Command Help", + "Sections to show": "Sections to show", + "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.", "Space settings": "Space settings", "Settings - %(spaceName)s": "Settings - %(spaceName)s", "Spaces you're in": "Spaces you're in", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index cf0776df062..4edc4884d5f 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -872,6 +872,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { [MetaSpace.Home]: true, }, false), }, + "Spaces.showPeopleInSpace": { + supportedLevels: [SettingLevel.ROOM_ACCOUNT], + default: true, + controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", null), + }, "showCommunitiesInsteadOfSpaces": { displayName: _td("Display Communities instead of Spaces"), description: _td("Temporarily show communities instead of Spaces for this session. " + diff --git a/src/settings/WatchManager.ts b/src/settings/WatchManager.ts index 744d75b136f..9eef8a5dbd9 100644 --- a/src/settings/WatchManager.ts +++ b/src/settings/WatchManager.ts @@ -18,7 +18,7 @@ import { SettingLevel } from "./SettingLevel"; export type CallbackFn = (changedInRoomId: string, atLevel: SettingLevel, newValAtLevel: any) => void; -const IRRELEVANT_ROOM = Symbol("irrelevant-room"); +const IRRELEVANT_ROOM: string = null; /** * Generalized management class for dealing with watchers on a per-handler (per-level) diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index e7d6e78206a..d9aa032c4b8 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -17,7 +17,7 @@ limitations under the License. import { RoomListStoreClass } from "./RoomListStore"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import SpaceStore from "../spaces/SpaceStore"; -import { MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; +import { isMetaSpace, MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; /** * Watches for changes in spaces to manage the filter on the provided RoomListStore @@ -66,7 +66,7 @@ export class SpaceWatcher { }; private updateFilter = () => { - if (this.activeSpace[0] === "!") { + if (!isMetaSpace(this.activeSpace)) { SpaceStore.instance.traverseSpace(this.activeSpace, roomId => { this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); }); diff --git a/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts index fd815bf86fc..b2d86b459ac 100644 --- a/src/stores/room-list/filters/SpaceFilterCondition.ts +++ b/src/stores/room-list/filters/SpaceFilterCondition.ts @@ -22,6 +22,7 @@ import { IDestroyable } from "../../../utils/IDestroyable"; import SpaceStore from "../../spaces/SpaceStore"; import { MetaSpace, SpaceKey } from "../../spaces"; import { setHasDiff } from "../../../utils/sets"; +import SettingsStore from "../../../settings/SettingsStore"; /** * A filter condition for the room list which reveals rooms which @@ -31,6 +32,8 @@ import { setHasDiff } from "../../../utils/sets"; */ export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable { private roomIds = new Set(); + private userIds = new Set(); + private showPeopleInSpace = true; private space: SpaceKey = MetaSpace.Home; public get kind(): FilterKind { @@ -38,7 +41,7 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi } public isVisible(room: Room): boolean { - return this.roomIds.has(room.roomId); + return SpaceStore.instance.isRoomInSpace(this.space, room.roomId); } private onStoreUpdate = async (): Promise => { @@ -46,7 +49,18 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi // clone the set as it may be mutated by the space store internally this.roomIds = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(this.space)); - if (setHasDiff(beforeRoomIds, this.roomIds)) { + const beforeUserIds = this.userIds; + // clone the set as it may be mutated by the space store internally + this.userIds = new Set(SpaceStore.instance.getSpaceFilteredUserIds(this.space)); + + const beforeShowPeopleInSpace = this.showPeopleInSpace; + this.showPeopleInSpace = this.space[0] !== "!" || + SettingsStore.getValue("Spaces.showPeopleInSpace", this.space); + + if (beforeShowPeopleInSpace !== this.showPeopleInSpace || + setHasDiff(beforeRoomIds, this.roomIds) || + setHasDiff(beforeUserIds, this.userIds) + ) { this.emit(FILTER_CHANGED); // XXX: Room List Store has a bug where updates to the pre-filter during a local echo of a // tags transition seem to be ignored, so refire in the next tick to work around it diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 13cd8b5b3a0..068d51bacef 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -20,6 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { IRoomCapability } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -32,15 +33,15 @@ 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 { setDiff, setHasDiff } from "../../utils/sets"; import RoomViewStore from "../RoomViewStore"; import { Action } from "../../dispatcher/actions"; import { arrayHasDiff, arrayHasOrderChange } from "../../utils/arrays"; -import { objectDiff } from "../../utils/objects"; import { reorderLexicographically } from "../../utils/stringOrderField"; import { TAG_ORDER } from "../../components/views/rooms/RoomList"; import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload"; import { + isMetaSpace, ISuggestedRoom, MetaSpace, SpaceKey, @@ -51,6 +52,7 @@ import { UPDATE_TOP_LEVEL_SPACES, } from "."; import { getCachedRoomIDForAlias } from "../../RoomAliasCache"; +import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; interface IState {} @@ -93,14 +95,14 @@ const getRoomFn: FetchRoomFn = (room: Room) => { export class SpaceStoreClass extends AsyncStoreWithClient { // 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 SpaceKey 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>(); + private spaceFilteredRooms = new Map>(); // won't contain MetaSpace.People + // Map from space ID to Set of user IDs that should be shown as part of that space's filter + private spaceFilteredUsers = new Map>(); // The space currently selected in the Space Panel private _activeSpace?: SpaceKey = MetaSpace.Home; // set properly by onReady private _suggestedRooms: ISuggestedRoom[] = []; @@ -115,6 +117,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { SettingsStore.monitorSetting("Spaces.allRoomsInHome", null); SettingsStore.monitorSetting("Spaces.enabledMetaSpaces", null); + SettingsStore.monitorSetting("Spaces.showPeopleInSpace", null); } public get invitedSpaces(): Room[] { @@ -134,7 +137,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public get activeSpaceRoom(): Room | null { - if (this._activeSpace[0] !== "!") return null; + if (isMetaSpace(this._activeSpace)) return null; return this.matrixClient?.getRoom(this._activeSpace); } @@ -147,7 +150,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public setActiveRoomInSpace(space: SpaceKey): void { - if (space[0] === "!" && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return; + if (!isMetaSpace(space) && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return; if (space !== this.activeSpace) this.setActiveSpace(space); if (space) { @@ -195,7 +198,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!space || !this.matrixClient || space === this.activeSpace) return; let cliSpace: Room; - if (space[0] === "!") { + if (!isMetaSpace(space)) { cliSpace = this.matrixClient.getRoom(space); if (!cliSpace?.isSpaceRoom()) return; } else if (!this.enabledMetaSpaces.includes(space as MetaSpace)) { @@ -215,7 +218,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // else view space home or home depending on what is being clicked on if (cliSpace?.getMyMembership() !== "invite" && this.matrixClient.getRoom(roomId)?.getMyMembership() === "join" && - this.getSpaceFilteredRoomIds(space).has(roomId) + this.isRoomInSpace(space, roomId) ) { defaultDispatcher.dispatch({ action: "view_room", @@ -349,6 +352,36 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.parentMap.get(roomId) || new Set(); } + public isRoomInSpace(space: SpaceKey, roomId: string): boolean { + if (space === MetaSpace.Home && this.allRoomsInHome) { + return true; + } + + if (this.spaceFilteredRooms.get(space)?.has(roomId)) { + return true; + } + + const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + if (!dmPartner) { + return false; + } + // beyond this point we know this is a DM + + if (space === MetaSpace.Home || space === MetaSpace.People) { + // these spaces contain all DMs + return true; + } + + if (!isMetaSpace(space) && + this.spaceFilteredUsers.get(space)?.has(dmPartner) && + SettingsStore.getValue("Spaces.showPeopleInSpace", space) + ) { + return true; + } + + return false; + } + public getSpaceFilteredRoomIds = (space: SpaceKey): Set => { if (space === MetaSpace.Home && this.allRoomsInHome) { return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); @@ -356,162 +389,147 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.spaceFilteredRooms.get(space) || new Set(); }; - private rebuild = throttle(() => { - if (!this.matrixClient) return; + public getSpaceFilteredUserIds = (space: SpaceKey): Set => { + if (space === MetaSpace.Home && this.allRoomsInHome) { + return undefined; + } + if (isMetaSpace(space)) return undefined; + return this.spaceFilteredUsers.get(space) || new Set(); + }; - const [visibleSpaces, visibleRooms] = partitionSpacesAndRooms(this.matrixClient.getVisibleRooms()); - const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce((arr, s) => { - if (s.getMyMembership() === "join") { - arr[0].push(s); - } else if (s.getMyMembership() === "invite") { - arr[1].push(s); - } - return arr; - }, [[], []]); + private markTreeChildren = (rootSpace: Room, unseen: Set): void => { + const stack = [rootSpace]; + while (stack.length) { + const space = stack.pop(); + unseen.delete(space); + this.getChildSpaces(space.roomId).forEach(space => { + if (unseen.has(space)) { + stack.push(space); + } + }); + } + }; + private findRootSpaces = (joinedSpaces: Room[]): Room[] => { // exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview - const unseenChildren = new Set([...visibleRooms, ...joinedSpaces]); - const backrefs = new EnhancedMap>(); + const unseenSpaces = new Set(joinedSpaces); - // Sort spaces by room ID to force the cycle breaking to be deterministic - const spaces = sortBy(joinedSpaces, space => space.roomId); - - // 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); + joinedSpaces.forEach(space => { + this.getChildSpaces(space.roomId).forEach(subspace => { + unseenSpaces.delete(subspace); }); }); - const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); + // Consider any spaces remaining in unseenSpaces as root, + // given they are not children of any known spaces. + // The hierarchy from these roots may not yet be exhaustive due to the possibility of full-cycles. + const rootSpaces = Array.from(unseenSpaces); - // somewhat 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); - } - }); - } - }; + // Next we need to determine the roots of any remaining full-cycles. + // We sort spaces by room ID to force the cycle breaking to be deterministic. + const detachedNodes = new Set(sortBy(joinedSpaces, space => space.roomId)); + // Mark any nodes which are children of our existing root spaces as attached. rootSpaces.forEach(rootSpace => { - markTreeChildren(rootSpace, detachedNodes); + this.markTreeChildren(rootSpace, detachedNodes); }); // Handle spaces forming fully cyclical relationships. - // In order, assume each detachedNode is a root unless it has already + // In order, assume each remaining 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. + // TODO consider sorting by number of in-refs to favour nodes with fewer parents. Array.from(detachedNodes).forEach(detachedNode => { - if (!detachedNodes.has(detachedNode)) return; + if (!detachedNodes.has(detachedNode)) return; // already claimed, skip // 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 + rootSpaces.push(detachedNode); // consider this node a new root space + this.markTreeChildren(detachedNode, detachedNodes); // declare this node and its children attached }); - // TODO neither of these handle an A->B->C->A with an additional C->D - // detachedNodes.forEach(space => { - // rootSpaces.push(space); - // }); + return rootSpaces; + }; + + private rebuildSpaceHierarchy = () => { + const visibleSpaces = this.matrixClient.getVisibleRooms().filter(r => r.isSpaceRoom()); + const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce(([joined, invited], s) => { + switch (getEffectiveMembership(s.getMyMembership())) { + case EffectiveMembership.Join: + joined.push(s); + break; + case EffectiveMembership.Invite: + invited.push(s); + break; + } + return [joined, invited]; + }, [[], []] as [Room[], Room[]]); - this.orphanedRooms = new Set(orphanedRooms.map(r => r.roomId)); + const rootSpaces = this.findRootSpaces(joinedSpaces); + const oldRootSpaces = this.rootSpaces; this.rootSpaces = this.sortRootSpaces(rootSpaces); - this.parentMap = backrefs; - // if the currently selected space no longer exists, remove its selection - if (this._activeSpace[0] === "!" && detachedNodes.has(this.matrixClient.getRoom(this._activeSpace))) { - this.goToFirstSpace(); - } + this.onRoomsUpdate(); - this.onRoomsUpdate(); // TODO only do this if a change has happened - this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces); + if (arrayHasOrderChange(oldRootSpaces, this.rootSpaces)) { + this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces); + } - // build initial state of invited spaces as we would have missed the emitted events about the room at launch + const oldInvitedSpaces = this._invitedSpaces; this._invitedSpaces = new Set(this.sortRootSpaces(invitedSpaces)); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); - }, 100, { trailing: true, leading: true }); - - private onSpaceUpdate = () => { - this.rebuild(); + if (setHasDiff(oldInvitedSpaces, this._invitedSpaces)) { + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } }; - private showInHomeSpace = (room: Room) => { - if (this.allRoomsInHome) return true; - if (room.isSpaceRoom()) return false; - 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 + private rebuildParentMap = () => { + const joinedSpaces = this.matrixClient.getVisibleRooms().filter(r => { + return r.isSpaceRoom() && r.getMyMembership() === "join"; + }); + + this.parentMap = new EnhancedMap>(); + joinedSpaces.forEach(space => { + const children = this.getChildren(space.roomId); + children.forEach(child => { + this.parentMap.getOrCreate(child.roomId, new Set()).add(space.roomId); + }); + }); }; - // 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) => { - const enabledMetaSpaces = new Set(this.enabledMetaSpaces); - // TODO more metaspace stuffs - if (enabledMetaSpaces.has(MetaSpace.Home)) { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(MetaSpace.Home)?.add(room.roomId); - this.emit(MetaSpace.Home); - } else if (!this.orphanedRooms.has(room.roomId)) { - this.spaceFilteredRooms.get(MetaSpace.Home)?.delete(room.roomId); - this.emit(MetaSpace.Home); - } + private rebuildHomeSpace = () => { + if (this.allRoomsInHome) { + // this is a special-case to not have to maintain a set of all rooms + this.spaceFilteredRooms.delete(MetaSpace.Home); + } else { + const rooms = new Set(this.matrixClient.getVisibleRooms().filter(this.showInHomeSpace).map(r => r.roomId)); + this.spaceFilteredRooms.set(MetaSpace.Home, rooms); } - }; - private onSpaceMembersChange = (ev: MatrixEvent) => { - // skip this update if we do not have a DM with this user - if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return; - this.onRoomsUpdate(); + if (this.activeSpace === MetaSpace.Home) { + this.switchSpaceIfNeeded(); + } }; - private onRoomsUpdate = throttle(() => { - // TODO resolve some updates as deltas + private rebuildMetaSpaces = () => { + const enabledMetaSpaces = new Set(this.enabledMetaSpaces); const visibleRooms = this.matrixClient.getVisibleRooms(); - const oldFilteredRooms = this.spaceFilteredRooms; - this.spaceFilteredRooms = new Map(); - - const enabledMetaSpaces = new Set(this.enabledMetaSpaces); - // populate the Home metaspace if it is enabled and is not set to all rooms - if (enabledMetaSpaces.has(MetaSpace.Home) && !this.allRoomsInHome) { - // put all room invites in the Home Space - const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); - this.spaceFilteredRooms.set(MetaSpace.Home, new Set(invites.map(r => r.roomId))); - - visibleRooms.forEach(room => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(MetaSpace.Home).add(room.roomId); - } - }); + if (enabledMetaSpaces.has(MetaSpace.Home)) { + this.rebuildHomeSpace(); + } else { + this.spaceFilteredRooms.delete(MetaSpace.Home); } - // populate the Favourites metaspace if it is enabled if (enabledMetaSpaces.has(MetaSpace.Favourites)) { const favourites = visibleRooms.filter(r => r.tags[DefaultTagID.Favourite]); this.spaceFilteredRooms.set(MetaSpace.Favourites, new Set(favourites.map(r => r.roomId))); + } else { + this.spaceFilteredRooms.delete(MetaSpace.Favourites); } - // populate the People metaspace if it is enabled - if (enabledMetaSpaces.has(MetaSpace.People)) { - const people = visibleRooms.filter(r => DMRoomMap.shared().getUserIdForRoomId(r.roomId)); - this.spaceFilteredRooms.set(MetaSpace.People, new Set(people.map(r => r.roomId))); - } + // The People metaspace doesn't need maintaining - // populate the Orphans metaspace if it is enabled - if (enabledMetaSpaces.has(MetaSpace.Orphans)) { + // Populate the orphans space if the Home space is enabled as it is a superset of it. + // Home is effectively a super set of People + Orphans with the addition of having all invites too. + if (enabledMetaSpaces.has(MetaSpace.Orphans) || enabledMetaSpaces.has(MetaSpace.Home)) { const orphans = visibleRooms.filter(r => { // filter out DMs and rooms with >0 parents return !this.parentMap.get(r.roomId)?.size && !DMRoomMap.shared().getUserIdForRoomId(r.roomId); @@ -519,6 +537,150 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.spaceFilteredRooms.set(MetaSpace.Orphans, new Set(orphans.map(r => r.roomId))); } + if (isMetaSpace(this.activeSpace)) { + this.switchSpaceIfNeeded(); + } + }; + + private updateNotificationStates = (spaces?: SpaceKey[]) => { + const enabledMetaSpaces = new Set(this.enabledMetaSpaces); + const visibleRooms = this.matrixClient.getVisibleRooms(); + + let dmBadgeSpace: MetaSpace; + // only show badges on dms on the most relevant space if such exists + if (enabledMetaSpaces.has(MetaSpace.People)) { + dmBadgeSpace = MetaSpace.People; + } else if (enabledMetaSpaces.has(MetaSpace.Home)) { + dmBadgeSpace = MetaSpace.Home; + } + + if (!spaces) { + spaces = [...this.spaceFilteredRooms.keys()]; + if (dmBadgeSpace === MetaSpace.People) { + spaces.push(MetaSpace.People); + } + if (enabledMetaSpaces.has(MetaSpace.Home) && !this.allRoomsInHome) { + spaces.push(MetaSpace.Home); + } + } + + spaces.forEach((s) => { + if (this.allRoomsInHome && s === MetaSpace.Home) return; // we'll be using the global notification state, skip + + // Update NotificationStates + this.getNotificationState(s).setRooms(visibleRooms.filter(room => { + if (s === MetaSpace.People) { + return this.isRoomInSpace(MetaSpace.People, room.roomId); + } + + if (room.isSpaceRoom() || !this.spaceFilteredRooms.get(s).has(room.roomId)) return false; + + if (dmBadgeSpace && DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + return s === dmBadgeSpace; + } + + return true; + })); + }); + + if (dmBadgeSpace !== MetaSpace.People) { + this.notificationStateMap.delete(MetaSpace.People); + } + }; + + private showInHomeSpace = (room: Room): boolean => { + if (this.allRoomsInHome) return true; + if (room.isSpaceRoom()) return false; + 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 + room.getMyMembership() === "invite"; // put all invites in the Home Space + }; + + private static isInSpace(member: RoomMember): boolean { + return member.membership === "join" || member.membership === "invite"; + } + + private static getSpaceMembers(space: Room): string[] { + return space.getMembers().filter(SpaceStoreClass.isInSpace).map(m => m.userId); + } + + // Method for resolving the impact of a single user's membership change in the given Space and its hierarchy + private onMemberUpdate = (space: Room, userId: string) => { + const inSpace = SpaceStoreClass.isInSpace(space.getMember(userId)); + + if (this.spaceFilteredUsers.get(space.roomId).has(userId)) { + if (inSpace) return; // nothing to do, user was already joined to subspace + if (this.getChildSpaces(space.roomId).some(s => this.spaceFilteredUsers.get(s.roomId).has(userId))) { + return; // nothing to do, this user leaving will have no effect as they are in a subspace + } + } else if (!inSpace) { + return; // nothing to do, user already not in the list + } + + const seen = new Set(); + const stack = [space.roomId]; + while (stack.length) { + const spaceId = stack.pop(); + seen.add(spaceId); + + if (inSpace) { + // add to our list and to that of all of our parents + this.spaceFilteredUsers.get(spaceId).add(userId); + } else { + // remove from our list and that of all of our parents until we hit a parent with this user + this.spaceFilteredUsers.get(spaceId).delete(userId); + } + + this.getKnownParents(spaceId).forEach(parentId => { + if (seen.has(parentId)) return; + const parent = this.matrixClient.getRoom(parentId); + // because spaceFilteredUsers is cumulative, if we are removing from lower in the hierarchy, + // but the member is present higher in the hierarchy we must take care not to wrongly over-remove them. + if (inSpace || !SpaceStoreClass.isInSpace(parent.getMember(userId))) { + stack.push(parentId); + } + }); + } + + this.switchSpaceIfNeeded(); + }; + + private onMembersUpdate = (space: Room, seen = new Set()) => { + // Update this space's membership list + const userIds = new Set(SpaceStoreClass.getSpaceMembers(space)); + // We only need to look one level with children + // as any further descendants will already be in their parent's superset + this.getChildSpaces(space.roomId).forEach(subspace => { + SpaceStoreClass.getSpaceMembers(subspace).forEach(userId => { + userIds.add(userId); + }); + }); + this.spaceFilteredUsers.set(space.roomId, userIds); + this.emit(space.roomId); + + // Traverse all parents and update them too + this.getKnownParents(space.roomId).forEach(parentId => { + if (seen.has(parentId)) return; + const parent = this.matrixClient.getRoom(parentId); + if (parent) { + const newSeen = new Set(seen); + newSeen.add(parentId); + this.onMembersUpdate(parent, newSeen); + } + }); + }; + + private onRoomsUpdate = () => { + const visibleRooms = this.matrixClient.getVisibleRooms(); + + const oldFilteredRooms = this.spaceFilteredRooms; + const oldFilteredUsers = this.spaceFilteredUsers; + this.spaceFilteredRooms = new Map(); + this.spaceFilteredUsers = new Map(); + + this.rebuildParentMap(); + this.rebuildMetaSpaces(); + const hiddenChildren = new EnhancedMap>(); visibleRooms.forEach(room => { if (room.getMyMembership() !== "join") return; @@ -530,31 +692,26 @@ export class SpaceStoreClass extends AsyncStoreWithClient { 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 => { + const fn = (spaceId: string, parentPath: Set): [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); + if (this.spaceFilteredRooms.has(spaceId) && this.spaceFilteredUsers.has(spaceId)) { + return [this.spaceFilteredRooms.get(spaceId), this.spaceFilteredUsers.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?.getMembers().forEach(member => { - if (member.membership !== "join" && member.membership !== "invite") return; - DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => { - roomIds.add(roomId); - }); - }); + const userIds = new Set(space?.getMembers().filter(m => { + return m.membership === "join" || m.membership === "invite"; + }).map(m => m.userId)); const newPath = new Set(parentPath).add(spaceId); childSpaces.forEach(childSpace => { - fn(childSpace.roomId, newPath)?.forEach(roomId => { - roomIds.add(roomId); - }); + const [rooms, users] = fn(childSpace.roomId, newPath) ?? []; + rooms?.forEach(roomId => roomIds.add(roomId)); + users?.forEach(userId => userIds.add(userId)); }); hiddenChildren.get(spaceId)?.forEach(roomId => { roomIds.add(roomId); @@ -565,42 +722,59 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.matrixClient.getRoomUpgradeHistory(roomId, true).map(r => r.roomId); })); this.spaceFilteredRooms.set(spaceId, expandedRoomIds); - return expandedRoomIds; + this.spaceFilteredUsers.set(spaceId, userIds); + return [expandedRoomIds, userIds]; }; fn(s.roomId, new Set()); }); - const diff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms); + const roomDiff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms); + const userDiff = mapDiff(oldFilteredUsers, this.spaceFilteredUsers); // 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 => { + const roomsChanged = roomDiff.changed.filter(k => { + return setHasDiff(oldFilteredRooms.get(k), this.spaceFilteredRooms.get(k)); + }); + const usersChanged = userDiff.changed.filter(k => { + return setHasDiff(oldFilteredUsers.get(k), this.spaceFilteredUsers.get(k)); + }); + + const changeSet = new Set([ + ...roomDiff.added, + ...userDiff.added, + ...roomDiff.removed, + ...userDiff.removed, + ...roomsChanged, + ...usersChanged, + ]); + + changeSet.forEach(k => { this.emit(k); }); - let dmBadgeSpace: MetaSpace; - // only show badges on dms on the most relevant space if such exists - if (enabledMetaSpaces.has(MetaSpace.People)) { - dmBadgeSpace = MetaSpace.People; - } else if (enabledMetaSpaces.has(MetaSpace.Home)) { - dmBadgeSpace = MetaSpace.Home; + if (changeSet.has(this.activeSpace)) { + this.switchSpaceIfNeeded(); } - this.spaceFilteredRooms.forEach((roomIds, s) => { - if (this.allRoomsInHome && s === MetaSpace.Home) return; // we'll be using the global notification state, skip + const notificationStatesToUpdate = [...changeSet]; + if (this.enabledMetaSpaces.includes(MetaSpace.People) && + userDiff.added.length + userDiff.removed.length + usersChanged.length > 0 + ) { + notificationStatesToUpdate.push(MetaSpace.People); + } + this.updateNotificationStates(notificationStatesToUpdate); + }; - // Update NotificationStates - this.getNotificationState(s).setRooms(visibleRooms.filter(room => { - if (!roomIds.has(room.roomId) || room.isSpaceRoom()) return false; + private switchSpaceIfNeeded = throttle(() => { + const roomId = RoomViewStore.getRoomId(); + if (this.isRoomInSpace(this.activeSpace, roomId)) return; - if (dmBadgeSpace && DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { - return s === dmBadgeSpace; - } - - return true; - })); - }); - }, 100, { trailing: true, leading: true }); + if (this.matrixClient.getRoom(roomId)?.isSpaceRoom()) { + this.goToFirstSpace(true); + } else { + this.switchToRelatedSpace(roomId); + } + }, 100, { leading: true, trailing: true }); private switchToRelatedSpace = (roomId: string) => { if (this.suggestedRooms.find(r => r.room_id === roomId)) return; @@ -616,11 +790,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // otherwise, try to find a metaspace which contains this room if (!parent) { // search meta spaces in reverse as Home is the first and least specific one - parent = [...this.enabledMetaSpaces].reverse().find(s => this.getSpaceFilteredRoomIds(s).has(roomId)); + parent = [...this.enabledMetaSpaces].reverse().find(s => this.isRoomInSpace(s, roomId)); } // don't trigger a context switch when we are switching a space to match the chosen room - this.setActiveSpace(parent ?? MetaSpace.Home, false); // TODO + if (parent) { + this.setActiveSpace(parent, false); + } else { + this.goToFirstSpace(); + } }; private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { @@ -632,10 +810,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const membership = newMembership || roomMembership; if (!room.isSpaceRoom()) { - // this.onRoomUpdate(room); - // this.onRoomsUpdate(); - // ideally we only need onRoomsUpdate here but it doesn't rebuild parentMap so always adds new rooms to Home - this.rebuild(); + this.onRoomsUpdate(); if (membership === "join") { // the user just joined a room, remove it from the suggested list if it was there @@ -655,13 +830,21 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Space if (membership === "invite") { + const len = this._invitedSpaces.size; this._invitedSpaces.add(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + if (len !== this._invitedSpaces.size) { + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } } else if (oldMembership === "invite" && membership !== "join") { - this._invitedSpaces.delete(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + if (this._invitedSpaces.delete(room)) { + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } } else { - this.onSpaceUpdate(); + this.rebuildSpaceHierarchy(); + // fire off updates to all parent listeners + this.parentMap.get(room.roomId)?.forEach((parentId) => { + this.emit(parentId); + }); this.emit(room.roomId); } @@ -687,28 +870,36 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!room) return; switch (ev.getType()) { - case EventType.SpaceChild: + case EventType.SpaceChild: { + const target = this.matrixClient.getRoom(ev.getStateKey()); + if (room.isSpaceRoom()) { - this.onSpaceUpdate(); + if (target?.isSpaceRoom()) { + this.rebuildSpaceHierarchy(); + this.emit(target.roomId); + } else { + this.onRoomsUpdate(); + } this.emit(room.roomId); } if (room.roomId === this.activeSpace && // current space - this.matrixClient.getRoom(ev.getStateKey())?.getMyMembership() !== "join" && // target not joined + target?.getMyMembership() !== "join" && // target not joined ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed ) { this.loadSuggestedRooms(room); } break; + } case 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 if (!this.allRoomsInHome) { - this.onRoomUpdate(room); + this.rebuildSpaceHierarchy(); + } else { + this.onRoomsUpdate(); } this.emit(room.roomId); break; @@ -724,8 +915,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then private onRoomStateMembers = (ev: MatrixEvent) => { const room = this.matrixClient.getRoom(ev.getRoomId()); - if (room?.isSpaceRoom()) { - this.onSpaceMembersChange(ev); + const userId = ev.getStateKey(); + if (room?.isSpaceRoom() && // only consider space rooms + DMRoomMap.shared().getDMRoomsForUserId(userId).length > 0 && // only consider members we have a DM with + ev.getPrevContent().membership !== ev.getContent().membership // only consider when membership changes + ) { + this.onMemberUpdate(room, userId); } }; @@ -744,35 +939,73 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldTags = lastEv?.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {}; if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) { - this.onRoomUpdate(room); + this.onRoomFavouriteChange(room); } } }; - private onAccountData = (ev: MatrixEvent, prevEvent?: MatrixEvent) => { - if (!this.allRoomsInHome && ev.getType() === EventType.Direct) { - const lastContent = prevEvent?.getContent() ?? {}; - const content = ev.getContent(); + private onRoomFavouriteChange(room: Room) { + if (this.enabledMetaSpaces.includes(MetaSpace.Favourites)) { + if (room.tags[DefaultTagID.Favourite]) { + this.spaceFilteredRooms.get(MetaSpace.Favourites).add(room.roomId); + } else { + this.spaceFilteredRooms.get(MetaSpace.Favourites).delete(room.roomId); + } + this.emit(MetaSpace.Favourites); + } + } + + private onRoomDmChange(room: Room, isDm: boolean): void { + const enabledMetaSpaces = new Set(this.enabledMetaSpaces); - 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 => { + if (!this.allRoomsInHome && enabledMetaSpaces.has(MetaSpace.Home)) { + const homeRooms = this.spaceFilteredRooms.get(MetaSpace.Home); + if (this.showInHomeSpace(room)) { + homeRooms?.add(room.roomId); + } else if (!this.spaceFilteredRooms.get(MetaSpace.Orphans).has(room.roomId)) { + this.spaceFilteredRooms.get(MetaSpace.Home)?.delete(room.roomId); + } + + this.emit(MetaSpace.Home); + } + + if (enabledMetaSpaces.has(MetaSpace.People)) { + this.emit(MetaSpace.People); + } + + if (enabledMetaSpaces.has(MetaSpace.Orphans) || enabledMetaSpaces.has(MetaSpace.Home)) { + if (isDm && this.spaceFilteredRooms.get(MetaSpace.Orphans).delete(room.roomId)) { + this.emit(MetaSpace.Orphans); + this.emit(MetaSpace.Home); + } + } + } + + private onAccountData = (ev: MatrixEvent, prevEv?: MatrixEvent) => { + if (ev.getType() === EventType.Direct) { + const previousRooms = new Set(Object.values(prevEv?.getContent>() ?? {}).flat()); + const currentRooms = new Set(Object.values(ev.getContent>()).flat()); + + const diff = setDiff(previousRooms, currentRooms); + [...diff.added, ...diff.removed].forEach(roomId => { const room = this.matrixClient?.getRoom(roomId); if (room) { - this.onRoomUpdate(room); + this.onRoomDmChange(room, currentRooms.has(roomId)); } }); + + if (diff.removed.length > 0) { + this.switchSpaceIfNeeded(); + } } }; protected async reset() { this.rootSpaces = []; - this.orphanedRooms = new Set(); this.parentMap = new EnhancedMap(); this.notificationStateMap = new Map(); this.spaceFilteredRooms = new Map(); + this.spaceFilteredUsers = new Map(); this._activeSpace = MetaSpace.Home; // set properly by onReady this._suggestedRooms = []; this._invitedSpaces = new Set(); @@ -809,17 +1042,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces"); this._enabledMetaSpaces = metaSpaceOrder.filter(k => enabledMetaSpaces[k]) as MetaSpace[]; - await this.onSpaceUpdate(); // trigger an initial update + this.rebuildSpaceHierarchy(); // 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 && ( - lastSpaceId[0] === "!" ? this.matrixClient.getRoom(lastSpaceId) : enabledMetaSpaces[lastSpaceId] - )) { + const valid = (lastSpaceId && !isMetaSpace(lastSpaceId)) + ? this.matrixClient.getRoom(lastSpaceId) + : enabledMetaSpaces[lastSpaceId]; + if (valid) { // don't context switch here as it may break permalinks this.setActiveSpace(lastSpaceId, false); } else { - this.goToFirstSpace(); + this.switchSpaceIfNeeded(); } } @@ -828,7 +1062,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } protected async onAction(payload: ActionPayload) { - if (!spacesEnabled) return; + if (!spacesEnabled || !this.matrixClient) return; + switch (payload.action) { case "view_room": { // Don't auto-switch rooms when reacting to a context-switch @@ -842,12 +1077,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!roomId) return; // we'll get re-fired with the room ID shortly - const room = this.matrixClient?.getRoom(roomId); + const room = this.matrixClient.getRoom(roomId); if (room?.isSpaceRoom()) { // Don't context switch when navigating to the space room // as it will cause you to end up in the wrong room this.setActiveSpace(room.roomId, false); - } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) { + } else if (!this.isRoomInSpace(this.activeSpace, roomId)) { this.switchToRelatedSpace(roomId); } @@ -866,9 +1101,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { break; case "after_leave_room": - if (this._activeSpace[0] === "!" && payload.room_id === this._activeSpace) { + if (!isMetaSpace(this._activeSpace) && payload.room_id === this._activeSpace) { // User has left the current space, go to first space - this.goToFirstSpace(); + this.goToFirstSpace(true); } break; @@ -892,7 +1127,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (this.allRoomsInHome !== newValue) { this._allRoomsInHome = newValue; this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome); - this.rebuild(); // rebuild everything + if (this.enabledMetaSpaces.includes(MetaSpace.Home)) { + this.rebuildHomeSpace(); + } } break; } @@ -901,18 +1138,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const newValue = SettingsStore.getValue("Spaces.enabledMetaSpaces"); const enabledMetaSpaces = metaSpaceOrder.filter(k => newValue[k]) as MetaSpace[]; if (arrayHasDiff(this._enabledMetaSpaces, enabledMetaSpaces)) { + const hadPeopleOrHomeEnabled = this.enabledMetaSpaces.some(s => { + return s === MetaSpace.Home || s === MetaSpace.People; + }); this._enabledMetaSpaces = enabledMetaSpaces; - // if a metaspace currently being viewed was remove, go to another one - if (this.activeSpace[0] !== "!" && - !enabledMetaSpaces.includes(this.activeSpace as MetaSpace) - ) { - this.goToFirstSpace(); + const hasPeopleOrHomeEnabled = this.enabledMetaSpaces.some(s => { + return s === MetaSpace.Home || s === MetaSpace.People; + }); + + // if a metaspace currently being viewed was removed, go to another one + if (isMetaSpace(this.activeSpace) && !newValue[this.activeSpace]) { + this.switchSpaceIfNeeded(); } + this.rebuildMetaSpaces(); + + if (hadPeopleOrHomeEnabled !== hasPeopleOrHomeEnabled) { + // in this case we have to rebuild everything as DM badges will move to/from real spaces + this.updateNotificationStates(); + } else { + this.updateNotificationStates(enabledMetaSpaces); + } + this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces); - this.rebuild(); // rebuild everything } break; } + + case "Spaces.showPeopleInSpace": + // getSpaceFilteredUserIds will return the appropriate value + this.emit(settingUpdatedPayload.roomId); + if (!this.enabledMetaSpaces.some(s => s === MetaSpace.Home || s === MetaSpace.People)) { + this.updateNotificationStates([settingUpdatedPayload.roomId]); + } + break; } } } diff --git a/src/stores/spaces/index.ts b/src/stores/spaces/index.ts index 7272cd6095b..f4bba0621b6 100644 --- a/src/stores/spaces/index.ts +++ b/src/stores/spaces/index.ts @@ -53,3 +53,10 @@ export type SpaceKey = MetaSpace | Room["roomId"]; export interface ISuggestedRoom extends IHierarchyRoom { viaServers: string[]; } + +export function isMetaSpace(spaceKey: SpaceKey): boolean { + return spaceKey === MetaSpace.Home || + spaceKey === MetaSpace.Favourites || + spaceKey === MetaSpace.People || + spaceKey === MetaSpace.Orphans; +} diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 6181d4e875b..a0fddde45cd 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -184,6 +184,8 @@ export function arrayHasDiff(a: any[], b: any[]): boolean { } } +export type Diff = { added: T[], removed: T[] }; + /** * Performs a diff on two arrays. The result is what is different with the * first array (`added` in the returned object means objects in B that aren't @@ -192,7 +194,7 @@ export function arrayHasDiff(a: any[], b: any[]): boolean { * @param b The second array. Must be defined. * @returns The diff between the arrays. */ -export function arrayDiff(a: T[], b: T[]): { added: T[], removed: T[] } { +export function arrayDiff(a: T[], b: T[]): Diff { return { added: b.filter(i => !a.includes(i)), removed: a.filter(i => !b.includes(i)), diff --git a/src/utils/sets.ts b/src/utils/sets.ts index e5427b2e94b..da856af2b5f 100644 --- a/src/utils/sets.ts +++ b/src/utils/sets.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { arrayDiff, Diff } from "./arrays"; + /** * Determines if two sets are different through a shallow comparison. * @param a The first set. Must be defined. @@ -32,3 +34,13 @@ export function setHasDiff(a: Set, b: Set): boolean { return true; // different lengths means they are naturally diverged } } + +/** + * Determines the values added and removed between two sets. + * @param a The first set. Must be defined. + * @param b The second set. Must be defined. + * @returns The difference between the values in each set. + */ +export function setDiff(a: Set, b: Set): Diff { + return arrayDiff(Array.from(a), Array.from(b)); +} diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 8642d423209..a88d621bc60 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -40,6 +40,7 @@ import Spinner from "../components/views/elements/Spinner"; import dis from "../dispatcher/dispatcher"; import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog"; import CreateSpaceFromCommunityDialog from "../components/views/dialogs/CreateSpaceFromCommunityDialog"; +import SpacePreferencesDialog, { SpacePreferenceTab } from "../components/views/dialogs/SpacePreferencesDialog"; export const shouldShowSpaceSettings = (space: Room) => { const userId = space.client.getUserId(); @@ -197,3 +198,10 @@ export const createSpaceFromCommunity = (cli: MatrixClient, groupId: string): Pr groupId, }, "mx_CreateSpaceFromCommunityDialog_wrapper").finished as Promise<[string?]>; }; + +export const showSpacePreferences = (space: Room, initialTabId?: SpacePreferenceTab): Promise => { + return Modal.createTrackedDialog("Space preferences", "", SpacePreferencesDialog, { + initialTabId, + space, + }, null, false, true).finished; +}; diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index 228d7da0597..52aff5b3815 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -40,11 +39,6 @@ jest.useFakeTimers(); const testUserId = "@test:user"; -const getUserIdForRoomId = jest.fn(); -const getDMRoomsForUserId = jest.fn(); -// @ts-ignore -DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId }; - const fav1 = "!fav1:server"; const fav2 = "!fav2:server"; const fav3 = "!fav3:server"; @@ -68,6 +62,28 @@ const space1 = "!space1:server"; const space2 = "!space2:server"; const space3 = "!space3:server"; +const getUserIdForRoomId = jest.fn(roomId => { + return { + [dm1]: dm1Partner.userId, + [dm2]: dm2Partner.userId, + [dm3]: dm3Partner.userId, + }[roomId]; +}); +const getDMRoomsForUserId = jest.fn(userId => { + switch (userId) { + case dm1Partner.userId: + return [dm1]; + case dm2Partner.userId: + return [dm2]; + case dm3Partner.userId: + return [dm3]; + default: + return []; + } +}); +// @ts-ignore +DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId }; + describe("SpaceStore", () => { stubClient(); const store = SpaceStore.instance; @@ -306,26 +322,6 @@ describe("SpaceStore", () => { client.getRoom(roomId).getMyMembership.mockReturnValue("invite"); }); - getUserIdForRoomId.mockImplementation(roomId => { - return { - [dm1]: dm1Partner.userId, - [dm2]: dm2Partner.userId, - [dm3]: dm3Partner.userId, - }[roomId]; - }); - getDMRoomsForUserId.mockImplementation(userId => { - switch (userId) { - case dm1Partner.userId: - return [dm1]; - case dm2Partner.userId: - return [dm2]; - case dm3Partner.userId: - return [dm3]; - default: - return []; - } - }); - // have dmPartner1 be in space1 with you const mySpace1Member = new RoomMember(space1, testUserId); mySpace1Member.membership = "join"; @@ -388,103 +384,104 @@ describe("SpaceStore", () => { }); it("home space contains orphaned rooms", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(orphan1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(orphan2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, orphan1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, orphan2)).toBeTruthy(); }); it("home space does not contain all favourites", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(fav1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(fav2)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(fav3)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav1)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav2)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav3)).toBeFalsy(); }); it("home space contains dm rooms", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(dm1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(dm2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(dm3)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm3)).toBeTruthy(); }); it("home space contains invites", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeTruthy(); }); it("home space contains invites even if they are also shown in a space", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite2)).toBeTruthy(); }); it("all rooms space does contain rooms/low priority even if they are also shown in a space", async () => { await setShowAllRooms(true); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(room1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeTruthy(); }); it("favourites space does contain favourites even if they are also shown in a space", async () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Favourites).has(fav1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Favourites).has(fav2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Favourites).has(fav3)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav3)).toBeTruthy(); }); it("people space does contain people even if they are also shown in a space", async () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.People).has(dm1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.People).has(dm2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.People).has(dm3)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm3)).toBeTruthy(); }); it("orphans space does contain orphans even if they are also shown in all rooms", async () => { await setShowAllRooms(true); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Orphans).has(orphan1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Orphans).has(orphan2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Orphans, orphan1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Orphans, orphan2)).toBeTruthy(); }); it("home space doesn't contain rooms/low priority if they are also shown in a space", async () => { await setShowAllRooms(false); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(room1)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeFalsy(); }); it("space contains child rooms", () => { - expect(store.getSpaceFilteredRoomIds(space1).has(fav1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space1).has(room1)).toBeTruthy(); + expect(store.isRoomInSpace(space1, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space1, room1)).toBeTruthy(); }); it("space contains child favourites", () => { - expect(store.getSpaceFilteredRoomIds(space2).has(fav1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(fav2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(fav3)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(room1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav2)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav3)).toBeTruthy(); + expect(store.isRoomInSpace(space2, room1)).toBeTruthy(); }); it("space contains child invites", () => { - expect(store.getSpaceFilteredRoomIds(space3).has(invite2)).toBeTruthy(); + expect(store.isRoomInSpace(space3, invite2)).toBeTruthy(); }); it("spaces contain dms which you have with members of that space", () => { - expect(store.getSpaceFilteredRoomIds(space1).has(dm1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(dm1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space3).has(dm1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space1).has(dm2)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space2).has(dm2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space3).has(dm2)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space1).has(dm3)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space2).has(dm3)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space3).has(dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, dm1)).toBeFalsy(); + expect(store.isRoomInSpace(space3, dm1)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm2)).toBeFalsy(); + expect(store.isRoomInSpace(space2, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(space3, dm2)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space2, dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space3, dm3)).toBeFalsy(); }); - it("dms are only added to Notification States for only the Home Space", () => { - // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better - // [dm1, dm2, dm3].forEach(d => { - // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(d)).toBeTruthy(); - // }); - [space1, space2, space3].forEach(s => { + it("dms are only added to Notification States for only the People Space", async () => { + [dm1, dm2, dm3].forEach(d => { + expect(store.getNotificationState(MetaSpace.People) + .rooms.map(r => r.roomId).includes(d)).toBeTruthy(); + }); + [space1, space2, space3, MetaSpace.Home, MetaSpace.Orphans, MetaSpace.Favourites].forEach(s => { [dm1, dm2, dm3].forEach(d => { expect(store.getNotificationState(s).rooms.map(r => r.roomId).includes(d)).toBeFalsy(); }); }); }); - it("orphan rooms are added to Notification States for only the Home Space", () => { - // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better - // [orphan1, orphan2].forEach(d => { - // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(d)).toBeTruthy(); - // }); + it("orphan rooms are added to Notification States for only the Home Space", async () => { + await setShowAllRooms(false); + [orphan1, orphan2].forEach(d => { + expect(store.getNotificationState(MetaSpace.Home) + .rooms.map(r => r.roomId).includes(d)).toBeTruthy(); + }); [space1, space2, space3].forEach(s => { [orphan1, orphan2].forEach(d => { expect(store.getNotificationState(s).rooms.map(r => r.roomId).includes(d)).toBeFalsy(); @@ -517,33 +514,22 @@ describe("SpaceStore", () => { }); it("honours m.space.parent if sender has permission in parent space", () => { - expect(store.getSpaceFilteredRoomIds(space2).has(room2)).toBeTruthy(); + expect(store.isRoomInSpace(space2, room2)).toBeTruthy(); }); it("does not honour m.space.parent if sender does not have permission in parent space", () => { - expect(store.getSpaceFilteredRoomIds(space3).has(room3)).toBeFalsy(); + expect(store.isRoomInSpace(space3, room3)).toBeFalsy(); }); }); }); describe("hierarchy resolution update tests", () => { - let emitter: EventEmitter; - beforeEach(async () => { - emitter = new EventEmitter(); - client.on.mockImplementation(emitter.on.bind(emitter)); - client.removeListener.mockImplementation(emitter.removeListener.bind(emitter)); - }); - afterEach(() => { - client.on.mockReset(); - client.removeListener.mockReset(); - }); - it("updates state when spaces are joined", async () => { await run(); expect(store.spacePanelSpaces).toStrictEqual([]); const space = mkSpace(space1); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); - emitter.emit("Room", space); + client.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([]); @@ -556,7 +542,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); - emitter.emit("Room.myMembership", space, "leave", "join"); + client.emit("Room.myMembership", space, "leave", "join"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); }); @@ -568,7 +554,7 @@ describe("SpaceStore", () => { const space = mkSpace(space1); space.getMyMembership.mockReturnValue("invite"); const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); - emitter.emit("Room", space); + client.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([space]); @@ -583,7 +569,7 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("join"); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); - emitter.emit("Room.myMembership", space, "join", "invite"); + client.emit("Room.myMembership", space, "join", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([]); @@ -598,7 +584,7 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); - emitter.emit("Room.myMembership", space, "leave", "invite"); + client.emit("Room.myMembership", space, "leave", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([]); @@ -612,21 +598,21 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([]); expect(store.getChildSpaces(space1)).toStrictEqual([]); expect(store.getChildRooms(space1)).toStrictEqual([]); - expect(store.getSpaceFilteredRoomIds(space1).has(invite1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite1)).toBeFalsy(); + expect(store.isRoomInSpace(space1, invite1)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeFalsy(); const invite = mkRoom(invite1); invite.getMyMembership.mockReturnValue("invite"); const prom = testUtils.emitPromise(store, space1); - emitter.emit("Room", space); + client.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([]); expect(store.getChildSpaces(space1)).toStrictEqual([]); expect(store.getChildRooms(space1)).toStrictEqual([invite]); - expect(store.getSpaceFilteredRoomIds(space1).has(invite1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite1)).toBeTruthy(); + expect(store.isRoomInSpace(space1, invite1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeTruthy(); }); }); @@ -817,7 +803,7 @@ describe("SpaceStore", () => { expect(store.activeSpace).toBe(MetaSpace.Orphans); }); - it("switch to first space when selected metaspace is disabled", async () => { + it("switch to first valid space when selected metaspace is disabled", async () => { store.setActiveSpace(MetaSpace.People, false); expect(store.activeSpace).toBe(MetaSpace.People); await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, { @@ -827,7 +813,7 @@ describe("SpaceStore", () => { [MetaSpace.Orphans]: true, }); jest.runAllTimers(); - expect(store.activeSpace).toBe(MetaSpace.Favourites); + expect(store.activeSpace).toBe(MetaSpace.Orphans); }); it("when switching rooms in the all rooms home space don't switch to related space", async () => { @@ -889,4 +875,105 @@ describe("SpaceStore", () => { expect(fn).toBeCalledWith("!c:server"); }); }); + + it("test user flow", async () => { + // init the store + await run(); + await setShowAllRooms(false); + + // receive invite to space + const rootSpace = mkSpace(space1, [room1, room2, space2]); + rootSpace.getMyMembership.mockReturnValue("invite"); + client.emit("Room", rootSpace); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([]); + + // accept invite to space + rootSpace.getMyMembership.mockReturnValue("join"); + client.emit("Room.myMembership", rootSpace, "join", "invite"); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + + // join room in space + expect(SpaceStore.instance.isRoomInSpace(space1, room1)).toBeFalsy(); + const rootSpaceRoom1 = mkRoom(room1); + rootSpaceRoom1.getMyMembership.mockReturnValue("join"); + client.emit("Room", rootSpaceRoom1); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.isRoomInSpace(space1, room1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Home, room1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Favourites, room1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.People, room1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Orphans, room1)).toBeFalsy(); + + // receive room invite + expect(SpaceStore.instance.isRoomInSpace(space1, room2)).toBeFalsy(); + const rootSpaceRoom2 = mkRoom(room2); + rootSpaceRoom2.getMyMembership.mockReturnValue("invite"); + client.emit("Room", rootSpaceRoom2); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.isRoomInSpace(space1, room2)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Home, room2)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Favourites, room2)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.People, room2)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Orphans, room2)).toBeFalsy(); + + // start DM in space + const myRootSpaceMember = new RoomMember(space1, testUserId); + myRootSpaceMember.membership = "join"; + const rootSpaceFriend = new RoomMember(space1, dm1Partner.userId); + rootSpaceFriend.membership = "join"; + rootSpace.getMembers.mockReturnValue([ + myRootSpaceMember, + rootSpaceFriend, + ]); + rootSpace.getMember.mockImplementation(userId => { + switch (userId) { + case testUserId: + return myRootSpaceMember; + case dm1Partner.userId: + return rootSpaceFriend; + } + }); + expect(SpaceStore.instance.getSpaceFilteredUserIds(space1).has(dm1Partner.userId)).toBeFalsy(); + client.emit("RoomState.members", mkEvent({ + event: true, + type: EventType.RoomMember, + content: { + membership: "join", + }, + skey: dm1Partner.userId, + user: dm1Partner.userId, + room: space1, + })); + jest.runAllTimers(); + expect(SpaceStore.instance.getSpaceFilteredUserIds(space1).has(dm1Partner.userId)).toBeTruthy(); + const dm1Room = mkRoom(dm1); + dm1Room.getMyMembership.mockReturnValue("join"); + client.emit("Room", dm1Room); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.isRoomInSpace(space1, dm1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Home, dm1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Favourites, dm1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Orphans, dm1)).toBeFalsy(); + + // join subspace + const subspace = mkSpace(space2); + subspace.getMyMembership.mockReturnValue("join"); + const prom = testUtils.emitPromise(SpaceStore.instance, space1); + client.emit("Room", subspace); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces.map(r => r.roomId)).toStrictEqual([rootSpace.roomId]); + await prom; + }); }); diff --git a/test/test-utils.js b/test/test-utils.js index d34385c7de9..0b9bbd642c8 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -1,4 +1,5 @@ import React from 'react'; +import EventEmitter from "events"; import ShallowRenderer from 'react-test-renderer/shallow'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -43,6 +44,8 @@ export function stubClient() { * @returns {object} MatrixClient stub */ export function createTestClient() { + const eventEmitter = new EventEmitter(); + return { getHomeserverUrl: jest.fn(), getIdentityServerUrl: jest.fn(), @@ -57,8 +60,9 @@ export function createTestClient() { getVisibleRooms: jest.fn().mockReturnValue([]), getGroups: jest.fn().mockReturnValue([]), loginFlows: jest.fn(), - on: jest.fn(), - removeListener: jest.fn(), + on: eventEmitter.on.bind(eventEmitter), + emit: eventEmitter.emit.bind(eventEmitter), + removeListener: eventEmitter.removeListener.bind(eventEmitter), isRoomEncrypted: jest.fn().mockReturnValue(false), peekInRoom: jest.fn().mockResolvedValue(mkStubRoom()),