diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 3e685a2839..6addeec354 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -53,10 +53,16 @@ import { } 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"; -interface IState {} +interface IState { } const ACTIVE_SPACE_LS_KEY = "mx_active_space"; @@ -101,10 +107,19 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private parentMap = new EnhancedMap>(); // Map from SpaceKey to SpaceNotificationState instance representing that space private notificationStateMap = new Map(); - // Map from space key to Set of room IDs that should be shown as part of that space's filter - private spaceFilteredRooms = new Map>(); // won't contain MetaSpace.People - // Map from space ID to Set of user IDs that should be shown as part of that space's filter - private spaceFilteredUsers = new Map>(); + // Map from SpaceKey to Set of room IDs that are direct descendants of that space + private roomIdsBySpace: SpaceEntityMap = 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 + private childSpacesBySpace: SpaceDescendantMap = new Map>(); + // Map from space id to Set of user IDs that are direct descendants of that space + private userIdsBySpace: SpaceEntityMap = new Map>(); + // cache that stores the aggregated lists of roomIdsBySpace and userIdsBySpace + // cleared on changes + private _aggregatedSpaceCache = { + roomIdsBySpace: new Map>(), + userIdsBySpace: new Map>(), + }; // The space currently selected in the Space Panel private _activeSpace?: SpaceKey = MetaSpace.Home; // set properly by onReady private _suggestedRooms: ISuggestedRoom[] = []; @@ -352,16 +367,19 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return sortBy(parents, r => r.roomId)?.[0] || null; } - public getKnownParents(roomId: string): Set { + public getKnownParents(roomId: string, includeAncestors?: boolean): Set { + if (includeAncestors) { + return flattenSpaceHierarchy(this.parentMap, this.parentMap, roomId); + } return this.parentMap.get(roomId) || new Set(); } - public isRoomInSpace(space: SpaceKey, roomId: string): boolean { + public isRoomInSpace(space: SpaceKey, roomId: string, includeDescendantSpaces = true): boolean { if (space === MetaSpace.Home && this.allRoomsInHome) { return true; } - if (this.spaceFilteredRooms.get(space)?.has(roomId)) { + if (this.getSpaceFilteredRoomIds(space, includeDescendantSpaces)?.has(roomId)) { return true; } @@ -377,7 +395,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (!isMetaSpace(space) && - this.spaceFilteredUsers.get(space)?.has(dmPartner) && + this.getSpaceFilteredUserIds(space, includeDescendantSpaces)?.has(dmPartner) && SettingsStore.getValue("Spaces.showPeopleInSpace", space) ) { return true; @@ -386,21 +404,46 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return false; } - public getSpaceFilteredRoomIds = (space: SpaceKey): Set => { + // get all rooms in a space + // including descendant spaces + public getSpaceFilteredRoomIds = ( + space: SpaceKey, includeDescendantSpaces = true, useCache = true, + ): Set => { if (space === MetaSpace.Home && this.allRoomsInHome) { return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); } - return this.spaceFilteredRooms.get(space) || new Set(); + + // 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): Set => { + public getSpaceFilteredUserIds = ( + space: SpaceKey, includeDescendantSpaces = true, useCache = true, + ): Set => { if (space === MetaSpace.Home && this.allRoomsInHome) { return undefined; } - if (isMetaSpace(space)) return undefined; - return this.spaceFilteredUsers.get(space) || new Set(); + 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): void => { const stack = [rootSpace]; while (stack.length) { @@ -503,10 +546,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private rebuildHomeSpace = () => { if (this.allRoomsInHome) { // this is a special-case to not have to maintain a set of all rooms - this.spaceFilteredRooms.delete(MetaSpace.Home); + this.roomIdsBySpace.delete(MetaSpace.Home); } else { const rooms = new Set(this.matrixClient.getVisibleRooms().filter(this.showInHomeSpace).map(r => r.roomId)); - this.spaceFilteredRooms.set(MetaSpace.Home, rooms); + this.roomIdsBySpace.set(MetaSpace.Home, rooms); } if (this.activeSpace === MetaSpace.Home) { @@ -521,14 +564,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (enabledMetaSpaces.has(MetaSpace.Home)) { this.rebuildHomeSpace(); } else { - this.spaceFilteredRooms.delete(MetaSpace.Home); + this.roomIdsBySpace.delete(MetaSpace.Home); } if (enabledMetaSpaces.has(MetaSpace.Favourites)) { const favourites = visibleRooms.filter(r => r.tags[DefaultTagID.Favourite]); - this.spaceFilteredRooms.set(MetaSpace.Favourites, new Set(favourites.map(r => r.roomId))); + this.roomIdsBySpace.set(MetaSpace.Favourites, new Set(favourites.map(r => r.roomId))); } else { - this.spaceFilteredRooms.delete(MetaSpace.Favourites); + this.roomIdsBySpace.delete(MetaSpace.Favourites); } // The People metaspace doesn't need maintaining @@ -540,7 +583,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // filter out DMs and rooms with >0 parents return !this.parentMap.get(r.roomId)?.size && !DMRoomMap.shared().getUserIdForRoomId(r.roomId); }); - this.spaceFilteredRooms.set(MetaSpace.Orphans, new Set(orphans.map(r => r.roomId))); + this.roomIdsBySpace.set(MetaSpace.Orphans, new Set(orphans.map(r => r.roomId))); } if (isMetaSpace(this.activeSpace)) { @@ -561,7 +604,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (!spaces) { - spaces = [...this.spaceFilteredRooms.keys()]; + spaces = [...this.roomIdsBySpace.keys()]; if (dmBadgeSpace === MetaSpace.People) { spaces.push(MetaSpace.People); } @@ -573,13 +616,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { 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() || !this.spaceFilteredRooms.get(s).has(room.roomId)) return false; + if (room.isSpaceRoom() || !flattenedRoomsForSpace.has(room.roomId)) return false; if (dmBadgeSpace && DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { return s === dmBadgeSpace; @@ -606,85 +651,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return member.membership === "join" || member.membership === "invite"; } - private static getSpaceMembers(space: Room): string[] { - return space.getMembers().filter(SpaceStoreClass.isInSpace).map(m => m.userId); - } - // Method for resolving the impact of a single user's membership change in the given Space and its hierarchy private onMemberUpdate = (space: Room, userId: string) => { const inSpace = SpaceStoreClass.isInSpace(space.getMember(userId)); - if (this.spaceFilteredUsers.get(space.roomId).has(userId)) { - if (inSpace) return; // nothing to do, user was already joined to subspace - if (this.getChildSpaces(space.roomId).some(s => this.spaceFilteredUsers.get(s.roomId).has(userId))) { - return; // nothing to do, this user leaving will have no effect as they are in a subspace - } - } else if (!inSpace) { - return; // nothing to do, user already not in the list + if (inSpace) { + this.userIdsBySpace.get(space.roomId)?.add(userId); + } else { + this.userIdsBySpace.get(space.roomId)?.delete(userId); } - const seen = new Set(); - const stack = [space.roomId]; - while (stack.length) { - const spaceId = stack.pop(); - seen.add(spaceId); + // bust cache + this._aggregatedSpaceCache.userIdsBySpace.clear(); - if (inSpace) { - // add to our list and to that of all of our parents - this.spaceFilteredUsers.get(spaceId).add(userId); - } else { - // remove from our list and that of all of our parents until we hit a parent with this user - this.spaceFilteredUsers.get(spaceId).delete(userId); - } - - this.getKnownParents(spaceId).forEach(parentId => { - if (seen.has(parentId)) return; - const parent = this.matrixClient.getRoom(parentId); - // because spaceFilteredUsers is cumulative, if we are removing from lower in the hierarchy, - // but the member is present higher in the hierarchy we must take care not to wrongly over-remove them. - if (inSpace || !SpaceStoreClass.isInSpace(parent.getMember(userId))) { - stack.push(parentId); - } - }); - } + const affectedParentSpaceIds = this.getKnownParents(space.roomId, true); + this.emit(space.roomId); + affectedParentSpaceIds.forEach(spaceId => this.emit(spaceId)); this.switchSpaceIfNeeded(); }; - private onMembersUpdate = (space: Room, seen = new Set()) => { - // Update this space's membership list - const userIds = new Set(SpaceStoreClass.getSpaceMembers(space)); - // We only need to look one level with children - // as any further descendants will already be in their parent's superset - this.getChildSpaces(space.roomId).forEach(subspace => { - SpaceStoreClass.getSpaceMembers(subspace).forEach(userId => { - userIds.add(userId); - }); - }); - this.spaceFilteredUsers.set(space.roomId, userIds); - this.emit(space.roomId); - - // Traverse all parents and update them too - this.getKnownParents(space.roomId).forEach(parentId => { - if (seen.has(parentId)) return; - const parent = this.matrixClient.getRoom(parentId); - if (parent) { - const newSeen = new Set(seen); - newSeen.add(parentId); - this.onMembersUpdate(parent, newSeen); - } - }); - }; - private onRoomsUpdate = () => { const visibleRooms = this.matrixClient.getVisibleRooms(); - const oldFilteredRooms = this.spaceFilteredRooms; - const oldFilteredUsers = this.spaceFilteredUsers; - this.spaceFilteredRooms = new Map(); - this.spaceFilteredUsers = new Map(); + 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>(); @@ -698,26 +697,28 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, // reusing results from like subtrees. - const fn = (spaceId: string, parentPath: Set): [Set, Set] => { + const traverseSpace = (spaceId: string, parentPath: Set): [Set, Set] => { if (parentPath.has(spaceId)) return; // prevent cycles - // reuse existing results if multiple similar branches exist - if (this.spaceFilteredRooms.has(spaceId) && this.spaceFilteredUsers.has(spaceId)) { - return [this.spaceFilteredRooms.get(spaceId), this.spaceFilteredUsers.get(spaceId)]; + 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 === "join" || m.membership === "invite"; }).map(m => m.userId)); const newPath = new Set(parentPath).add(spaceId); + childSpaces.forEach(childSpace => { - const [rooms, users] = fn(childSpace.roomId, newPath) ?? []; - rooms?.forEach(roomId => roomIds.add(roomId)); - users?.forEach(userId => userIds.add(userId)); + traverseSpace(childSpace.roomId, newPath) ?? []; }); hiddenChildren.get(spaceId)?.forEach(roomId => { roomIds.add(roomId); @@ -727,33 +728,50 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const expandedRoomIds = new Set(Array.from(roomIds).flatMap(roomId => { return this.matrixClient.getRoomUpgradeHistory(roomId, true).map(r => r.roomId); })); - this.spaceFilteredRooms.set(spaceId, expandedRoomIds); - this.spaceFilteredUsers.set(spaceId, userIds); + + this.roomIdsBySpace.set(spaceId, expandedRoomIds); + + this.userIdsBySpace.set(spaceId, userIds); return [expandedRoomIds, userIds]; }; - fn(s.roomId, new Set()); + traverseSpace(s.roomId, new Set()); }); - const roomDiff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms); - const userDiff = mapDiff(oldFilteredUsers, this.spaceFilteredUsers); + 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(oldFilteredRooms.get(k), this.spaceFilteredRooms.get(k)); + return setHasDiff(prevRoomsBySpace.get(k), this.roomIdsBySpace.get(k)); }); const usersChanged = userDiff.changed.filter(k => { - return setHasDiff(oldFilteredUsers.get(k), this.spaceFilteredUsers.get(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); }); @@ -786,7 +804,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // otherwise, try to find a root space which contains this room if (!parent) { - parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(roomId))?.roomId; + parent = this.rootSpaces.find(s => this.isRoomInSpace(s.roomId, roomId))?.roomId; } // otherwise, try to find a metaspace which contains this room @@ -869,6 +887,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private onRoomState = (ev: MatrixEvent) => { const room = this.matrixClient.getRoom(ev.getRoomId()); + if (!room) return; switch (ev.getType()) { @@ -917,6 +936,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then private onRoomStateMembers = (ev: MatrixEvent) => { const room = this.matrixClient.getRoom(ev.getRoomId()); + 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 @@ -949,9 +969,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private onRoomFavouriteChange(room: Room) { if (this.enabledMetaSpaces.includes(MetaSpace.Favourites)) { if (room.tags[DefaultTagID.Favourite]) { - this.spaceFilteredRooms.get(MetaSpace.Favourites).add(room.roomId); + this.roomIdsBySpace.get(MetaSpace.Favourites).add(room.roomId); } else { - this.spaceFilteredRooms.get(MetaSpace.Favourites).delete(room.roomId); + this.roomIdsBySpace.get(MetaSpace.Favourites).delete(room.roomId); } this.emit(MetaSpace.Favourites); } @@ -961,11 +981,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const enabledMetaSpaces = new Set(this.enabledMetaSpaces); if (!this.allRoomsInHome && enabledMetaSpaces.has(MetaSpace.Home)) { - const homeRooms = this.spaceFilteredRooms.get(MetaSpace.Home); + const homeRooms = this.roomIdsBySpace.get(MetaSpace.Home); if (this.showInHomeSpace(room)) { homeRooms?.add(room.roomId); - } else if (!this.spaceFilteredRooms.get(MetaSpace.Orphans).has(room.roomId)) { - this.spaceFilteredRooms.get(MetaSpace.Home)?.delete(room.roomId); + } else if (!this.roomIdsBySpace.get(MetaSpace.Orphans).has(room.roomId)) { + this.roomIdsBySpace.get(MetaSpace.Home)?.delete(room.roomId); } this.emit(MetaSpace.Home); @@ -976,7 +996,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (enabledMetaSpaces.has(MetaSpace.Orphans) || enabledMetaSpaces.has(MetaSpace.Home)) { - if (isDm && this.spaceFilteredRooms.get(MetaSpace.Orphans).delete(room.roomId)) { + if (isDm && this.roomIdsBySpace.get(MetaSpace.Orphans).delete(room.roomId)) { this.emit(MetaSpace.Orphans); this.emit(MetaSpace.Home); } @@ -1006,8 +1026,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.rootSpaces = []; this.parentMap = new EnhancedMap(); this.notificationStateMap = new Map(); - this.spaceFilteredRooms = new Map(); - this.spaceFilteredUsers = 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(); diff --git a/src/stores/spaces/flattenSpaceHierarchy.ts b/src/stores/spaces/flattenSpaceHierarchy.ts new file mode 100644 index 0000000000..9d94cd4a8d --- /dev/null +++ b/src/stores/spaces/flattenSpaceHierarchy.ts @@ -0,0 +1,76 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { SpaceKey } from "."; + +export type SpaceEntityMap = Map>; +export type SpaceDescendantMap = Map>; + +const traverseSpaceDescendants = ( + spaceDescendantMap: SpaceDescendantMap, + spaceId: SpaceKey, + flatSpace = new Set(), +): Set => { + flatSpace.add(spaceId); + const descendentSpaces = spaceDescendantMap.get(spaceId); + descendentSpaces?.forEach( + descendantSpaceId => { + if (!flatSpace.has(descendantSpaceId)) { + traverseSpaceDescendants(spaceDescendantMap, descendantSpaceId, flatSpace); + } + }, + ); + + return flatSpace; +}; + +/** + * Helper function to traverse space heirachy and flatten + * @param spaceEntityMap ie map of rooms or dm userIds + * @param spaceDescendantMap map of spaces and their children + * @returns set of all rooms + */ +export const flattenSpaceHierarchy = ( + spaceEntityMap: SpaceEntityMap, + spaceDescendantMap: SpaceDescendantMap, + spaceId: SpaceKey, +): Set => { + const flattenedSpaceIds = traverseSpaceDescendants(spaceDescendantMap, spaceId); + const flattenedRooms = new Set(); + + flattenedSpaceIds.forEach(id => { + const roomIds = spaceEntityMap.get(id); + roomIds?.forEach(flattenedRooms.add, flattenedRooms); + }); + + return flattenedRooms; +}; + +export const flattenSpaceHierarchyWithCache = (cache: SpaceEntityMap) => ( + spaceEntityMap: SpaceEntityMap, + spaceDescendantMap: SpaceDescendantMap, + spaceId: SpaceKey, + useCache = true, +): Set => { + if (useCache && cache.has(spaceId)) { + return cache.get(spaceId); + } + const result = flattenSpaceHierarchy(spaceEntityMap, spaceDescendantMap, spaceId); + cache.set(spaceId, result); + + return result; +}; + diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index ffb1ef9166..49e0144990 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -18,6 +18,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import "../skinned-sdk"; // Must be first for skinning to work + import SpaceStore from "../../src/stores/spaces/SpaceStore"; import { MetaSpace, @@ -58,9 +59,11 @@ const invite2 = "!invite2:server"; const room1 = "!room1:server"; const room2 = "!room2:server"; const room3 = "!room3:server"; +const room4 = "!room4:server"; const space1 = "!space1:server"; const space2 = "!space2:server"; const space3 = "!space3:server"; +const space4 = "!space4:server"; const getUserIdForRoomId = jest.fn(roomId => { return { @@ -303,11 +306,13 @@ describe("SpaceStore", () => { describe("test fixture 1", () => { beforeEach(async () => { - [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1, room2, room3] + [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1, room2, room3, room4] .forEach(mkRoom); mkSpace(space1, [fav1, room1]); mkSpace(space2, [fav1, fav2, fav3, room1]); mkSpace(space3, [invite2]); + mkSpace(space4, [room4, fav2, space2, space3]); + client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); [fav1, fav2, fav3].forEach(roomId => { @@ -383,85 +388,144 @@ describe("SpaceStore", () => { await run(); }); - it("home space contains orphaned rooms", () => { - expect(store.isRoomInSpace(MetaSpace.Home, orphan1)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.Home, orphan2)).toBeTruthy(); - }); + describe('isRoomInSpace()', () => { + it("home space contains orphaned rooms", () => { + expect(store.isRoomInSpace(MetaSpace.Home, orphan1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, orphan2)).toBeTruthy(); + }); - it("home space does not contain all favourites", () => { - expect(store.isRoomInSpace(MetaSpace.Home, fav1)).toBeFalsy(); - expect(store.isRoomInSpace(MetaSpace.Home, fav2)).toBeFalsy(); - expect(store.isRoomInSpace(MetaSpace.Home, fav3)).toBeFalsy(); - }); + it("home space does not contain all favourites", () => { + expect(store.isRoomInSpace(MetaSpace.Home, fav1)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav2)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav3)).toBeFalsy(); + }); - it("home space contains dm rooms", () => { - expect(store.isRoomInSpace(MetaSpace.Home, dm1)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.Home, dm2)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.Home, dm3)).toBeTruthy(); - }); + it("home space contains dm rooms", () => { + expect(store.isRoomInSpace(MetaSpace.Home, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm3)).toBeTruthy(); + }); - it("home space contains invites", () => { - expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeTruthy(); - }); + it("home space contains invites", () => { + expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeTruthy(); + }); - it("home space contains invites even if they are also shown in a space", () => { - expect(store.isRoomInSpace(MetaSpace.Home, invite2)).toBeTruthy(); - }); + it("home space contains invites even if they are also shown in a space", () => { + expect(store.isRoomInSpace(MetaSpace.Home, invite2)).toBeTruthy(); + }); - it("all rooms space does contain rooms/low priority even if they are also shown in a space", async () => { - await setShowAllRooms(true); - expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeTruthy(); - }); + it( + "all rooms space does contain rooms/low priority even if they are also shown in a space", + async () => { + await setShowAllRooms(true); + expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeTruthy(); + }); - it("favourites space does contain favourites even if they are also shown in a space", async () => { - expect(store.isRoomInSpace(MetaSpace.Favourites, fav1)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.Favourites, fav2)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.Favourites, fav3)).toBeTruthy(); - }); + it("favourites space does contain favourites even if they are also shown in a space", async () => { + expect(store.isRoomInSpace(MetaSpace.Favourites, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav3)).toBeTruthy(); + }); - it("people space does contain people even if they are also shown in a space", async () => { - expect(store.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.People, dm2)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.People, dm3)).toBeTruthy(); - }); + it("people space does contain people even if they are also shown in a space", async () => { + expect(store.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm3)).toBeTruthy(); + }); - it("orphans space does contain orphans even if they are also shown in all rooms", async () => { - await setShowAllRooms(true); - expect(store.isRoomInSpace(MetaSpace.Orphans, orphan1)).toBeTruthy(); - expect(store.isRoomInSpace(MetaSpace.Orphans, orphan2)).toBeTruthy(); - }); + it("orphans space does contain orphans even if they are also shown in all rooms", async () => { + await setShowAllRooms(true); + expect(store.isRoomInSpace(MetaSpace.Orphans, orphan1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Orphans, orphan2)).toBeTruthy(); + }); - it("home space doesn't contain rooms/low priority if they are also shown in a space", async () => { - await setShowAllRooms(false); - expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeFalsy(); - }); + it("home space doesn't contain rooms/low priority if they are also shown in a space", async () => { + await setShowAllRooms(false); + expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeFalsy(); + }); - it("space contains child rooms", () => { - expect(store.isRoomInSpace(space1, fav1)).toBeTruthy(); - expect(store.isRoomInSpace(space1, room1)).toBeTruthy(); - }); + it("space contains child rooms", () => { + expect(store.isRoomInSpace(space1, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space1, room1)).toBeTruthy(); + }); - it("space contains child favourites", () => { - expect(store.isRoomInSpace(space2, fav1)).toBeTruthy(); - expect(store.isRoomInSpace(space2, fav2)).toBeTruthy(); - expect(store.isRoomInSpace(space2, fav3)).toBeTruthy(); - expect(store.isRoomInSpace(space2, room1)).toBeTruthy(); - }); + it("returns true for all sub-space child rooms when includeSubSpaceRooms is undefined", () => { + expect(store.isRoomInSpace(space4, room4)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav2)).toBeTruthy(); + // space2's rooms + expect(store.isRoomInSpace(space4, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav3)).toBeTruthy(); + expect(store.isRoomInSpace(space4, room1)).toBeTruthy(); + // space3's rooms + expect(store.isRoomInSpace(space4, invite2)).toBeTruthy(); + }); - it("space contains child invites", () => { - expect(store.isRoomInSpace(space3, invite2)).toBeTruthy(); - }); + it("returns true for all sub-space child rooms when includeSubSpaceRooms is true", () => { + expect(store.isRoomInSpace(space4, room4, true)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav2, true)).toBeTruthy(); + // space2's rooms + expect(store.isRoomInSpace(space4, fav1, true)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav3, true)).toBeTruthy(); + expect(store.isRoomInSpace(space4, room1, true)).toBeTruthy(); + // space3's rooms + expect(store.isRoomInSpace(space4, invite2, true)).toBeTruthy(); + }); - it("spaces contain dms which you have with members of that space", () => { - expect(store.isRoomInSpace(space1, dm1)).toBeTruthy(); - expect(store.isRoomInSpace(space2, dm1)).toBeFalsy(); - expect(store.isRoomInSpace(space3, dm1)).toBeFalsy(); - expect(store.isRoomInSpace(space1, dm2)).toBeFalsy(); - expect(store.isRoomInSpace(space2, dm2)).toBeTruthy(); - expect(store.isRoomInSpace(space3, dm2)).toBeFalsy(); - expect(store.isRoomInSpace(space1, dm3)).toBeFalsy(); - expect(store.isRoomInSpace(space2, dm3)).toBeFalsy(); - expect(store.isRoomInSpace(space3, dm3)).toBeFalsy(); + it("returns false for all sub-space child rooms when includeSubSpaceRooms is false", () => { + // direct children + expect(store.isRoomInSpace(space4, room4, false)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav2, false)).toBeTruthy(); + // space2's rooms + expect(store.isRoomInSpace(space4, fav1, false)).toBeFalsy(); + expect(store.isRoomInSpace(space4, fav3, false)).toBeFalsy(); + expect(store.isRoomInSpace(space4, room1, false)).toBeFalsy(); + // space3's rooms + expect(store.isRoomInSpace(space4, invite2, false)).toBeFalsy(); + }); + + it("space contains all sub-space's child rooms", () => { + expect(store.isRoomInSpace(space4, room4)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav2)).toBeTruthy(); + // space2's rooms + expect(store.isRoomInSpace(space4, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav3)).toBeTruthy(); + expect(store.isRoomInSpace(space4, room1)).toBeTruthy(); + // space3's rooms + expect(store.isRoomInSpace(space4, invite2)).toBeTruthy(); + }); + + it("space contains child favourites", () => { + expect(store.isRoomInSpace(space2, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav2)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav3)).toBeTruthy(); + expect(store.isRoomInSpace(space2, room1)).toBeTruthy(); + }); + + it("space contains child invites", () => { + expect(store.isRoomInSpace(space3, invite2)).toBeTruthy(); + }); + + it("spaces contain dms which you have with members of that space", () => { + expect(store.isRoomInSpace(space1, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, dm1)).toBeFalsy(); + expect(store.isRoomInSpace(space3, dm1)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm2)).toBeFalsy(); + expect(store.isRoomInSpace(space2, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(space3, dm2)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space2, dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space3, dm3)).toBeFalsy(); + }); + + it('uses cached aggregated rooms', () => { + const rooms = store.getSpaceFilteredRoomIds(space4, true); + expect(store.isRoomInSpace(space4, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space4, fav3)).toBeTruthy(); + expect(store.isRoomInSpace(space4, room1)).toBeTruthy(); + + // isRoomInSpace calls didn't rebuild room set + expect(rooms).toStrictEqual(store.getSpaceFilteredRoomIds(space4, true)); + }); }); it("dms are only added to Notification States for only the People Space", async () => { @@ -614,6 +678,115 @@ describe("SpaceStore", () => { expect(store.isRoomInSpace(space1, invite1)).toBeTruthy(); expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeTruthy(); }); + + describe('onRoomsUpdate()', () => { + beforeEach(() => { + [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1, room2, room3, room4] + .forEach(mkRoom); + mkSpace(space2, [fav1, fav2, fav3, room1]); + mkSpace(space3, [invite2]); + mkSpace(space4, [room4, fav2, space2, space3]); + mkSpace(space1, [fav1, room1, space4]); + }); + + const addChildRoom = (spaceId, childId) => { + const childEvent = mkEvent({ + event: true, + type: EventType.SpaceChild, + room: spaceId, + user: client.getUserId(), + skey: childId, + content: { via: [], canonical: true }, + ts: Date.now(), + }); + const spaceRoom = client.getRoom(spaceId); + spaceRoom.currentState.getStateEvents.mockImplementation( + testUtils.mockStateEventImplementation([childEvent]), + ); + + client.emit("RoomState.events", childEvent); + }; + + const addMember = (spaceId, user: RoomMember) => { + const memberEvent = mkEvent({ + event: true, + type: EventType.RoomMember, + room: spaceId, + user: client.getUserId(), + skey: user.userId, + content: { membership: 'join' }, + ts: Date.now(), + }); + const spaceRoom = client.getRoom(spaceId); + spaceRoom.currentState.getStateEvents.mockImplementation( + testUtils.mockStateEventImplementation([memberEvent]), + ); + spaceRoom.getMember.mockReturnValue(user); + + client.emit("RoomState.members", memberEvent); + }; + + it('emits events for parent spaces when child room is added', async () => { + await run(); + + const room5 = mkRoom('!room5:server'); + const emitSpy = jest.spyOn(store, 'emit').mockClear(); + // add room5 into space2 + addChildRoom(space2, room5.roomId); + + expect(emitSpy).toHaveBeenCalledWith(space2); + // space2 is subspace of space4 + expect(emitSpy).toHaveBeenCalledWith(space4); + // space4 is a subspace of space1 + expect(emitSpy).toHaveBeenCalledWith(space1); + expect(emitSpy).not.toHaveBeenCalledWith(space3); + }); + + it('updates rooms state when a child room is added', async () => { + await run(); + const room5 = mkRoom('!room5:server'); + + expect(store.isRoomInSpace(space2, room5.roomId)).toBeFalsy(); + expect(store.isRoomInSpace(space4, room5.roomId)).toBeFalsy(); + + // add room5 into space2 + addChildRoom(space2, room5.roomId); + + expect(store.isRoomInSpace(space2, room5.roomId)).toBeTruthy(); + // space2 is subspace of space4 + expect(store.isRoomInSpace(space4, room5.roomId)).toBeTruthy(); + // space4 is subspace of space1 + expect(store.isRoomInSpace(space1, room5.roomId)).toBeTruthy(); + }); + + it('emits events for parent spaces when a member is added', async () => { + await run(); + + const emitSpy = jest.spyOn(store, 'emit').mockClear(); + // add into space2 + addMember(space2, dm1Partner); + + expect(emitSpy).toHaveBeenCalledWith(space2); + // space2 is subspace of space4 + expect(emitSpy).toHaveBeenCalledWith(space4); + // space4 is a subspace of space1 + expect(emitSpy).toHaveBeenCalledWith(space1); + expect(emitSpy).not.toHaveBeenCalledWith(space3); + }); + + it('updates users state when a member is added', async () => { + await run(); + + expect(store.getSpaceFilteredUserIds(space2)).toEqual(new Set([])); + + // add into space2 + addMember(space2, dm1Partner); + + expect(store.getSpaceFilteredUserIds(space2)).toEqual(new Set([dm1Partner.userId])); + expect(store.getSpaceFilteredUserIds(space4)).toEqual(new Set([dm1Partner.userId])); + expect(store.getSpaceFilteredUserIds(space1)).toEqual(new Set([dm1Partner.userId])); + }); + }); }); describe("active space switching tests", () => { diff --git a/test/stores/room-list/filters/SpaceFilterCondition-test.ts b/test/stores/room-list/filters/SpaceFilterCondition-test.ts new file mode 100644 index 0000000000..0f1586dcd7 --- /dev/null +++ b/test/stores/room-list/filters/SpaceFilterCondition-test.ts @@ -0,0 +1,196 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked } from 'jest-mock'; + +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { FILTER_CHANGED } from "../../../../src/stores/room-list/filters/IFilterCondition"; +import { SpaceFilterCondition } from "../../../../src/stores/room-list/filters/SpaceFilterCondition"; +import { MetaSpace } from "../../../../src/stores/spaces"; +import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; + +jest.mock("../../../../src/settings/SettingsStore"); +jest.mock("../../../../src/stores/spaces/SpaceStore", () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const EventEmitter = require('events'); + class MockSpaceStore extends EventEmitter { + isRoomInSpace = jest.fn(); + getSpaceFilteredUserIds = jest.fn().mockReturnValue(new Set([])); + getSpaceFilteredRoomIds = jest.fn().mockReturnValue(new Set([])); + } + return { instance: new MockSpaceStore() }; +}); + +const SettingsStoreMock = mocked(SettingsStore); +const SpaceStoreInstanceMock = mocked(SpaceStore.instance); + +jest.useFakeTimers(); + +describe('SpaceFilterCondition', () => { + const space1 = '!space1:server'; + const space2 = '!space2:server'; + const room1Id = '!r1:server'; + const room2Id = '!r2:server'; + const room3Id = '!r3:server'; + const user1Id = '@u1:server'; + const user2Id = '@u2:server'; + const user3Id = '@u3:server'; + const makeMockGetValue = (settings = {}) => (settingName, space) => settings[settingName]?.[space] || false; + + beforeEach(() => { + jest.resetAllMocks(); + SettingsStoreMock.getValue.mockClear().mockImplementation(makeMockGetValue()); + SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([])); + SpaceStoreInstanceMock.isRoomInSpace.mockReturnValue(true); + }); + + const initFilter = (space): SpaceFilterCondition => { + const filter = new SpaceFilterCondition(); + filter.updateSpace(space); + jest.runOnlyPendingTimers(); + return filter; + }; + + describe('isVisible', () => { + const room1 = { roomId: room1Id } as unknown as Room; + it('calls isRoomInSpace correctly', () => { + const filter = initFilter(space1); + + expect(filter.isVisible(room1)).toEqual(true); + expect(SpaceStoreInstanceMock.isRoomInSpace).toHaveBeenCalledWith(space1, room1Id); + }); + }); + + describe('onStoreUpdate', () => { + it('emits filter changed event when updateSpace is called even without changes', async () => { + const filter = new SpaceFilterCondition(); + const emitSpy = jest.spyOn(filter, 'emit'); + filter.updateSpace(space1); + jest.runOnlyPendingTimers(); + expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); + }); + + describe('showPeopleInSpace setting', () => { + it('emits filter changed event when setting changes', async () => { + // init filter with setting true for space1 + SettingsStoreMock.getValue.mockImplementation(makeMockGetValue({ + ["Spaces.showPeopleInSpace"]: { [space1]: true }, + })); + const filter = initFilter(space1); + const emitSpy = jest.spyOn(filter, 'emit'); + + SettingsStoreMock.getValue.mockClear().mockImplementation(makeMockGetValue({ + ["Spaces.showPeopleInSpace"]: { [space1]: false }, + })); + + SpaceStoreInstanceMock.emit(space1); + jest.runOnlyPendingTimers(); + expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); + }); + + it('emits filter changed event when setting is false and space changes to a meta space', async () => { + // init filter with setting true for space1 + SettingsStoreMock.getValue.mockImplementation(makeMockGetValue({ + ["Spaces.showPeopleInSpace"]: { [space1]: false }, + })); + const filter = initFilter(space1); + const emitSpy = jest.spyOn(filter, 'emit'); + + filter.updateSpace(MetaSpace.Home); + jest.runOnlyPendingTimers(); + expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); + }); + }); + + it('does not emit filter changed event on store update when nothing changed', async () => { + const filter = initFilter(space1); + const emitSpy = jest.spyOn(filter, 'emit'); + SpaceStoreInstanceMock.emit(space1); + jest.runOnlyPendingTimers(); + expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED); + }); + + it('removes listener when updateSpace is called', async () => { + const filter = initFilter(space1); + filter.updateSpace(space2); + jest.runOnlyPendingTimers(); + const emitSpy = jest.spyOn(filter, 'emit'); + + // update mock so filter would emit change if it was listening to space1 + SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id])); + SpaceStoreInstanceMock.emit(space1); + jest.runOnlyPendingTimers(); + // no filter changed event + expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED); + }); + + it('removes listener when destroy is called', async () => { + const filter = initFilter(space1); + filter.destroy(); + jest.runOnlyPendingTimers(); + const emitSpy = jest.spyOn(filter, 'emit'); + + // update mock so filter would emit change if it was listening to space1 + SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id])); + SpaceStoreInstanceMock.emit(space1); + jest.runOnlyPendingTimers(); + // no filter changed event + expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED); + }); + + describe('when directChildRoomIds change', () => { + beforeEach(() => { + SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id, room2Id])); + }); + const filterChangedCases = [ + ['room added', [room1Id, room2Id, room3Id]], + ['room removed', [room1Id]], + ['room swapped', [room1Id, room3Id]], // same number of rooms with changes + ]; + + it.each(filterChangedCases)('%s', (_d, rooms) => { + const filter = initFilter(space1); + const emitSpy = jest.spyOn(filter, 'emit'); + + SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set(rooms)); + SpaceStoreInstanceMock.emit(space1); + jest.runOnlyPendingTimers(); + expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); + }); + }); + + describe('when user ids change', () => { + beforeEach(() => { + SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([user1Id, user2Id])); + }); + const filterChangedCases = [ + ['user added', [user1Id, user2Id, user3Id]], + ['user removed', [user1Id]], + ['user swapped', [user1Id, user3Id]], // same number of rooms with changes + ]; + + it.each(filterChangedCases)('%s', (_d, rooms) => { + const filter = initFilter(space1); + const emitSpy = jest.spyOn(filter, 'emit'); + + SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set(rooms)); + SpaceStoreInstanceMock.emit(space1); + jest.runOnlyPendingTimers(); + expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED); + }); + }); + }); +});