diff --git a/res/css/_components.scss b/res/css/_components.scss
index 674c648778..346a948abd 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 1f2f5f4a58..547a27ad78 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 59aed520fd..e982b6245f 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 0000000000..370c0f845b
--- /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 d143ddfd98..6c7d6f6597 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}
             />
+            <IconizedContextMenuOption
+                iconClassName="mx_SpacePanel_iconPreferences"
+                label={_t("Preferences")}
+                onClick={onPreferencesClick}
+            />
             { settingsOption }
             { leaveOption }
             { devtoolsOption }
diff --git a/src/components/views/dialogs/SpacePreferencesDialog.tsx b/src/components/views/dialogs/SpacePreferencesDialog.tsx
new file mode 100644
index 0000000000..dc047245ac
--- /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<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;
diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx
index 4cf08ca24e..e50c12e6fb 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 100b1ca435..6031fcca7e 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 8e29977786..c36df0546d 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<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>;
             }
diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx
index e8d090644a..7a0db1171d 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<IItemProps, IItemState> {
         );
 
         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 2851a8ce5f..5d7d5f0787 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 cf0776df06..4edc4884d5 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 744d75b136..9eef8a5dbd 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 e7d6e78206..d9aa032c4b 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 fd815bf86f..b2d86b459a 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<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
diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts
index 13cd8b5b3a..068d51bace 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<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;
                 }
             }
         }
diff --git a/src/stores/spaces/index.ts b/src/stores/spaces/index.ts
index 7272cd6095..f4bba0621b 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 6181d4e875..a0fddde45c 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<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)),
diff --git a/src/utils/sets.ts b/src/utils/sets.ts
index e5427b2e94..da856af2b5 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<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));
+}
diff --git a/src/utils/space.tsx b/src/utils/space.tsx
index 8642d42320..a88d621bc6 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<unknown> => {
+    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 228d7da059..52aff5b381 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 d34385c7de..0b9bbd642c 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()),