Space preferences for whether or not you see DMs in a Space (#7250)

This commit is contained in:
Michael Telatynski 2021-12-17 09:26:32 +00:00 committed by GitHub
parent 5ee356daaa
commit 39c4b78371
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 911 additions and 350 deletions

View file

@ -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";

View file

@ -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;

View file

@ -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.

View file

@ -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;
}
}
}
}

View file

@ -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}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPreferences"
label={_t("Preferences")}
onClick={onPreferencesClick}
/>
{ settingsOption }
{ leaveOption }
{ devtoolsOption }

View file

@ -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<IProps, "space">) => {
const showPeople = useSettingValue("Spaces.showPeopleInSpace", space.roomId);
return (
<div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Sections to show") }</div>
<div className="mx_SettingsTab_section">
<StyledCheckbox
checked={!!showPeople}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
SettingsStore.setValue(
"Spaces.showPeopleInSpace",
space.roomId,
SettingLevel.ROOM_ACCOUNT,
!showPeople,
);
}}
>
{ _t("People") }
</StyledCheckbox>
<p>
{ _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,
}) }
</p>
</div>
</div>
);
};
const SpacePreferencesDialog: React.FC<IProps> = ({ space, initialTabId, onFinished }) => {
const tabs = [
new Tab(
SpacePreferenceTab.Appearance,
_td("Appearance"),
"mx_RoomSettingsDialog_notificationsIcon",
<SpacePreferencesAppearanceTab space={space} />,
),
];
return (
<BaseDialog
className="mx_SpacePreferencesDialog"
hasCancel
onFinished={onFinished}
title={_t("Preferences")}
fixedWidth={false}
>
<h4>
<RoomName room={space} />
</h4>
<div className="mx_SettingsDialog_content">
<TabbedView tabs={tabs} initialTabId={initialTabId} />
</div>
</BaseDialog>
);
};
export default SpacePreferencesDialog;

View file

@ -52,7 +52,7 @@ export enum UserTab {
}
interface IProps extends IDialogProps {
initialTabId?: string;
initialTabId?: UserTab;
}
interface IState {

View file

@ -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;
}

View file

@ -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<IProps, IState> {
};
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<IProps, IState> {
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<IProps, IState> {
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") }
</AccessibleButton>
</div>;
}

View file

@ -176,7 +176,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
);
this.state = {
collapsed: collapsed,
collapsed,
childSpaces: this.childSpaces,
};

View file

@ -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",

View file

@ -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. " +

View file

@ -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)

View file

@ -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();
});

View file

