matrix-react-sdk
Version:
SDK for matrix.org using React
1,029 lines (998 loc) • 204 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.getChildOrder = exports.default = exports.SpaceStoreClass = void 0;
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _lodash = require("lodash");
var _matrix = require("matrix-js-sdk/src/matrix");
var _types = require("matrix-js-sdk/src/types");
var _logger = require("matrix-js-sdk/src/logger");
var _AsyncStoreWithClient = require("../AsyncStoreWithClient");
var _dispatcher = _interopRequireDefault(require("../../dispatcher/dispatcher"));
var _RoomListStore = _interopRequireDefault(require("../room-list/RoomListStore"));
var _SettingsStore = _interopRequireDefault(require("../../settings/SettingsStore"));
var _DMRoomMap = _interopRequireDefault(require("../../utils/DMRoomMap"));
var _SpaceNotificationState = require("../notifications/SpaceNotificationState");
var _RoomNotificationStateStore = require("../notifications/RoomNotificationStateStore");
var _models = require("../room-list/models");
var _maps = require("../../utils/maps");
var _sets = require("../../utils/sets");
var _actions = require("../../dispatcher/actions");
var _arrays = require("../../utils/arrays");
var _stringOrderField = require("../../utils/stringOrderField");
var _RoomList = require("../../components/views/rooms/RoomList");
var _ = require(".");
var _RoomAliasCache = require("../../RoomAliasCache");
var _membership = require("../../utils/membership");
var _flattenSpaceHierarchy = require("./flattenSpaceHierarchy");
var _PosthogAnalytics = require("../../PosthogAnalytics");
var _SDKContext = require("../../contexts/SDKContext");
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } /*
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.
*/
const ACTIVE_SPACE_LS_KEY = "mx_active_space";
const metaSpaceOrder = [_.MetaSpace.Home, _.MetaSpace.Favourites, _.MetaSpace.People, _.MetaSpace.Orphans, _.MetaSpace.VideoRooms];
const MAX_SUGGESTED_ROOMS = 20;
const getSpaceContextKey = space => `mx_space_context_${space}`;
const partitionSpacesAndRooms = arr => {
// [spaces, rooms]
return arr.reduce((result, room) => {
result[room.isSpaceRoom() ? 0 : 1].push(room);
return result;
}, [[], []]);
};
const validOrder = order => {
if (typeof order === "string" && order.length <= 50 && Array.from(order).every(c => {
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`
const getChildOrder = (order, ts, roomId) => {
return [validOrder(order) ?? NaN, ts, roomId]; // NaN has lodash sort it at the end in asc
};
exports.getChildOrder = getChildOrder;
const getRoomFn = room => {
return _RoomNotificationStateStore.RoomNotificationStateStore.instance.getRoomState(room);
};
class SpaceStoreClass extends _AsyncStoreWithClient.AsyncStoreWithClient {
constructor() {
super(_dispatcher.default, {});
// The spaces representing the roots of the various tree-like hierarchies
(0, _defineProperty2.default)(this, "rootSpaces", []);
// Map from room/space ID to set of spaces which list it as a child
(0, _defineProperty2.default)(this, "parentMap", new _maps.EnhancedMap());
// Map from SpaceKey to SpaceNotificationState instance representing that space
(0, _defineProperty2.default)(this, "notificationStateMap", new Map());
// Map from SpaceKey to Set of room IDs that are direct descendants of that space
(0, _defineProperty2.default)(this, "roomIdsBySpace", new Map());
// 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
(0, _defineProperty2.default)(this, "childSpacesBySpace", new Map());
// Map from space id to Set of user IDs that are direct descendants of that space
(0, _defineProperty2.default)(this, "userIdsBySpace", new Map());
// cache that stores the aggregated lists of roomIdsBySpace and userIdsBySpace
// cleared on changes
(0, _defineProperty2.default)(this, "_aggregatedSpaceCache", {
roomIdsBySpace: new Map(),
userIdsBySpace: new Map()
});
// The space currently selected in the Space Panel
(0, _defineProperty2.default)(this, "_activeSpace", _.MetaSpace.Home);
// set properly by onReady
(0, _defineProperty2.default)(this, "_suggestedRooms", []);
(0, _defineProperty2.default)(this, "_invitedSpaces", new Set());
(0, _defineProperty2.default)(this, "spaceOrderLocalEchoMap", new Map());
// The following properties are set by onReady as they live in account_data
(0, _defineProperty2.default)(this, "_allRoomsInHome", false);
(0, _defineProperty2.default)(this, "_enabledMetaSpaces", []);
/** Whether the feature flag is set for MSC3946 */
(0, _defineProperty2.default)(this, "_msc3946ProcessDynamicPredecessor", _SettingsStore.default.getValue("feature_dynamic_room_predecessors"));
(0, _defineProperty2.default)(this, "fetchSuggestedRooms", async (space, limit = MAX_SUGGESTED_ROOMS) => {
try {
const {
rooms
} = await this.matrixClient.getRoomHierarchy(space.roomId, limit, 1, true);
const viaMap = new _maps.EnhancedMap();
rooms.forEach(room => {
room.children_state.forEach(ev => {
if (ev.type === _matrix.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 !== _matrix.RoomType.Space && this.matrixClient?.getRoom(roomInfo.room_id)?.getMyMembership() !== _types.KnownMembership.Join;
}).map(roomInfo => _objectSpread(_objectSpread({}, roomInfo), {}, {
viaServers: Array.from(viaMap.get(roomInfo.room_id) || [])
}));
} catch (e) {
_logger.logger.error(e);
}
return [];
});
// get all rooms in a space
// including descendant spaces
(0, _defineProperty2.default)(this, "getSpaceFilteredRoomIds", (space, includeDescendantSpaces = true, useCache = true) => {
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 || (0, _.isMetaSpace)(space)) {
return this.roomIdsBySpace.get(space) || new Set();
}
return this.getAggregatedRoomIdsBySpace(this.roomIdsBySpace, this.childSpacesBySpace, space, useCache);
});
(0, _defineProperty2.default)(this, "getSpaceFilteredUserIds", (space, includeDescendantSpaces = true, useCache = true) => {
if (space === _.MetaSpace.Home && this.allRoomsInHome) {
return undefined;
}
if ((0, _.isMetaSpace)(space)) {
return undefined;
}
// meta spaces never have descendants
// and the aggregate cache is not managed for meta spaces
if (!includeDescendantSpaces || (0, _.isMetaSpace)(space)) {
return this.userIdsBySpace.get(space) || new Set();
}
return this.getAggregatedUserIdsBySpace(this.userIdsBySpace, this.childSpacesBySpace, space, useCache);
});
(0, _defineProperty2.default)(this, "getAggregatedRoomIdsBySpace", (0, _flattenSpaceHierarchy.flattenSpaceHierarchyWithCache)(this._aggregatedSpaceCache.roomIdsBySpace));
(0, _defineProperty2.default)(this, "getAggregatedUserIdsBySpace", (0, _flattenSpaceHierarchy.flattenSpaceHierarchyWithCache)(this._aggregatedSpaceCache.userIdsBySpace));
(0, _defineProperty2.default)(this, "markTreeChildren", (rootSpace, unseen) => {
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);
}
});
}
});
(0, _defineProperty2.default)(this, "findRootSpaces", joinedSpaces => {
// 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((0, _lodash.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;
});
(0, _defineProperty2.default)(this, "rebuildSpaceHierarchy", () => {
if (!this.matrixClient) return;
const visibleSpaces = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor).filter(r => r.isSpaceRoom());
const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce(([joined, invited], s) => {
switch ((0, _membership.getEffectiveMembership)(s.getMyMembership())) {
case _membership.EffectiveMembership.Join:
joined.push(s);
break;
case _membership.EffectiveMembership.Invite:
invited.push(s);
break;
}
return [joined, invited];
}, [[], []]);
const rootSpaces = this.findRootSpaces(joinedSpaces);
const oldRootSpaces = this.rootSpaces;
this.rootSpaces = this.sortRootSpaces(rootSpaces);
this.onRoomsUpdate();
if ((0, _arrays.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 ((0, _sets.setHasDiff)(oldInvitedSpaces, this._invitedSpaces)) {
this.emit(_.UPDATE_INVITED_SPACES, this.invitedSpaces);
}
});
(0, _defineProperty2.default)(this, "rebuildParentMap", () => {
if (!this.matrixClient) return;
const joinedSpaces = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor).filter(r => {
return r.isSpaceRoom() && r.getMyMembership() === _types.KnownMembership.Join;
});
this.parentMap = new _maps.EnhancedMap();
joinedSpaces.forEach(space => {
const children = this.getChildren(space.roomId);
children.forEach(child => {
this.parentMap.getOrCreate(child.roomId, new Set()).add(space.roomId);
});
});
_PosthogAnalytics.PosthogAnalytics.instance.setProperty("numSpaces", joinedSpaces.length);
});
(0, _defineProperty2.default)(this, "rebuildHomeSpace", () => {
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();
}
});
(0, _defineProperty2.default)(this, "rebuildMetaSpaces", () => {
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[_models.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.default.shared().getUserIdForRoomId(r.roomId);
});
this.roomIdsBySpace.set(_.MetaSpace.Orphans, new Set(orphans.map(r => r.roomId)));
}
if ((0, _.isMetaSpace)(this.activeSpace)) {
this.switchSpaceIfNeeded();
}
});
(0, _defineProperty2.default)(this, "updateNotificationStates", spaces => {
if (!this.matrixClient) return;
const enabledMetaSpaces = new Set(this.enabledMetaSpaces);
const visibleRooms = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor);
let dmBadgeSpace;
// 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.default.shared().getUserIdForRoomId(room.roomId)) {
return s === dmBadgeSpace;
}
return true;
}));
});
if (dmBadgeSpace !== _.MetaSpace.People) {
this.notificationStateMap.delete(_.MetaSpace.People);
}
});
(0, _defineProperty2.default)(this, "showInHomeSpace", 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.default.shared().getUserIdForRoomId(room.roomId) ||
// put all DMs in the Home Space
room.getMyMembership() === _types.KnownMembership.Invite; // put all invites in the Home Space
});
// Method for resolving the impact of a single user's membership change in the given Space and its hierarchy
(0, _defineProperty2.default)(this, "onMemberUpdate", (space, userId) => {
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();
}
});
(0, _defineProperty2.default)(this, "onRoomsUpdate", () => {
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 _maps.EnhancedMap();
visibleRooms.forEach(room => {
if (![_types.KnownMembership.Join, _types.KnownMembership.Invite].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, parentPath) => {
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 === _types.KnownMembership.Join || m.membership === _types.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 = (0, _maps.mapDiff)(prevRoomsBySpace, this.roomIdsBySpace);
const userDiff = (0, _maps.mapDiff)(prevUsersBySpace, this.userIdsBySpace);
const spaceDiff = (0, _maps.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 (0, _sets.setHasDiff)(prevRoomsBySpace.get(k), this.roomIdsBySpace.get(k));
});
const usersChanged = userDiff.changed.filter(k => {
return (0, _sets.setHasDiff)(prevUsersBySpace.get(k), this.userIdsBySpace.get(k));
});
const spacesChanged = spaceDiff.changed.filter(k => {
return (0, _sets.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);
});
(0, _defineProperty2.default)(this, "switchSpaceIfNeeded", (roomId = _SDKContext.SdkContextClass.instance.roomViewStore.getRoomId()) => {
if (!roomId) return;
if (!this.isRoomInSpace(this.activeSpace, roomId) && !this.matrixClient?.getRoom(roomId)?.isSpaceRoom()) {
this.switchToRelatedSpace(roomId);
}
});
(0, _defineProperty2.default)(this, "switchToRelatedSpace", roomId => {
if (this.suggestedRooms.find(r => r.room_id === roomId)) return;
// try to find the canonical parent first
let parent = 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();
}
});
(0, _defineProperty2.default)(this, "onRoom", (room, newMembership, oldMembership) => {
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 === _types.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 === _types.KnownMembership.Join && room.roomId === _SDKContext.SdkContextClass.instance.roomViewStore.getRoomId()) {
this.switchSpaceIfNeeded(room.roomId);
}
}
return;
}
// Space
if (membership === _types.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 === _types.KnownMembership.Invite && membership !== _types.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 === _types.KnownMembership.Join && room.roomId === _SDKContext.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 === _types.KnownMembership.Leave && room.roomId === this.activeSpace) {
// user's active space has gone away, go back to home
this.goToFirstSpace(true);
}
});
(0, _defineProperty2.default)(this, "onRoomState", ev => {
const room = this.matrixClient?.getRoom(ev.getRoomId());
if (!this.matrixClient || !room) return;
switch (ev.getType()) {
case _matrix.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() !== _types.KnownMembership.Join &&
// target not joined
ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed
) {
this.loadSuggestedRooms(room);
}
break;
}
case _matrix.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 _matrix.EventType.RoomPowerLevels:
if (room.isSpaceRoom()) {
this.onRoomsUpdate();
}
break;
case _matrix.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
(0, _defineProperty2.default)(this, "onRoomStateMembers", ev => {
const room = this.matrixClient?.getRoom(ev.getRoomId());
const userId = ev.getStateKey();
if (room?.isSpaceRoom() &&
// only consider space rooms
_DMRoomMap.default.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);
}
});
(0, _defineProperty2.default)(this, "onRoomAccountData", (ev, room, lastEv) => {
if (room.isSpaceRoom() && ev.getType() === _matrix.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() === _matrix.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[_models.DefaultTagID.Favourite] !== !!newTags[_models.DefaultTagID.Favourite]) {
this.onRoomFavouriteChange(room);
}
}
});
(0, _defineProperty2.default)(this, "onAccountData", (ev, prevEv) => {
if (ev.getType() === _matrix.EventType.Direct) {
const previousRooms = new Set(Object.values(prevEv?.getContent() ?? {}).flat());
const currentRooms = new Set(Object.values(ev.getContent()).flat());
const diff = (0, _sets.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();
}
}
});
(0, _defineProperty2.default)(this, "getSpaceTagOrdering", space => {
if (this.spaceOrderLocalEchoMap.has(space.roomId)) return this.spaceOrderLocalEchoMap.get(space.roomId);
return validOrder(space.getAccountData(_matrix.EventType.SpaceOrder)?.getContent()?.order);
});
_SettingsStore.default.monitorSetting("Spaces.allRoomsInHome", null);
_SettingsStore.default.monitorSetting("Spaces.enabledMetaSpaces", null);
_SettingsStore.default.monitorSetting("Spaces.showPeopleInSpace", null);
_SettingsStore.default.monitorSetting("feature_dynamic_room_predecessors", null);
}
get invitedSpaces() {
return Array.from(this._invitedSpaces);
}
get enabledMetaSpaces() {
return this._enabledMetaSpaces;
}
get spacePanelSpaces() {
return this.rootSpaces;
}
get activeSpace() {
return this._activeSpace;
}
get activeSpaceRoom() {
if ((0, _.isMetaSpace)(this._activeSpace)) return null;
return this.matrixClient?.getRoom(this._activeSpace) ?? null;
}
get suggestedRooms() {
return this._suggestedRooms;
}
get allRoomsInHome() {
return this._allRoomsInHome;
}
setActiveRoomInSpace(space) {
if (!(0, _.isMetaSpace)(space) && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return;
if (space !== this.activeSpace) this.setActiveSpace(space, false);
let roomId;
if (space === _.MetaSpace.Home && this.allRoomsInHome) {
const hasMentions = _RoomNotificationStateStore.RoomNotificationStateStore.instance.globalState.hasMentions;
const lists = _RoomListStore.default.instance.orderedLists;
tagLoop: for (let i = 0; i < _RoomList.TAG_ORDER.length; i++) {
const t = _RoomList.TAG_ORDER[i];
if (!lists[t]) continue;
for (const room of lists[t]) {
const state = _RoomNotificationStateStore.RoomNotificationStateStore.instance.getRoomState(room);
if (hasMentions ? state.hasMentions : state.isUnread) {
roomId = room.roomId;
break tagLoop;
}
}
}
} else {
roomId = this.getNotificationState(space).getFirstRoomWithNotifications();
}
if (!!roomId) {
_dispatcher.default.dispatch({
action: _actions.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.
*/
setActiveSpace(space, contextSwitch = true) {
if (!space || !this.matrixClient || space === this.activeSpace) return;
let cliSpace = null;
if (!(0, _.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() !== _types.KnownMembership.Invite && this.matrixClient.getRoom(roomId)?.getMyMembership() === _types.KnownMembership.Join && this.isRoomInSpace(space, roomId)) {
_dispatcher.default.dispatch({
action: _actions.Action.ViewRoom,
room_id: roomId,
context_switch: true,
metricsTrigger: "WebSpaceContextSwitch"
});
} else if (cliSpace) {
_dispatcher.default.dispatch({
action: _actions.Action.ViewRoom,
room_id: space,
context_switch: true,
metricsTrigger: "WebSpaceContextSwitch"
});
} else {
_dispatcher.default.dispatch({
action: _actions.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);
}
}
async loadSuggestedRooms(space) {
const suggestedRooms = await this.fetchSuggestedRooms(space);
if (this._activeSpace === space.roomId) {
this._suggestedRooms = suggestedRooms;
this.emit(_.UPDATE_SUGGESTED_ROOMS, this._suggestedRooms);
}
}
addRoomToSpace(space, roomId, via, suggested = false) {
return this.matrixClient.sendStateEvent(space.roomId, _matrix.EventType.SpaceChild, {
via,
suggested
}, roomId);
}
getChildren(spaceId) {
const room = this.matrixClient?.getRoom(spaceId);
const childEvents = room?.currentState.getStateEvents(_matrix.EventType.SpaceChild).filter(ev => ev.getContent()?.via);
return (0, _lodash.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() === _types.KnownMembership.Join || room?.getMyMembership() === _types.KnownMembership.Invite;
}) || [];
}
getChildRooms(spaceId) {
return this.getChildren(spaceId).filter(r => !r.isSpaceRoom());
}
getChildSpaces(spaceId) {
// 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() === _types.KnownMembership.Join);
}
getParents(roomId, canonicalOnly = false) {
if (!this.matrixClient) return [];
const userId = this.matrixClient.getSafeUserId();
const room = this.matrixClient.getRoom(roomId);
const events = room?.currentState.getStateEvents(_matrix.EventType.SpaceParent) ?? [];
return (0, _arrays.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(_matrix.EventType.SpaceChild, roomId);
if (!parent?.currentState.maySendStateEvent(_matrix.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;
}));
}
getCanonicalParent(roomId) {
const parents = this.getParents(roomId, true);
return (0, _lodash.sortBy)(parents, r => r.roomId)?.[0] || null;
}
getKnownParents(roomId, includeAncestors) {
if (includeAncestors) {
return (0, _flattenSpaceHierarchy.flattenSpaceHierarchy)(this.parentMap, this.parentMap, roomId);
}
return this.parentMap.get(roomId) || new Set();
}
isRoomInSpace(space, roomId, includeDescendantSpaces = true) {
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.default.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 (!(0, _.isMetaSpace)(space) && this.getSpaceFilteredUserIds(space, includeDescendantSpaces)?.has(dmPartner) && _SettingsStore.default.getValue("Spaces.showPeopleInSpace", space)) {
return true;
}
return false;
}
static isInSpace(member) {
return member?.membership === _types.KnownMembership.Join || member?.membership === _types.KnownMembership.Invite;
}
notifyIfOrderChanged() {
const rootSpaces = this.sortRootSpaces(this.rootSpaces);
if ((0, _arrays.arrayHasOrderChange)(this.rootSpaces, rootSpaces)) {
this.rootSpaces = rootSpaces;
this.emit(_.UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces);
}
}
onRoomFavouriteChange(room) {
if (this.enabledMetaSpaces.includes(_.MetaSpace.Favourites)) {
if (room.tags[_models.DefaultTagID.Favourite]) {
this.roomIdsBySpace.get(_.MetaSpace.Favourites)?.add(room.roomId);
} else {
this.roomIdsBySpace.get(_.MetaSpace.Favourites)?.delete(room.roomId);
}
this.emit(_.MetaSpace.Favourites);
}
}
onRoomDmChange(room, isDm) {
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);
}
}
}
async reset() {
this.rootSpaces = [];
this.parentMap = new _maps.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 = [];
}
async onNotReady() {
if (this.matrixClient) {
this.matrixClient.removeListener(_matrix.ClientEvent.Room, this.onRoom);
this.matrixClient.removeListener(_matrix.RoomEvent.MyMembership, this.onRoom);
this.matrixClient.removeListener(_matrix.RoomEvent.AccountData, this.onRoomAccountData);
this.matrixClient.removeListener(_matrix.RoomStateEvent.Events, this.onRoomState);
this.matrixClient.removeListener(_matrix.RoomStateEvent.Members, this.onRoomStateMembers);
this.matrixClient.removeListener(_matrix.ClientEvent.AccountData, this.onAccountData);
}
await this.reset();
}
async onReady() {
if (!this.matrixClient) return;
this.matrixClient.on(_matrix.ClientEvent.Room, this.onRoom);
this.matrixClient.on(_matrix.RoomEvent.MyMembership, this.onRoom);
this.matrixClient.on(_matrix.RoomEvent.AccountData, this.onRoomAccountData);
this.matrixClient.on(_matrix.RoomStateEvent.Events, this.onRoomState);
this.matrixClient.on(_matrix.RoomStateEvent.Members, this.onRoomStateMembers);
this.matrixClient.on(_matrix.ClientEvent.AccountData, this.onAccountData);
const oldMetaSpaces = this._enabledMetaSpaces;
const enabledMetaSpaces = _SettingsStore.default.getValue("Spaces.enabledMetaSpaces");
this._enabledMetaSpaces = metaSpaceOrder.filter(k => enabledMetaSpaces[k]);
this._allRoomsInHome = _SettingsStore.default.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 ((0, _arrays.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 && (!(0, _.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();
}
}
sendUserProperties() {
const enabled = new Set(this.enabledMetaSpaces);
_PosthogAnalytics.PosthogAnalytics.instance.setProperty("WebMetaSpaceHomeEnabled", enabled.has(_.MetaSpace.Home));
_PosthogAnalytics.PosthogAnalytics.instance.setProperty("WebMetaSpaceHomeAllRooms", this.allRoomsInHome);
_PosthogAnalytics.PosthogAnalytics.instance.setProperty("WebMetaSpacePeopleEnabled", enabled.has(_.MetaSpace.People));
_PosthogAnalytics.PosthogAnalytics.instance.setProperty("WebMetaSpaceFavouritesEnabled", enabled.has(_.MetaSpace.Favourites));
_PosthogAnalytics.PosthogAnalytics.instance.setProperty("WebMetaSpaceOrphansEnabled", enabled.has(_.MetaSpace.Orphans));
}
goToFirstSpace(contextSwitch = false) {
this.setActiveSpace(this.enabledMetaSpaces[0] ?? this.spacePanelSpaces[0]?.roomId, contextSwitch);
}
async onAction(payload) {
if (!this.matrixClient) return;
switch (payload.action) {
case _actions.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 === _matrix.RoomType.Space;
if (payload.context_switch || payload.justCreatedOpts && !isSpace) break;
let roomId = payload.room_id;
if (payload.room_alias && !roomId) {
roomId = (0, _RoomAliasCache.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 _actions.Action.ViewHomePage:
if (!payload.context_switch && this.enabledMetaSpaces.includes(_.MetaSpace.Home)) {
this.setActiveSpace(_.MetaSpace.Home, false);
window.localStorage.setItem(getSpaceContextKey(this.activeSpace), "");
}
break;
case _actions.Action.AfterLeaveRoom:
if (!(0, _.isMetaSpace)(this._activeSpace) && payload.room_id === this._activeSpace) {
// User has left the current space, go to first space
this.goToFirstSpace(true);
}
break;
case _actions.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 _actions.Action.SettingUpdated:
{
switch (payload.settingName) {
case "Spaces.allRoomsInHome":
{
const newValue = _SettingsStore.default.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.default.getValue("Spaces.enabledMetaSpaces");
const enabledMetaSpaces = metaSpaceOrder.filter(k => newValue[k]);
if ((0, _arrays.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 ((0, _.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