UNPKG

matrix-react-sdk

Version:
1,203 lines (1,039 loc) 59.5 kB
/* 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 as MetaSpace)) { 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); 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.enabledMetaSpac