@ -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<string>();
private userIds = new Set<string>();
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<void> => {
@ -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

View file

@ -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<IState> {
// 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 SpaceKey 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<SpaceKey, Set<string>>();
private spaceFilteredRooms = new Map<SpaceKey, Set<string>>(); // 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<Room["roomId"], Set<string>>();
// 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<IState> {
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<IState> {
}
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<IState> {
}
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<IState> {
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<IState> {
// 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<IState> {
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<string> => {
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<IState> {
return this.spaceFilteredRooms.get(space) || new Set();
};
private rebuild = throttle(() => {
if (!this.matrixClient) return;
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;
}, [[], []]);
// exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview
const unseenChildren = new Set<Room>([...visibleRooms, ...joinedSpaces]);
const backrefs = new EnhancedMap<string, Set<string>>();
// 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);
});
});
const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren));
// somewhat 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.map(r => r.roomId));
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();
public getSpaceFilteredUserIds = (space: SpaceKey): Set<string> => {
if (space === MetaSpace.Home && this.allRoomsInHome) {
return undefined;
}
this.onRoomsUpdate(); // TODO only do this if a change has happened
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
this._invitedSpaces = new Set(this.sortRootSpaces(invitedSpaces));
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}, 100, { trailing: true, leading: true });
private onSpaceUpdate = () => {
this.rebuild();
if (isMetaSpace(space)) return undefined;
return this.spaceFilteredUsers.get(space) || new Set();
};
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
};
// 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 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();
};
private onRoomsUpdate = throttle(() => {
// TODO resolve some updates as deltas
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);
private markTreeChildren = (rootSpace: Room, unseen: Set<Room>): 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 unseenSpaces = new Set(joinedSpaces);
joinedSpaces.forEach(space => {
this.getChildSpaces(space.roomId).forEach(subspace => {
unseenSpaces.delete(subspace);
});
});
// 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);
// 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<Room>(sortBy(joinedSpaces, space => space.roomId));
// Mark any nodes which are children of our existing root spaces as attached.
rootSpaces.forEach(rootSpace => {
this.markTreeChildren(rootSpace, detachedNodes);
});
// Handle spaces forming fully cyclical relationships.
// 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; // already claimed, skip
// declare this detached node a new root, find its children, without ever looping back to it
rootSpaces.push(detachedNode); // consider this node a new root space
this.markTreeChildren(detachedNode, detachedNodes); // declare this node and its children attached
});
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[]]);
const rootSpaces = this.findRootSpaces(joinedSpaces);
const oldRootSpaces = this.rootSpaces;
this.rootSpaces = this.sortRootSpaces(rootSpaces);
this.onRoomsUpdate();
if (arrayHasOrderChange(oldRootSpaces, this.rootSpaces)) {
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces);
}
const oldInvitedSpaces = this._invitedSpaces;
this._invitedSpaces = new Set(this.sortRootSpaces(invitedSpaces));
if (setHasDiff(oldInvitedSpaces, this._invitedSpaces)) {
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}
};
private rebuildParentMap = () => {
const joinedSpaces = this.matrixClient.getVisibleRooms().filter(r => {
return r.isSpaceRoom() && r.getMyMembership() === "join";
});
this.parentMap = new EnhancedMap<string, Set<string>>();
joinedSpaces.forEach(space => {
const children = this.getChildren(space.roomId);
children.forEach(child => {
this.parentMap.getOrCreate(child.roomId, new Set()).add(space.roomId);
});
});
};
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);
}
if (this.activeSpace === MetaSpace.Home) {
this.switchSpaceIfNeeded();
}
};
private rebuildMetaSpaces = () => {
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
const visibleRooms = this.matrixClient.getVisibleRooms();
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<IState> {
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<string>();
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<string>()) => {
// 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<string, Set<string>>();
visibleRooms.forEach(room => {
if (room.getMyMembership() !== "join") return;
@ -530,31 +692,26 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
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> => {
const fn = (spaceId: string, parentPath: Set<string>): [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);
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<IState> {
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<IState> {
// 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<IState> {
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<IState> {
// 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<IState> {
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<IState> {
// 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<IState> {
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);
}
}
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 => {
private onRoomDmChange(room: Room, isDm: boolean): void {
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
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<Record<string, string[]>>() ?? {}).flat());
const currentRooms = new Set(Object.values(ev.getContent<Record<string, string[]>>()).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<IState> {
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<IState> {
}
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<IState> {
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<IState> {
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<IState> {
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<IState> {
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;
}
}
}

View file

@ -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;
}

View file

@ -184,6 +184,8 @@ export function arrayHasDiff(a: any[], b: any[]): boolean {
}
}
export type Diff<T> = { 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<T>(a: T[], b: T[]): { added: T[], removed: T[] } {
export function arrayDiff<T>(a: T[], b: T[]): Diff<T> {
return {
added: b.filter(i => !a.includes(i)),
removed: a.filter(i => !b.includes(i)),

View file

@ -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<T>(a: Set<T>, b: Set<T>): 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<T>(a: Set<T>, b: Set<T>): Diff<T> {
return arrayDiff(Array.from(a), Array.from(b));
}

View file

@ -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<unknown> => {
return Modal.createTrackedDialog("Space preferences", "", SpacePreferencesDialog, {
initialTabId,
space,
}, null, false, true).finished;
};

View file

@ -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;
});
});

View file

@ -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()),