1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-09 14:42:51 +03:00
Files
element-web/src/stores/spaces/SpaceStore.ts
Michael Telatynski 1e42f28a69 Harden Settings using mapped types (#28775)
* Harden Settings using mapped types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix issues found during hardening

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove oidc native flow stale key

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-12-23 20:25:15 +00:00

1419 lines
58 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ListIteratee, Many, sortBy } from "lodash";
import {
EventType,
RoomType,
Room,
RoomEvent,
RoomMember,
RoomStateEvent,
MatrixEvent,
ClientEvent,
ISendEventResponse,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import RoomListStore from "../room-list/RoomListStore";
import SettingsStore from "../../settings/SettingsStore";
import DMRoomMap from "../../utils/DMRoomMap";
import { FetchRoomFn } from "../notifications/ListNotificationState";
import { SpaceNotificationState } from "../notifications/SpaceNotificationState";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { DefaultTagID } from "../room-list/models";
import { EnhancedMap, mapDiff } from "../../utils/maps";
import { setDiff, setHasDiff } from "../../utils/sets";
import { Action } from "../../dispatcher/actions";
import { arrayHasDiff, arrayHasOrderChange, filterBoolean } from "../../utils/arrays";
import { reorderLexicographically } from "../../utils/stringOrderField";
import { TAG_ORDER } from "../../components/views/rooms/RoomList";
import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
import {
isMetaSpace,
ISuggestedRoom,
MetaSpace,
SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE,
UPDATE_SUGGESTED_ROOMS,
UPDATE_TOP_LEVEL_SPACES,
} from ".";
import { getCachedRoomIDForAlias } from "../../RoomAliasCache";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import {
flattenSpaceHierarchyWithCache,
SpaceEntityMap,
SpaceDescendantMap,
flattenSpaceHierarchy,
} from "./flattenSpaceHierarchy";
import { PosthogAnalytics } from "../../PosthogAnalytics";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload";
import { SdkContextClass } from "../../contexts/SDKContext";
interface IState {}
const ACTIVE_SPACE_LS_KEY = "mx_active_space";
const metaSpaceOrder: MetaSpace[] = [
MetaSpace.Home,
MetaSpace.Favourites,
MetaSpace.People,
MetaSpace.Orphans,
MetaSpace.VideoRooms,
];
const MAX_SUGGESTED_ROOMS = 20;
const getSpaceContextKey = (space: SpaceKey): string => `mx_space_context_${space}`;
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => {
// [spaces, rooms]
return arr.reduce<[Room[], Room[]]>(
(result, room: Room) => {
result[room.isSpaceRoom() ? 0 : 1].push(room);
return result;
},
[[], []],
);
};
const validOrder = (order?: string): string | undefined => {
if (
typeof order === "string" &&
order.length <= 50 &&
Array.from(order).every((c: string) => {
const charCode = c.charCodeAt(0);
return charCode >= 0x20 && charCode <= 0x7e;
})
) {
return order;
}
};
// For sorting space children using a validated `order`, `origin_server_ts`, `room_id`
export const getChildOrder = (
order: string | undefined,
ts: number,
roomId: string,
): Array<Many<ListIteratee<unknown>>> => {
return [validOrder(order) ?? NaN, ts, roomId]; // NaN has lodash sort it at the end in asc
};
const getRoomFn: FetchRoomFn = (room: Room) => {
return RoomNotificationStateStore.instance.getRoomState(room);
};
type SpaceStoreActions =
| SettingUpdatedPayload
| ViewRoomPayload
| ViewHomePagePayload
| SwitchSpacePayload
| AfterLeaveRoomPayload;
export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// The spaces representing the roots of the various tree-like hierarchies
private rootSpaces: Room[] = [];
// Map from room/space 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 SpaceKey to Set of room IDs that are direct descendants of that space
private roomIdsBySpace: SpaceEntityMap = new Map<SpaceKey, Set<string>>(); // won't contain MetaSpace.People
// Map from space id to Set of space keys that are direct descendants of that space
// meta spaces do not have descendants
private childSpacesBySpace: SpaceDescendantMap = new Map<Room["roomId"], Set<Room["roomId"]>>();
// Map from space id to Set of user IDs that are direct descendants of that space
private userIdsBySpace: SpaceEntityMap = new Map<Room["roomId"], Set<string>>();
// cache that stores the aggregated lists of roomIdsBySpace and userIdsBySpace
// cleared on changes
private _aggregatedSpaceCache = {
roomIdsBySpace: new Map<SpaceKey, Set<string>>(),
userIdsBySpace: 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[] = [];
private _invitedSpaces = new Set<Room>();
private spaceOrderLocalEchoMap = new Map<string, string | undefined>();
// The following properties are set by onReady as they live in account_data
private _allRoomsInHome = false;
private _enabledMetaSpaces: MetaSpace[] = [];
/** Whether the feature flag is set for MSC3946 */
private _msc3946ProcessDynamicPredecessor: boolean = SettingsStore.getValue("feature_dynamic_room_predecessors");
public constructor() {
super(defaultDispatcher, {});
SettingsStore.monitorSetting("Spaces.allRoomsInHome", null);
SettingsStore.monitorSetting("Spaces.enabledMetaSpaces", null);
SettingsStore.monitorSetting("Spaces.showPeopleInSpace", null);
SettingsStore.monitorSetting("feature_dynamic_room_predecessors", null);
}
public get invitedSpaces(): Room[] {
return Array.from(this._invitedSpaces);
}
public get enabledMetaSpaces(): MetaSpace[] {
return this._enabledMetaSpaces;
}
public get spacePanelSpaces(): Room[] {
return this.rootSpaces;
}
public get activeSpace(): SpaceKey {
return this._activeSpace;
}
public get activeSpaceRoom(): Room | null {
if (isMetaSpace(this._activeSpace)) return null;
return this.matrixClient?.getRoom(this._activeSpace) ?? null;
}
public get suggestedRooms(): ISuggestedRoom[] {
return this._suggestedRooms;
}
public get allRoomsInHome(): boolean {
return this._allRoomsInHome;
}
public setActiveRoomInSpace(space: SpaceKey): void {
if (!isMetaSpace(space) && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return;
if (space !== this.activeSpace) this.setActiveSpace(space, false);
let roomId: string | undefined;
if (space === MetaSpace.Home && this.allRoomsInHome) {
const hasMentions = RoomNotificationStateStore.instance.globalState.hasMentions;
const lists = RoomListStore.instance.orderedLists;
tagLoop: for (let i = 0; i < TAG_ORDER.length; i++) {
const t = TAG_ORDER[i];
if (!lists[t]) continue;
for (const room of lists[t]) {
const state = RoomNotificationStateStore.instance.getRoomState(room);
if (hasMentions ? state.hasMentions : state.isUnread) {
roomId = room.roomId;
break tagLoop;
}
}
}
} else {
roomId = this.getNotificationState(space).getFirstRoomWithNotifications();
}
if (!!roomId) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
context_switch: true,
metricsTrigger: "WebSpacePanelNotificationBadge",
});
}
}
/**
* Sets the active space, updates room list filters,
* optionally switches the user's room back to where they were when they last viewed that space.
* @param space which space to switch to.
* @param contextSwitch whether to switch the user's context,
* should not be done when the space switch is done implicitly due to another event like switching room.
*/
public setActiveSpace(space: SpaceKey, contextSwitch = true): void {
if (!space || !this.matrixClient || space === this.activeSpace) return;
let cliSpace: Room | null = null;
if (!isMetaSpace(space)) {
cliSpace = this.matrixClient.getRoom(space);
if (!cliSpace?.isSpaceRoom()) return;
} else if (!this.enabledMetaSpaces.includes(space)) {
return;
}
window.localStorage.setItem(ACTIVE_SPACE_LS_KEY, (this._activeSpace = space)); // Update & persist selected space
if (contextSwitch) {
// view last selected room from space
const roomId = window.localStorage.getItem(getSpaceContextKey(space));
// if the space being selected is an invite then always view that invite
// else if the last viewed room in this space is joined then view that
// else view space home or home depending on what is being clicked on
if (
roomId &&
cliSpace?.getMyMembership() !== KnownMembership.Invite &&
this.matrixClient.getRoom(roomId)?.getMyMembership() === KnownMembership.Join &&
this.isRoomInSpace(space, roomId)
) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
context_switch: true,
metricsTrigger: "WebSpaceContextSwitch",
});
} else if (cliSpace) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: space,
context_switch: true,
metricsTrigger: "WebSpaceContextSwitch",
});
} else {
defaultDispatcher.dispatch<ViewHomePagePayload>({
action: Action.ViewHomePage,
context_switch: true,
});
}
}
this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);
this.emit(UPDATE_SUGGESTED_ROOMS, (this._suggestedRooms = []));
if (cliSpace) {
this.loadSuggestedRooms(cliSpace);
// Load all members for the selected space and its subspaces,
// so we can correctly show DMs we have with members of this space.
SpaceStore.instance.traverseSpace(
space,
(roomId) => {
this.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded();
},
false,
);
}
}
private async loadSuggestedRooms(space: Room): Promise<void> {
const suggestedRooms = await this.fetchSuggestedRooms(space);
if (this._activeSpace === space.roomId) {
this._suggestedRooms = suggestedRooms;
this.emit(UPDATE_SUGGESTED_ROOMS, this._suggestedRooms);
}
}
public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise<ISuggestedRoom[]> => {
try {
const { rooms } = await this.matrixClient!.getRoomHierarchy(space.roomId, limit, 1, true);
const viaMap = new EnhancedMap<string, Set<string>>();
rooms.forEach((room) => {
room.children_state.forEach((ev) => {
if (ev.type === EventType.SpaceChild && ev.content.via?.length) {
ev.content.via.forEach((via) => {
viaMap.getOrCreate(ev.state_key, new Set()).add(via);
});
}
});
});
return rooms
.filter((roomInfo) => {
return (
roomInfo.room_type !== RoomType.Space &&
this.matrixClient?.getRoom(roomInfo.room_id)?.getMyMembership() !== KnownMembership.Join
);
})
.map((roomInfo) => ({
...roomInfo,
viaServers: Array.from(viaMap.get(roomInfo.room_id) || []),
}));
} catch (e) {
logger.error(e);
}
return [];
};
public addRoomToSpace(space: Room, roomId: string, via: string[], suggested = false): Promise<ISendEventResponse> {
return this.matrixClient!.sendStateEvent(
space.roomId,
EventType.SpaceChild,
{
via,
suggested,
},
roomId,
);
}
public getChildren(spaceId: string): Room[] {
const room = this.matrixClient?.getRoom(spaceId);
const childEvents = room?.currentState
.getStateEvents(EventType.SpaceChild)
.filter((ev) => ev.getContent()?.via);
return (
sortBy(childEvents, (ev) => {
return getChildOrder(ev.getContent().order, ev.getTs(), ev.getStateKey()!);
})
.map((ev) => {
const history = this.matrixClient!.getRoomUpgradeHistory(
ev.getStateKey()!,
true,
this._msc3946ProcessDynamicPredecessor,
);
return history[history.length - 1];
})
.filter((room) => {
return (
room?.getMyMembership() === KnownMembership.Join ||
room?.getMyMembership() === KnownMembership.Invite
);
}) || []
);
}
public getChildRooms(spaceId: string): Room[] {
return this.getChildren(spaceId).filter((r) => !r.isSpaceRoom());
}
public getChildSpaces(spaceId: string): Room[] {
// don't show invited subspaces as they surface at the top level for better visibility
return this.getChildren(spaceId).filter((r) => r.isSpaceRoom() && r.getMyMembership() === KnownMembership.Join);
}
public getParents(roomId: string, canonicalOnly = false): Room[] {
if (!this.matrixClient) return [];
const userId = this.matrixClient.getSafeUserId();
const room = this.matrixClient.getRoom(roomId);
const events = room?.currentState.getStateEvents(EventType.SpaceParent) ?? [];
return filterBoolean(
events.map((ev) => {
const content = ev.getContent();
if (!Array.isArray(content.via) || (canonicalOnly && !content.canonical)) {
return; // skip
}
// only respect the relationship if the sender has sufficient permissions in the parent to set
// child relations, as per MSC1772.
// https://github.com/matrix-org/matrix-doc/blob/main/proposals/1772-groups-as-rooms.md#relationship-between-rooms-and-spaces
const parent = this.matrixClient?.getRoom(ev.getStateKey());
const relation = parent?.currentState.getStateEvents(EventType.SpaceChild, roomId);
if (
!parent?.currentState.maySendStateEvent(EventType.SpaceChild, userId) ||
// also skip this relation if the parent had this child added but then since removed it
(relation && !Array.isArray(relation.getContent().via))
) {
return; // skip
}
return parent;
}),
);
}
public getCanonicalParent(roomId: string): Room | null {
const parents = this.getParents(roomId, true);
return sortBy(parents, (r) => r.roomId)?.[0] || null;
}
public getKnownParents(roomId: string, includeAncestors?: boolean): Set<string> {
if (includeAncestors) {
return flattenSpaceHierarchy(this.parentMap, this.parentMap, roomId);
}
return this.parentMap.get(roomId) || new Set();
}
public isRoomInSpace(space: SpaceKey, roomId: string, includeDescendantSpaces = true): boolean {
if (space === MetaSpace.Home && this.allRoomsInHome) {
return true;
}
if (space === MetaSpace.VideoRooms) {
return !!this.matrixClient?.getRoom(roomId)?.isCallRoom();
}
if (this.getSpaceFilteredRoomIds(space, includeDescendantSpaces)?.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.getSpaceFilteredUserIds(space, includeDescendantSpaces)?.has(dmPartner) &&
SettingsStore.getValue("Spaces.showPeopleInSpace", space)
) {
return true;
}
return false;
}
// get all rooms in a space
// including descendant spaces
public getSpaceFilteredRoomIds = (
space: SpaceKey,
includeDescendantSpaces = true,
useCache = true,
): Set<string> => {
if (space === MetaSpace.Home && this.allRoomsInHome) {
return new Set(
this.matrixClient!.getVisibleRooms(this._msc3946ProcessDynamicPredecessor).map((r) => r.roomId),
);
}
// meta spaces never have descendants
// and the aggregate cache is not managed for meta spaces
if (!includeDescendantSpaces || isMetaSpace(space)) {
return this.roomIdsBySpace.get(space) || new Set();
}
return this.getAggregatedRoomIdsBySpace(this.roomIdsBySpace, this.childSpacesBySpace, space, useCache);
};
public getSpaceFilteredUserIds = (
space: SpaceKey,
includeDescendantSpaces = true,
useCache = true,
): Set<string> | undefined => {
if (space === MetaSpace.Home && this.allRoomsInHome) {
return undefined;
}
if (isMetaSpace(space)) {
return undefined;
}
// meta spaces never have descendants
// and the aggregate cache is not managed for meta spaces
if (!includeDescendantSpaces || isMetaSpace(space)) {
return this.userIdsBySpace.get(space) || new Set();
}
return this.getAggregatedUserIdsBySpace(this.userIdsBySpace, this.childSpacesBySpace, space, useCache);
};
private getAggregatedRoomIdsBySpace = flattenSpaceHierarchyWithCache(this._aggregatedSpaceCache.roomIdsBySpace);
private getAggregatedUserIdsBySpace = flattenSpaceHierarchyWithCache(this._aggregatedSpaceCache.userIdsBySpace);
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 = (): void => {
if (!this.matrixClient) return;
const visibleSpaces = this.matrixClient
.getVisibleRooms(this._msc3946ProcessDynamicPredecessor)
.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 = (): void => {
if (!this.matrixClient) return;
const joinedSpaces = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor).filter((r) => {
return r.isSpaceRoom() && r.getMyMembership() === KnownMembership.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);
});
});
PosthogAnalytics.instance.setProperty("numSpaces", joinedSpaces.length);
};
private rebuildHomeSpace = (): void => {
if (this.allRoomsInHome) {
// this is a special-case to not have to maintain a set of all rooms
this.roomIdsBySpace.delete(MetaSpace.Home);
} else {
const rooms = new Set(
this.matrixClient!.getVisibleRooms(this._msc3946ProcessDynamicPredecessor)
.filter(this.showInHomeSpace)
.map((r) => r.roomId),
);
this.roomIdsBySpace.set(MetaSpace.Home, rooms);
}
if (this.activeSpace === MetaSpace.Home) {
this.switchSpaceIfNeeded();
}
};
private rebuildMetaSpaces = (): void => {
if (!this.matrixClient) return;
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
const visibleRooms = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor);
if (enabledMetaSpaces.has(MetaSpace.Home)) {
this.rebuildHomeSpace();
} else {
this.roomIdsBySpace.delete(MetaSpace.Home);
}
if (enabledMetaSpaces.has(MetaSpace.Favourites)) {
const favourites = visibleRooms.filter((r) => r.tags[DefaultTagID.Favourite]);
this.roomIdsBySpace.set(MetaSpace.Favourites, new Set(favourites.map((r) => r.roomId)));
} else {
this.roomIdsBySpace.delete(MetaSpace.Favourites);
}
// The People metaspace doesn't need maintaining
// 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);
});
this.roomIdsBySpace.set(MetaSpace.Orphans, new Set(orphans.map((r) => r.roomId)));
}
if (isMetaSpace(this.activeSpace)) {
this.switchSpaceIfNeeded();
}
};
private updateNotificationStates = (spaces?: SpaceKey[]): void => {
if (!this.matrixClient) return;
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
const visibleRooms = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor);
let dmBadgeSpace: MetaSpace | undefined;
// 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.roomIdsBySpace.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
const flattenedRoomsForSpace = this.getSpaceFilteredRoomIds(s, true);
// Update NotificationStates
this.getNotificationState(s).setRooms(
visibleRooms.filter((room) => {
if (s === MetaSpace.People) {
return this.isRoomInSpace(MetaSpace.People, room.roomId);
}
if (room.isSpaceRoom() || !flattenedRoomsForSpace.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() === KnownMembership.Invite
); // put all invites in the Home Space
};
private static isInSpace(member?: RoomMember | null): boolean {
return member?.membership === KnownMembership.Join || member?.membership === KnownMembership.Invite;
}
// 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): void => {
const inSpace = SpaceStoreClass.isInSpace(space.getMember(userId));
if (inSpace) {
this.userIdsBySpace.get(space.roomId)?.add(userId);
} else {
this.userIdsBySpace.get(space.roomId)?.delete(userId);
}
// bust cache
this._aggregatedSpaceCache.userIdsBySpace.clear();
const affectedParentSpaceIds = this.getKnownParents(space.roomId, true);
this.emit(space.roomId);
affectedParentSpaceIds.forEach((spaceId) => this.emit(spaceId));
if (!inSpace) {
// switch space if the DM is no longer considered part of the space
this.switchSpaceIfNeeded();
}
};
private onRoomsUpdate = (): void => {
if (!this.matrixClient) return;
const visibleRooms = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor);
const prevRoomsBySpace = this.roomIdsBySpace;
const prevUsersBySpace = this.userIdsBySpace;
const prevChildSpacesBySpace = this.childSpacesBySpace;
this.roomIdsBySpace = new Map();
this.userIdsBySpace = new Map();
this.childSpacesBySpace = new Map();
this.rebuildParentMap();
// mutates this.roomIdsBySpace
this.rebuildMetaSpaces();
const hiddenChildren = new EnhancedMap<string, Set<string>>();
visibleRooms.forEach((room) => {
if (!([KnownMembership.Join, KnownMembership.Invite] as Array<string>).includes(room.getMyMembership()))
return;
this.getParents(room.roomId).forEach((parent) => {
hiddenChildren.getOrCreate(parent.roomId, new Set()).add(room.roomId);
});
});
this.rootSpaces.forEach((s) => {
// traverse each space tree in DFS to build up the supersets as you go up,
// reusing results from like subtrees.
const traverseSpace = (
spaceId: string,
parentPath: Set<string>,
): [Set<string>, Set<string>] | undefined => {
if (parentPath.has(spaceId)) return; // prevent cycles
// reuse existing results if multiple similar branches exist
if (this.roomIdsBySpace.has(spaceId) && this.userIdsBySpace.has(spaceId)) {
return [this.roomIdsBySpace.get(spaceId)!, this.userIdsBySpace.get(spaceId)!];
}
const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId));
this.childSpacesBySpace.set(spaceId, new Set(childSpaces.map((space) => space.roomId)));
const roomIds = new Set(childRooms.map((r) => r.roomId));
const space = this.matrixClient?.getRoom(spaceId);
const userIds = new Set(
space
?.getMembers()
.filter((m) => {
return m.membership === KnownMembership.Join || m.membership === KnownMembership.Invite;
})
.map((m) => m.userId),
);
const newPath = new Set(parentPath).add(spaceId);
childSpaces.forEach((childSpace) => {
traverseSpace(childSpace.roomId, newPath);
});
hiddenChildren.get(spaceId)?.forEach((roomId) => {
roomIds.add(roomId);
});
// Expand room IDs to all known versions of the given rooms
const expandedRoomIds = new Set(
Array.from(roomIds).flatMap((roomId) => {
return this.matrixClient!.getRoomUpgradeHistory(
roomId,
true,
this._msc3946ProcessDynamicPredecessor,
).map((r) => r.roomId);
}),
);
this.roomIdsBySpace.set(spaceId, expandedRoomIds);
this.userIdsBySpace.set(spaceId, userIds);
return [expandedRoomIds, userIds];
};
traverseSpace(s.roomId, new Set());
});
const roomDiff = mapDiff(prevRoomsBySpace, this.roomIdsBySpace);
const userDiff = mapDiff(prevUsersBySpace, this.userIdsBySpace);
const spaceDiff = mapDiff(prevChildSpacesBySpace, this.childSpacesBySpace);
// filter out keys which changed by reference only by checking whether the sets differ
const roomsChanged = roomDiff.changed.filter((k) => {
return setHasDiff(prevRoomsBySpace.get(k)!, this.roomIdsBySpace.get(k)!);
});
const usersChanged = userDiff.changed.filter((k) => {
return setHasDiff(prevUsersBySpace.get(k)!, this.userIdsBySpace.get(k)!);
});
const spacesChanged = spaceDiff.changed.filter((k) => {
return setHasDiff(prevChildSpacesBySpace.get(k)!, this.childSpacesBySpace.get(k)!);
});
const changeSet = new Set([
...roomDiff.added,
...userDiff.added,
...spaceDiff.added,
...roomDiff.removed,
...userDiff.removed,
...spaceDiff.removed,
...roomsChanged,
...usersChanged,
...spacesChanged,
]);
const affectedParents = Array.from(changeSet).flatMap((changedId) => [
...this.getKnownParents(changedId, true),
]);
affectedParents.forEach((parentId) => changeSet.add(parentId));
// bust aggregate cache
this._aggregatedSpaceCache.roomIdsBySpace.clear();
this._aggregatedSpaceCache.userIdsBySpace.clear();
changeSet.forEach((k) => {
this.emit(k);
});
if (changeSet.has(this.activeSpace)) {
this.switchSpaceIfNeeded();
}
const notificationStatesToUpdate = [...changeSet];
// We update the People metaspace even if we didn't detect any changes
// as roomIdsBySpace does not pre-calculate it so we have to assume it could have changed
if (this.enabledMetaSpaces.includes(MetaSpace.People)) {
notificationStatesToUpdate.push(MetaSpace.People);
}
this.updateNotificationStates(notificationStatesToUpdate);
};
private switchSpaceIfNeeded = (roomId = SdkContextClass.instance.roomViewStore.getRoomId()): void => {
if (!roomId) return;
if (!this.isRoomInSpace(this.activeSpace, roomId) && !this.matrixClient?.getRoom(roomId)?.isSpaceRoom()) {
this.switchToRelatedSpace(roomId);
}
};
private switchToRelatedSpace = (roomId: string): void => {
if (this.suggestedRooms.find((r) => r.room_id === roomId)) return;
// try to find the canonical parent first
let parent: SpaceKey | undefined = this.getCanonicalParent(roomId)?.roomId;
// otherwise, try to find a root space which contains this room
if (!parent) {
parent = this.rootSpaces.find((s) => this.isRoomInSpace(s.roomId, roomId))?.roomId;
}
// 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.isRoomInSpace(s, roomId));
}
// don't trigger a context switch when we are switching a space to match the chosen room
if (parent) {
this.setActiveSpace(parent, false);
} else {
this.goToFirstSpace();
}
};
private onRoom = (room: Room, newMembership?: string, oldMembership?: string): void => {
const roomMembership = room.getMyMembership();
if (!roomMembership) {
// room is still being baked in the js-sdk, we'll process it at Room.myMembership instead
return;
}
const membership = newMembership || roomMembership;
if (!room.isSpaceRoom()) {
this.onRoomsUpdate();
if (membership === KnownMembership.Join) {
// the user just joined a room, remove it from the suggested list if it was there
const numSuggestedRooms = this._suggestedRooms.length;
this._suggestedRooms = this._suggestedRooms.filter((r) => r.room_id !== room.roomId);
if (numSuggestedRooms !== this._suggestedRooms.length) {
this.emit(UPDATE_SUGGESTED_ROOMS, this._suggestedRooms);
// If the suggested room was present in the list then we know we don't need to switch space
return;
}
// if the room currently being viewed was just joined then switch to its related space
if (
newMembership === KnownMembership.Join &&
room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()
) {
this.switchSpaceIfNeeded(room.roomId);
}
}
return;
}
// Space
if (membership === KnownMembership.Invite) {
const len = this._invitedSpaces.size;
this._invitedSpaces.add(room);
if (len !== this._invitedSpaces.size) {
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}
} else if (oldMembership === KnownMembership.Invite && membership !== KnownMembership.Join) {
if (this._invitedSpaces.delete(room)) {
this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces);
}
} else {
this.rebuildSpaceHierarchy();
// fire off updates to all parent listeners
this.parentMap.get(room.roomId)?.forEach((parentId) => {
this.emit(parentId);
});
this.emit(room.roomId);
}
if (membership === KnownMembership.Join && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) {
// if the user was looking at the space and then joined: select that space
this.setActiveSpace(room.roomId, false);
} else if (membership === KnownMembership.Leave && room.roomId === this.activeSpace) {
// user's active space has gone away, go back to home
this.goToFirstSpace(true);
}
};
private notifyIfOrderChanged(): void {
const rootSpaces = this.sortRootSpaces(this.rootSpaces);
if (arrayHasOrderChange(this.rootSpaces, rootSpaces)) {
this.rootSpaces = rootSpaces;
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces);
}
}
private onRoomState = (ev: MatrixEvent): void => {
const room = this.matrixClient?.getRoom(ev.getRoomId());
if (!this.matrixClient || !room) return;
switch (ev.getType()) {
case EventType.SpaceChild: {
const target = this.matrixClient.getRoom(ev.getStateKey());
if (room.isSpaceRoom()) {
if (target?.isSpaceRoom()) {
this.rebuildSpaceHierarchy();
this.emit(target.roomId);
} else {
this.onRoomsUpdate();
}
this.emit(room.roomId);
}
if (
room.roomId === this.activeSpace && // current space
target?.getMyMembership() !== KnownMembership.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.rebuildSpaceHierarchy();
} else {
this.onRoomsUpdate();
}
this.emit(room.roomId);
break;
case EventType.RoomPowerLevels:
if (room.isSpaceRoom()) {
this.onRoomsUpdate();
}
break;
case EventType.RoomCreate:
// The room might become a video room. We need to tag it for that videoRooms space.
this.onRoomsUpdate();
break;
}
};
// 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): void => {
const room = this.matrixClient?.getRoom(ev.getRoomId());
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);
}
};
private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEv?: MatrixEvent): void => {
if (room.isSpaceRoom() && ev.getType() === EventType.SpaceOrder) {
this.spaceOrderLocalEchoMap.delete(room.roomId); // clear any local echo
const order = ev.getContent()?.order;
const lastOrder = lastEv?.getContent()?.order;
if (order !== lastOrder) {
this.notifyIfOrderChanged();
}
} else if (ev.getType() === EventType.Tag) {
// If the room was in favourites and now isn't or the opposite then update its position in the trees
const oldTags = lastEv?.getContent()?.tags || {};
const newTags = ev.getContent()?.tags || {};
if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) {
this.onRoomFavouriteChange(room);
}
}
};
private onRoomFavouriteChange(room: Room): void {
if (this.enabledMetaSpaces.includes(MetaSpace.Favourites)) {
if (room.tags[DefaultTagID.Favourite]) {
this.roomIdsBySpace.get(MetaSpace.Favourites)?.add(room.roomId);
} else {
this.roomIdsBySpace.get(MetaSpace.Favourites)?.delete(room.roomId);
}
this.emit(MetaSpace.Favourites);
}
}
private onRoomDmChange(room: Room, isDm: boolean): void {
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
if (!this.allRoomsInHome && enabledMetaSpaces.has(MetaSpace.Home)) {
const homeRooms = this.roomIdsBySpace.get(MetaSpace.Home);
if (this.showInHomeSpace(room)) {
homeRooms?.add(room.roomId);
} else if (!this.roomIdsBySpace.get(MetaSpace.Orphans)?.has(room.roomId)) {
this.roomIdsBySpace.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.roomIdsBySpace.get(MetaSpace.Orphans)?.delete(room.roomId)) {
this.emit(MetaSpace.Orphans);
this.emit(MetaSpace.Home);
}
}
}
private onAccountData = (ev: MatrixEvent, prevEv?: MatrixEvent): void => {
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.onRoomDmChange(room, currentRooms.has(roomId));
}
});
if (diff.removed.length > 0) {
this.switchSpaceIfNeeded();
}
}
};
protected async reset(): Promise<void> {
this.rootSpaces = [];
this.parentMap = new EnhancedMap();
this.notificationStateMap = new Map();
this.roomIdsBySpace = new Map();
this.userIdsBySpace = new Map();
this._aggregatedSpaceCache.roomIdsBySpace.clear();
this._aggregatedSpaceCache.userIdsBySpace.clear();
this._activeSpace = MetaSpace.Home; // set properly by onReady
this._suggestedRooms = [];
this._invitedSpaces = new Set();
this._enabledMetaSpaces = [];
}
protected async onNotReady(): Promise<void> {
if (this.matrixClient) {
this.matrixClient.removeListener(ClientEvent.Room, this.onRoom);
this.matrixClient.removeListener(RoomEvent.MyMembership, this.onRoom);
this.matrixClient.removeListener(RoomEvent.AccountData, this.onRoomAccountData);
this.matrixClient.removeListener(RoomStateEvent.Events, this.onRoomState);
this.matrixClient.removeListener(RoomStateEvent.Members, this.onRoomStateMembers);
this.matrixClient.removeListener(ClientEvent.AccountData, this.onAccountData);
}
await this.reset();
}
protected async onReady(): Promise<void> {
if (!this.matrixClient) return;
this.matrixClient.on(ClientEvent.Room, this.onRoom);
this.matrixClient.on(RoomEvent.MyMembership, this.onRoom);
this.matrixClient.on(RoomEvent.AccountData, this.onRoomAccountData);
this.matrixClient.on(RoomStateEvent.Events, this.onRoomState);
this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers);
this.matrixClient.on(ClientEvent.AccountData, this.onAccountData);
const oldMetaSpaces = this._enabledMetaSpaces;
const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces");
this._enabledMetaSpaces = metaSpaceOrder.filter((k) => enabledMetaSpaces[k]);
this._allRoomsInHome = SettingsStore.getValue("Spaces.allRoomsInHome");
this.sendUserProperties();
this.rebuildSpaceHierarchy(); // trigger an initial update
// rebuildSpaceHierarchy will only send an update if the spaces have changed.
// If only the meta spaces have changed, we need to send an update ourselves.
if (arrayHasDiff(oldMetaSpaces, this._enabledMetaSpaces)) {
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces);
}
// restore selected state from last session if any and still valid
const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY) as MetaSpace;
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.switchSpaceIfNeeded();
}
}
private sendUserProperties(): void {
const enabled = new Set(this.enabledMetaSpaces);
PosthogAnalytics.instance.setProperty("WebMetaSpaceHomeEnabled", enabled.has(MetaSpace.Home));
PosthogAnalytics.instance.setProperty("WebMetaSpaceHomeAllRooms", this.allRoomsInHome);
PosthogAnalytics.instance.setProperty("WebMetaSpacePeopleEnabled", enabled.has(MetaSpace.People));
PosthogAnalytics.instance.setProperty("WebMetaSpaceFavouritesEnabled", enabled.has(MetaSpace.Favourites));
PosthogAnalytics.instance.setProperty("WebMetaSpaceOrphansEnabled", enabled.has(MetaSpace.Orphans));
}
private goToFirstSpace(contextSwitch = false): void {
this.setActiveSpace(this.enabledMetaSpaces[0] ?? this.spacePanelSpaces[0]?.roomId, contextSwitch);
}
protected async onAction(payload: SpaceStoreActions): Promise<void> {
if (!this.matrixClient) return;
switch (payload.action) {
case Action.ViewRoom: {
// Don't auto-switch rooms when reacting to a context-switch or for new rooms being created
// as this is not helpful and can create loops of rooms/space switching
const isSpace = payload.justCreatedOpts?.roomType === RoomType.Space;
if (payload.context_switch || (payload.justCreatedOpts && !isSpace)) break;
let roomId = payload.room_id;
if (payload.room_alias && !roomId) {
roomId = getCachedRoomIDForAlias(payload.room_alias);
}
if (!roomId) return; // we'll get re-fired with the room ID shortly
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 {
this.switchSpaceIfNeeded(roomId);
}
// Persist last viewed room from a space
// we don't await setActiveSpace above as we only care about this.activeSpace being up to date
// synchronously for the below code - everything else can and should be async.
window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id ?? "");
break;
}
case Action.ViewHomePage:
if (!payload.context_switch && this.enabledMetaSpaces.includes(MetaSpace.Home)) {
this.setActiveSpace(MetaSpace.Home, false);
window.localStorage.setItem(getSpaceContextKey(this.activeSpace), "");
}
break;
case Action.AfterLeaveRoom:
if (!isMetaSpace(this._activeSpace) && payload.room_id === this._activeSpace) {
// User has left the current space, go to first space
this.goToFirstSpace(true);
}
break;
case Action.SwitchSpace: {
// Metaspaces start at 1, Spaces follow
if (payload.num < 1 || payload.num > 9) break;
const numMetaSpaces = this.enabledMetaSpaces.length;
if (payload.num <= numMetaSpaces) {
this.setActiveSpace(this.enabledMetaSpaces[payload.num - 1]);
} else if (this.spacePanelSpaces.length > payload.num - numMetaSpaces - 1) {
this.setActiveSpace(this.spacePanelSpaces[payload.num - numMetaSpaces - 1].roomId);
}
break;
}
case Action.SettingUpdated: {
switch (payload.settingName) {
case "Spaces.allRoomsInHome": {
const newValue = SettingsStore.getValue("Spaces.allRoomsInHome");
if (this.allRoomsInHome !== newValue) {
this._allRoomsInHome = newValue;
this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome);
if (this.enabledMetaSpaces.includes(MetaSpace.Home)) {
this.rebuildHomeSpace();
}
this.sendUserProperties();
}
break;
}
case "Spaces.enabledMetaSpaces": {
const newValue = SettingsStore.getValue("Spaces.enabledMetaSpaces");
const enabledMetaSpaces = metaSpaceOrder.filter((k) => newValue[k]);
if (arrayHasDiff(this._enabledMetaSpaces, enabledMetaSpaces)) {
const hadPeopleOrHomeEnabled = this.enabledMetaSpaces.some((s) => {
return s === MetaSpace.Home || s === MetaSpace.People;
});
this._enabledMetaSpaces = enabledMetaSpaces;
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.sendUserProperties();
}
break;
}
case "Spaces.showPeopleInSpace":
if (payload.roomId) {
// getSpaceFilteredUserIds will return the appropriate value
this.emit(payload.roomId);
if (!this.enabledMetaSpaces.some((s) => s === MetaSpace.Home || s === MetaSpace.People)) {
this.updateNotificationStates([payload.roomId]);
}
}
break;
case "feature_dynamic_room_predecessors":
this._msc3946ProcessDynamicPredecessor = SettingsStore.getValue(
"feature_dynamic_room_predecessors",
);
this.rebuildSpaceHierarchy();
break;
}
}
}
}
public getNotificationState(key: SpaceKey): SpaceNotificationState {
if (this.notificationStateMap.has(key)) {
return this.notificationStateMap.get(key)!;
}
const state = new SpaceNotificationState(getRoomFn);
this.notificationStateMap.set(key, state);
return state;
}
// traverse space tree with DFS calling fn on each space including the given root one,
// if includeRooms is true then fn will be called on each leaf room, if it is present in multiple sub-spaces
// then fn will be called with it multiple times.
public traverseSpace(
spaceId: string,
fn: (roomId: string) => void,
includeRooms = false,
parentPath?: Set<string>,
): void {
if (parentPath && parentPath.has(spaceId)) return; // prevent cycles
fn(spaceId);
const newPath = new Set(parentPath).add(spaceId);
const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId));
if (includeRooms) {
childRooms.forEach((r) => fn(r.roomId));
}
childSpaces.forEach((s) => this.traverseSpace(s.roomId, fn, includeRooms, newPath));
}
private getSpaceTagOrdering = (space: Room): string | undefined => {
if (this.spaceOrderLocalEchoMap.has(space.roomId)) return this.spaceOrderLocalEchoMap.get(space.roomId);
return validOrder(space.getAccountData(EventType.SpaceOrder)?.getContent()?.order);
};
private sortRootSpaces(spaces: Room[]): Room[] {
return sortBy(spaces, [this.getSpaceTagOrdering, "roomId"]);
}
private async setRootSpaceOrder(space: Room, order?: string): Promise<void> {
this.spaceOrderLocalEchoMap.set(space.roomId, order);
try {
await this.matrixClient?.setRoomAccountData(space.roomId, EventType.SpaceOrder, { order });
} catch (e) {
logger.warn("Failed to set root space order", e);
if (this.spaceOrderLocalEchoMap.get(space.roomId) === order) {
this.spaceOrderLocalEchoMap.delete(space.roomId);
}
}
}
public moveRootSpace(fromIndex: number, toIndex: number): void {
const currentOrders = this.rootSpaces.map(this.getSpaceTagOrdering);
const changes = reorderLexicographically(currentOrders, fromIndex, toIndex);
changes.forEach(({ index, order }) => {
this.setRootSpaceOrder(this.rootSpaces[index], order);
});
this.notifyIfOrderChanged();
}
}
export default class SpaceStore {
private static readonly internalInstance = (() => {
const instance = new SpaceStoreClass();
instance.start();
return instance;
})();
public static get instance(): SpaceStoreClass {
return SpaceStore.internalInstance;
}
/**
* @internal for test only
*/
public static testInstance(): SpaceStoreClass {
const store = new SpaceStoreClass();
store.start();
return store;
}
}
window.mxSpaceStore = SpaceStore.instance;