diff --git a/src/Unread.js b/src/Unread.js index ddf225ac64..12c15eb6af 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -45,7 +45,7 @@ export function eventTriggersUnreadCount(ev) { } export function doesRoomHaveUnreadMessages(room) { - const myUserId = MatrixClientPeg.get().credentials.userId; + const myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. // N.B. this is NOT a read marker (RM, aka "read up to marker"), diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 56f0728f87..ec6227e45e 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -113,7 +113,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public async setActiveSpace(space: Room | null, contextSwitch = true) { - if (space === this.activeSpace) return; + if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return; this._activeSpace = space; this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); @@ -195,7 +195,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); return sortBy(childEvents, getOrder) .map(ev => this.matrixClient.getRoom(ev.getStateKey())) - .filter(room => room?.getMyMembership() === "join") || []; + .filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || []; } public getChildRooms(spaceId: string): Room[] { @@ -203,7 +203,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getChildSpaces(spaceId: string): Room[] { - return this.getChildren(spaceId).filter(r => r.isSpaceRoom()); + // 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() === "join"); } public getParents(roomId: string, canonicalOnly = false): Room[] { @@ -409,32 +410,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }, 100, {trailing: true, leading: true}); - private onRoom = (room: Room, membership?: string, oldMembership?: string) => { - if ((membership || room.getMyMembership()) === "invite") { - this._invitedSpaces.add(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); - } else if (oldMembership === "invite") { - this._invitedSpaces.delete(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); - } else if (room?.isSpaceRoom()) { - this.onSpaceUpdate(); - this.emit(room.roomId); - } else { + private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { + const membership = newMembership || room.getMyMembership(); + + if (!room.isSpaceRoom()) { // this.onRoomUpdate(room); this.onRoomsUpdate(); - } - if (room.getMyMembership() === "join") { - if (!room.isSpaceRoom()) { + if (membership === "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(SUGGESTED_ROOMS, this._suggestedRooms); } - } else if (room.roomId === RoomViewStore.getRoomId()) { - // if the user was looking at the space and then joined: select that space - this.setActiveSpace(room); } + return; + } + + // Space + if (membership === "invite") { + this._invitedSpaces.add(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else if (oldMembership === "invite" && membership !== "join") { + this._invitedSpaces.delete(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else { + this.onSpaceUpdate(); + this.emit(room.roomId); + } + + if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) { + // if the user was looking at the space and then joined: select that space + this.setActiveSpace(room); } }; @@ -498,6 +506,17 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } }; + protected async reset() { + this.rootSpaces = []; + this.orphanedRooms = new Set(); + this.parentMap = new EnhancedMap(); + this.notificationStateMap = new Map(); + this.spaceFilteredRooms = new Map(); + this._activeSpace = null; + this._suggestedRooms = []; + this._invitedSpaces = new Set(); + } + protected async onNotReady() { if (!SettingsStore.getValue("feature_spaces")) return; if (this.matrixClient) { @@ -507,7 +526,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("accountData", this.onAccountData); } - await this.reset({}); + await this.reset(); } protected async onReady() { @@ -540,17 +559,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // as this is not helpful and can create loops of rooms/space switching if (!room || payload.context_switch) break; - // persist last viewed room from a space - 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, false); } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) { - // TODO maybe reverse these first 2 clauses once space panel active is fixed - let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); + let parent = this.getCanonicalParent(room.roomId); if (!parent) { - parent = this.getCanonicalParent(room.roomId); + parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); } if (!parent) { const parents = Array.from(this.parentMap.get(room.roomId) || []); @@ -584,7 +600,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return state; } - // traverse space tree with DFS calling fn on each space including the given root one + // traverse space tree with DFS calling fn on each space including the given root one, + // if includeRooms is true then fn will be called on each leaf room, if it is present in multiple sub-spaces + // then fn will be called with it multiple times. public traverseSpace( spaceId: string, fn: (roomId: string) => void, diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts new file mode 100644 index 0000000000..aef788647d --- /dev/null +++ b/test/stores/SpaceStore-test.ts @@ -0,0 +1,714 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventEmitter } from "events"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import "../skinned-sdk"; // Must be first for skinning to work +import SpaceStore, { + UPDATE_INVITED_SPACES, + UPDATE_SELECTED_SPACE, + UPDATE_TOP_LEVEL_SPACES +} from "../../src/stores/SpaceStore"; +import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; +import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; +import { EnhancedMap } from "../../src/utils/maps"; +import SettingsStore from "../../src/settings/SettingsStore"; +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import defaultDispatcher from "../../src/dispatcher/dispatcher"; + +type MatrixEvent = any; // importing from js-sdk upsets things + +jest.useFakeTimers(); + +const mockStateEventImplementation = (events: MatrixEvent[]) => { + const stateMap = new EnhancedMap>(); + events.forEach(event => { + stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); + }); + + return (eventType: string, stateKey?: string) => { + if (stateKey || stateKey === "") { + return stateMap.get(eventType)?.get(stateKey) || null; + } + return Array.from(stateMap.get(eventType)?.values() || []); + }; +}; + +const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); + +const testUserId = "@test:user"; + +let rooms = []; + +const mkRoom = (roomId: string) => { + const room = mkStubRoom(roomId); + room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([])); + rooms.push(room); + return room; +}; + +const mkSpace = (spaceId: string, children: string[] = []) => { + const space = mkRoom(spaceId); + space.isSpaceRoom.mockReturnValue(true); + space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId => + mkEvent({ + event: true, + type: EventType.SpaceChild, + room: spaceId, + user: testUserId, + skey: roomId, + content: { via: [] }, + ts: Date.now(), + }), + ))); + return space; +}; + +const getValue = jest.fn(); +SettingsStore.getValue = getValue; + +const getUserIdForRoomId = jest.fn(); +// @ts-ignore +DMRoomMap.sharedInstance = { getUserIdForRoomId }; + +const fav1 = "!fav1:server"; +const fav2 = "!fav2:server"; +const fav3 = "!fav3:server"; +const dm1 = "!dm1:server"; +const dm1Partner = "@dm1Partner:server"; +const dm2 = "!dm2:server"; +const dm2Partner = "@dm2Partner:server"; +const dm3 = "!dm3:server"; +const dm3Partner = "@dm3Partner:server"; +const orphan1 = "!orphan1:server"; +const orphan2 = "!orphan2:server"; +const invite1 = "!invite1:server"; +const invite2 = "!invite2:server"; +const room1 = "!room1:server"; +const room2 = "!room2:server"; +const space1 = "!space1:server"; +const space2 = "!space2:server"; +const space3 = "!space3:server"; + +describe("SpaceStore", () => { + stubClient(); + const store = SpaceStore.instance; + const client = MatrixClientPeg.get(); + + const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true); + + const run = async () => { + client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); + await setupAsyncStoreWithClient(store, client); + jest.runAllTimers(); + }; + + beforeEach(() => { + jest.runAllTimers(); + client.getVisibleRooms.mockReturnValue(rooms = []); + getValue.mockImplementation(settingName => { + if (settingName === "feature_spaces") { + return true; + } + }); + }); + afterEach(async () => { + await resetAsyncStoreWithClient(store); + }); + + describe("static hierarchy resolution tests", () => { + it("handles no spaces", async () => { + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("handles 3 joined top level spaces", async () => { + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!space3:server"); + await run(); + + expect(store.spacePanelSpaces.sort()).toStrictEqual(client.getVisibleRooms().sort()); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("handles a basic hierarchy", async () => { + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!company:server", [ + mkSpace("!company_dept1:server", [ + mkSpace("!company_dept1_group1:server").roomId, + ]).roomId, + mkSpace("!company_dept2:server").roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([ + "!space1:server", + "!space2:server", + "!company:server", + ].sort()); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!space1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space1:server")).toStrictEqual([]); + expect(store.getChildRooms("!space2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space2:server")).toStrictEqual([]); + expect(store.getChildRooms("!company:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company:server")).toStrictEqual([ + client.getRoom("!company_dept1:server"), + client.getRoom("!company_dept2:server"), + ]); + expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([ + client.getRoom("!company_dept1_group1:server"), + ]); + expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([]); + }); + + it("handles a sub-space existing in multiple places in the space tree", async () => { + const subspace = mkSpace("!subspace:server"); + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!company:server", [ + mkSpace("!company_dept1:server", [ + mkSpace("!company_dept1_group1:server", [subspace.roomId]).roomId, + ]).roomId, + mkSpace("!company_dept2:server", [subspace.roomId]).roomId, + subspace.roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([ + "!space1:server", + "!space2:server", + "!company:server", + ].sort()); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!space1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space1:server")).toStrictEqual([]); + expect(store.getChildRooms("!space2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space2:server")).toStrictEqual([]); + expect(store.getChildRooms("!company:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company:server")).toStrictEqual([ + client.getRoom("!company_dept1:server"), + client.getRoom("!company_dept2:server"), + subspace, + ]); + expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([ + client.getRoom("!company_dept1_group1:server"), + ]); + expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([subspace]); + expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([subspace]); + }); + + it("handles full cycles", async () => { + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]); + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]); + }); + + it("handles partial cycles", async () => { + mkSpace("!b:server", [ + mkSpace("!a:server", [ + mkSpace("!c:server", [ + "!a:server", + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!b:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!a:server")]); + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]); + }); + + it("handles partial cycles with additional spaces coming off them", async () => { + // TODO this test should be failing right now + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + mkSpace("!d:server").roomId, + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]); + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([ + client.getRoom("!a:server"), + client.getRoom("!d:server"), + ]); + expect(store.getChildRooms("!d:server")).toStrictEqual([]); + expect(store.getChildSpaces("!d:server")).toStrictEqual([]); + }); + + it("invite to a subspace is only shown at the top level", async () => { + mkSpace(invite1).getMyMembership.mockReturnValue("invite"); + mkSpace(space1, [invite1]); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([client.getRoom(space1)]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([client.getRoom(invite1)]); + }); + + describe("test fixture 1", () => { + beforeEach(async () => { + [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1].forEach(mkRoom); + mkSpace(space1, [fav1, room1]); + mkSpace(space2, [fav1, fav2, fav3, room1]); + mkSpace(space3, [invite2]); + + [fav1, fav2, fav3].forEach(roomId => { + client.getRoom(roomId).tags = { + "m.favourite": { + order: 0.5, + }, + }; + }); + + [invite1, invite2].forEach(roomId => { + client.getRoom(roomId).getMyMembership.mockReturnValue("invite"); + }); + + getUserIdForRoomId.mockImplementation(roomId => { + return { + [dm1]: dm1Partner, + [dm2]: dm2Partner, + [dm3]: dm3Partner, + }[roomId]; + }); + await run(); + }); + + it("home space contains orphaned rooms", () => { + expect(store.getSpaceFilteredRoomIds(null).has(orphan1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(orphan2)).toBeTruthy(); + }); + + it("home space contains favourites", () => { + expect(store.getSpaceFilteredRoomIds(null).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(fav2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(fav3)).toBeTruthy(); + }); + + it("home space contains dm rooms", () => { + expect(store.getSpaceFilteredRoomIds(null).has(dm1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(dm2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(dm3)).toBeTruthy(); + }); + + it("home space contains invites", () => { + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy(); + }); + + it("home space contains invites even if they are also shown in a space", () => { + expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); + }); + + it("home space does not contain rooms/low priority from rooms within spaces", () => { + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); + }); + + it("space contains child rooms", () => { + const space = client.getRoom(space1); + expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy(); + }); + + it("space contains child favourites", () => { + const space = client.getRoom(space2); + expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(fav2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(fav3)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy(); + }); + + it("space contains child invites", () => { + const space = client.getRoom(space3); + expect(store.getSpaceFilteredRoomIds(space).has(invite2)).toBeTruthy(); + }); + }); + }); + + describe("hierarchy resolution update tests", () => { + let emitter: EventEmitter; + beforeEach(async () => { + emitter = new EventEmitter(); + client.on.mockImplementation(emitter.on.bind(emitter)); + client.removeListener.mockImplementation(emitter.removeListener.bind(emitter)); + }); + afterEach(() => { + client.on.mockReset(); + client.removeListener.mockReset(); + }); + + it("updates state when spaces are joined", async () => { + await run(); + expect(store.spacePanelSpaces).toStrictEqual([]); + const space = mkSpace(space1); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room", space); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("updates state when spaces are left", async () => { + const space = mkSpace(space1); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("leave"); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room.myMembership", space, "leave", "join"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + }); + + it("updates state when space invite comes in", async () => { + await run(); + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + const prom = emitPromise(store, UPDATE_INVITED_SPACES); + emitter.emit("Room", space); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + }); + + it("updates state when space invite is accepted", async () => { + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("join"); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room.myMembership", space, "join", "invite"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("updates state when space invite is rejected", async () => { + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("leave"); + const prom = emitPromise(store, UPDATE_INVITED_SPACES); + emitter.emit("Room.myMembership", space, "leave", "invite"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("room invite gets added to relevant space filters", async () => { + const space = mkSpace(space1, [invite1]); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([]); + expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeFalsy(); + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeFalsy(); + + const invite = mkRoom(invite1); + invite.getMyMembership.mockReturnValue("invite"); + const prom = emitPromise(store, space1); + emitter.emit("Room", space); + await prom; + + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([invite]); + expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy(); + }); + }); + + describe("active space switching tests", () => { + const fn = jest.spyOn(store, "emit"); + + beforeEach(async () => { + mkRoom(room1); // not a space + mkSpace(space1, [ + mkSpace(space2).roomId, + ]); + mkSpace(space3).getMyMembership.mockReturnValue("invite"); + await run(); + await store.setActiveSpace(null); + expect(store.activeSpace).toBe(null); + }); + afterEach(() => { + fn.mockClear(); + }); + + it("switch to home space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + fn.mockClear(); + + await store.setActiveSpace(null); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null); + expect(store.activeSpace).toBe(null); + }); + + it("switch to invited space", async () => { + const space = client.getRoom(space3); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to top level space", async () => { + const space = client.getRoom(space1); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to subspace", async () => { + const space = client.getRoom(space2); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to unknown space is a nop", async () => { + expect(store.activeSpace).toBe(null); + const space = client.getRoom(room1); // not a space + await store.setActiveSpace(space); + expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(null); + }); + }); + + describe("context switching tests", () => { + const fn = jest.spyOn(defaultDispatcher, "dispatch"); + + beforeEach(async () => { + [room1, room2, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2]); + mkSpace(space2, [room2]); + await run(); + }); + afterEach(() => { + fn.mockClear(); + localStorage.clear(); + }); + + const getCurrentRoom = () => fn.mock.calls.reverse().find(([p]) => p.action === "view_room")?.[0].room_id; + + it("last viewed room in target space is the current viewed and in both spaces", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space2)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room2); + }); + + it("last viewed room in target space is in the current space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room2); + }); + + it("last viewed room in target space is not in the current space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room1); + }); + + it("last viewed room is target space is not known", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + localStorage.setItem(`mx_space_context_${space2}`, orphan2); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + }); + + it("no last viewed room in target space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + }); + + it("no last viewed room in home space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(null); + expect(fn.mock.calls[fn.mock.calls.length - 1][0]).toStrictEqual({ action: "view_home_page" }); + }); + }); + + describe("space auto switching tests", () => { + beforeEach(async () => { + [room1, room2, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2]); + mkSpace(space2, [room1, room2]); + + client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ + mkEvent({ + event: true, + type: EventType.SpaceParent, + room: room2, + user: testUserId, + skey: space2, + content: { via: [], canonical: true }, + ts: Date.now(), + }), + ])); + await run(); + }); + + it("no switch required, room is in current space", async () => { + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space1), false); + viewRoom(room2); + expect(store.activeSpace).toBe(client.getRoom(space1)); + }); + + it("switch to canonical parent space for room", async () => { + viewRoom(room1); + await store.setActiveSpace(null, false); + viewRoom(room2); + expect(store.activeSpace).toBe(client.getRoom(space2)); + }); + + it("switch to first containing space for room", async () => { + viewRoom(room2); + await store.setActiveSpace(null, false); + viewRoom(room1); + expect(store.activeSpace).toBe(client.getRoom(space1)); + }); + + it("switch to home for orphaned room", async () => { + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space1), false); + viewRoom(orphan1); + expect(store.activeSpace).toBeNull(); + }); + }); + + describe("traverseSpace", () => { + beforeEach(() => { + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + mkRoom("!c-child:server").roomId, + mkRoom("!shared-child:server").roomId, + ]).roomId, + mkRoom("!b-child:server").roomId, + ]).roomId, + mkRoom("!a-child:server").roomId, + "!shared-child:server", + ]); + }); + + it("avoids cycles", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn); + + expect(fn).toBeCalledTimes(3); + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!c:server"); + }); + + it("including rooms", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn, true); + + expect(fn).toBeCalledTimes(8); // twice for shared-child + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!a-child:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!b-child:server"); + expect(fn).toBeCalledWith("!c:server"); + expect(fn).toBeCalledWith("!c-child:server"); + expect(fn).toBeCalledWith("!shared-child:server"); + }); + + it("excluding rooms", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn, false); + + expect(fn).toBeCalledTimes(3); + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!c:server"); + }); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index b5dc985222..baa77114ed 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -79,6 +79,10 @@ export function createTestClient() { generateClientSecret: () => "t35tcl1Ent5ECr3T", isGuest: () => false, isCryptoEnabled: () => false, + getSpaceSummary: jest.fn().mockReturnValue({ + rooms: [], + events: [], + }), // Used by various internal bits we aren't concerned with (yet) _sessionStore: { @@ -95,8 +99,8 @@ export function createTestClient() { * @param {string} opts.type The event.type * @param {string} opts.room The event.room_id * @param {string} opts.user The event.user_id - * @param {string} opts.skey Optional. The state key (auto inserts empty string) - * @param {Number} opts.ts Optional. Timestamp for the event + * @param {string=} opts.skey Optional. The state key (auto inserts empty string) + * @param {number=} opts.ts Optional. Timestamp for the event * @param {Object} opts.content The event.content * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object} a JSON object representing this event. @@ -231,7 +235,7 @@ export function mkStubRoom(roomId = null) { hasMembershipState: () => null, getVersion: () => '1', shouldUpgradeToVersion: () => null, - getMyMembership: () => "join", + getMyMembership: jest.fn().mockReturnValue("join"), maySendMessage: jest.fn().mockReturnValue(true), currentState: { getStateEvents: jest.fn(), @@ -240,17 +244,17 @@ export function mkStubRoom(roomId = null) { maySendEvent: jest.fn().mockReturnValue(true), members: [], }, - tags: { - "m.favourite": { - order: 0.5, - }, - }, + tags: {}, setBlacklistUnverifiedDevices: jest.fn(), on: jest.fn(), removeListener: jest.fn(), getDMInviter: jest.fn(), getAvatarUrl: () => 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', + isSpaceRoom: jest.fn(() => false), + getUnreadNotificationCount: jest.fn(() => 0), + getEventReadUpTo: jest.fn(() => null), + timeline: [], }; } diff --git a/test/utils/ShieldUtils-test.js b/test/utils/ShieldUtils-test.js index 8e3b19c1c4..bea3d26565 100644 --- a/test/utils/ShieldUtils-test.js +++ b/test/utils/ShieldUtils-test.js @@ -128,7 +128,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { describe("shieldStatusForMembership other-trust behaviour", function() { beforeAll(() => { - DMRoomMap._sharedInstance = { + DMRoomMap.sharedInstance = { getUserIdForRoomId: (roomId) => roomId === "DM" ? "@any:h" : null, }; }); diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts new file mode 100644 index 0000000000..af92987a3d --- /dev/null +++ b/test/utils/test-utils.ts @@ -0,0 +1,33 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; + +// These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent +// ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. + +export const setupAsyncStoreWithClient = async (store: AsyncStoreWithClient, client: MatrixClient) => { + // @ts-ignore + store.readyStore.useUnitTestClient(client); + // @ts-ignore + await store.onReady(); +}; + +export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient) => { + // @ts-ignore + await store.onNotReady(); +};