/* 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 { ConditionKind, EventType, IPushRule, JoinRule, MatrixEvent, PendingEventOrdering, PushRuleActionName, Room, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { mocked } from "jest-mock"; import defaultDispatcher, { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import SettingsStore, { CallbackFn } from "../../../src/settings/SettingsStore"; import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models"; import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause } from "../../../src/stores/room-list/models"; import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { flushPromises, stubClient, upsertRoomStateEvents, mkRoom } from "../../test-utils"; import { DEFAULT_PUSH_RULES, makePushRule } from "../../test-utils/pushRules"; describe("RoomListStore", () => { const client = stubClient(); const newRoomId = "!roomid:example.com"; const roomNoPredecessorId = "!roomnopreid:example.com"; const oldRoomId = "!oldroomid:example.com"; const userId = "@user:example.com"; const createWithPredecessor = new MatrixEvent({ type: EventType.RoomCreate, sender: userId, room_id: newRoomId, content: { predecessor: { room_id: oldRoomId, event_id: "tombstone_event_id" }, }, event_id: "$create", state_key: "", }); const createNoPredecessor = new MatrixEvent({ type: EventType.RoomCreate, sender: userId, room_id: newRoomId, content: {}, event_id: "$create", state_key: "", }); const predecessor = new MatrixEvent({ type: EventType.RoomPredecessor, sender: userId, room_id: newRoomId, content: { predecessor_room_id: oldRoomId, last_known_event_id: "tombstone_event_id", }, event_id: "$pred", state_key: "", }); const roomWithPredecessorEvent = new Room(newRoomId, client, userId, {}); upsertRoomStateEvents(roomWithPredecessorEvent, [predecessor]); const roomWithCreatePredecessor = new Room(newRoomId, client, userId, {}); upsertRoomStateEvents(roomWithCreatePredecessor, [createWithPredecessor]); const roomNoPredecessor = new Room(roomNoPredecessorId, client, userId, { pendingEventOrdering: PendingEventOrdering.Detached, }); upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]); const oldRoom = new Room(oldRoomId, client, userId, {}); const normalRoom = new Room("!normal:server.org", client, userId); client.getRoom = jest.fn().mockImplementation((roomId) => { switch (roomId) { case newRoomId: return roomWithCreatePredecessor; case oldRoomId: return oldRoom; case normalRoom.roomId: return normalRoom; default: return null; } }); beforeAll(async () => { await (RoomListStore.instance as RoomListStoreClass).makeReady(client); }); it.each(OrderedDefaultTagIDs)("defaults to importance ordering for %s=", (tagId) => { expect(RoomListStore.instance.getTagSorting(tagId)).toBe(SortAlgorithm.Recent); }); it.each(OrderedDefaultTagIDs)("defaults to activity ordering for %s=", (tagId) => { expect(RoomListStore.instance.getListOrder(tagId)).toBe(ListAlgorithm.Natural); }); function createStore(): { store: RoomListStoreClass; handleRoomUpdate: jest.Mock } { const fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher; const store = new RoomListStoreClass(fakeDispatcher); // @ts-ignore accessing private member to set client store.readyStore.matrixClient = client; const handleRoomUpdate = jest.fn(); // @ts-ignore accessing private member to mock it store.algorithm.handleRoomUpdate = handleRoomUpdate; return { store, handleRoomUpdate }; } it("Removes old room if it finds a predecessor in the create event", () => { // Given a store we can spy on const { store, handleRoomUpdate } = createStore(); // When we tell it we joined a new room that has an old room as // predecessor in the create event const payload = { oldMembership: KnownMembership.Invite, membership: KnownMembership.Join, room: roomWithCreatePredecessor, }; store.onDispatchMyMembership(payload); // Then the old room is removed expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved); // And the new room is added expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithCreatePredecessor, RoomUpdateCause.NewRoom); }); it("Does not remove old room if there is no predecessor in the create event", () => { // Given a store we can spy on const { store, handleRoomUpdate } = createStore(); // When we tell it we joined a new room with no predecessor const payload = { oldMembership: KnownMembership.Invite, membership: KnownMembership.Join, room: roomNoPredecessor, }; store.onDispatchMyMembership(payload); // Then the new room is added expect(handleRoomUpdate).toHaveBeenCalledWith(roomNoPredecessor, RoomUpdateCause.NewRoom); // And no other updates happen expect(handleRoomUpdate).toHaveBeenCalledTimes(1); }); it("Lists all rooms that the client says are visible", () => { // Given 3 rooms that are visible according to the client const room1 = new Room("!r1:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached }); const room2 = new Room("!r2:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached }); const room3 = new Room("!r3:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached }); room1.updateMyMembership(KnownMembership.Join); room2.updateMyMembership(KnownMembership.Join); room3.updateMyMembership(KnownMembership.Join); DMRoomMap.makeShared(client); const { store } = createStore(); client.getVisibleRooms = jest.fn().mockReturnValue([room1, room2, room3]); // When we make the list of rooms store.regenerateAllLists({ trigger: false }); // Then the list contains all 3 expect(store.orderedLists).toMatchObject({ "im.vector.fake.recent": [room1, room2, room3], }); // We asked not to use MSC3946 when we asked the client for the visible rooms expect(client.getVisibleRooms).toHaveBeenCalledWith(false); expect(client.getVisibleRooms).toHaveBeenCalledTimes(1); }); it("Watches the feature flag setting", () => { jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("dyn_pred_ref"); jest.spyOn(SettingsStore, "unwatchSetting"); // When we create a store const { store } = createStore(); // Then we watch the feature flag expect(SettingsStore.watchSetting).toHaveBeenCalledWith( "feature_dynamic_room_predecessors", null, expect.any(Function), ); // And when we unmount it store.componentWillUnmount(); // Then we unwatch it. expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("dyn_pred_ref"); }); it("Regenerates all lists when the feature flag is set", () => { // Given a store allowing us to spy on any use of SettingsStore let featureFlagValue = false; jest.spyOn(SettingsStore, "getValue").mockImplementation(() => featureFlagValue); let watchCallback: CallbackFn | undefined; jest.spyOn(SettingsStore, "watchSetting").mockImplementation( (_settingName: string, _roomId: string | null, callbackFn: CallbackFn) => { watchCallback = callbackFn; return "dyn_pred_ref"; }, ); jest.spyOn(SettingsStore, "unwatchSetting"); const { store } = createStore(); client.getVisibleRooms = jest.fn().mockReturnValue([]); // Sanity: no calculation has happened yet expect(client.getVisibleRooms).toHaveBeenCalledTimes(0); // When we calculate for the first time store.regenerateAllLists({ trigger: false }); // Then we use the current feature flag value (false) expect(client.getVisibleRooms).toHaveBeenCalledWith(false); expect(client.getVisibleRooms).toHaveBeenCalledTimes(1); // But when we update the feature flag featureFlagValue = true; watchCallback!( "feature_dynamic_room_predecessors", "", SettingLevel.DEFAULT, featureFlagValue, featureFlagValue, ); // Then we recalculate and passed the updated value (true) expect(client.getVisibleRooms).toHaveBeenCalledWith(true); expect(client.getVisibleRooms).toHaveBeenCalledTimes(2); }); describe("When feature_dynamic_room_predecessors = true", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockImplementation( (settingName) => settingName === "feature_dynamic_room_predecessors", ); }); afterEach(() => { jest.spyOn(SettingsStore, "getValue").mockReset(); }); it("Removes old room if it finds a predecessor in the m.predecessor event", () => { // Given a store we can spy on const { store, handleRoomUpdate } = createStore(); // When we tell it we joined a new room that has an old room as // predecessor in the create event const payload = { oldMembership: KnownMembership.Invite, membership: KnownMembership.Join, room: roomWithPredecessorEvent, }; store.onDispatchMyMembership(payload); // Then the old room is removed expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved); // And the new room is added expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithPredecessorEvent, RoomUpdateCause.NewRoom); }); it("Passes the feature flag on to the client when asking for visible rooms", () => { // Given a store that we can ask for a room list DMRoomMap.makeShared(client); const { store } = createStore(); client.getVisibleRooms = jest.fn().mockReturnValue([]); // When we make the list of rooms store.regenerateAllLists({ trigger: false }); // We asked to use MSC3946 when we asked the client for the visible rooms expect(client.getVisibleRooms).toHaveBeenCalledWith(true); expect(client.getVisibleRooms).toHaveBeenCalledTimes(1); }); }); describe("room updates", () => { const makeStore = async () => { const store = new RoomListStoreClass(defaultDispatcher); await store.start(); return store; }; describe("push rules updates", () => { const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => { return new MatrixEvent({ type: EventType.PushRules, content: { global: { ...DEFAULT_PUSH_RULES.global, override: overrideRules, }, }, }); }; it("triggers a room update when room mutes have changed", async () => { const rule = makePushRule(normalRoom.roomId, { actions: [PushRuleActionName.DontNotify], conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }], }); const event = makePushRulesEvent([rule]); const previousEvent = makePushRulesEvent(); const store = await makeStore(); // @ts-ignore private property alg const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined); // @ts-ignore cheat and call protected fn store.onAction({ action: "MatrixActions.accountData", event, previousEvent }); // flush setImmediate await flushPromises(); expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange); }); it("handles when a muted room is unknown by the room list", async () => { const rule = makePushRule(normalRoom.roomId, { actions: [PushRuleActionName.DontNotify], conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }], }); const unknownRoomRule = makePushRule("!unknown:server.org", { conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: "!unknown:server.org" }], }); const event = makePushRulesEvent([unknownRoomRule, rule]); const previousEvent = makePushRulesEvent(); const store = await makeStore(); // @ts-ignore private property alg const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined); // @ts-ignore cheat and call protected fn store.onAction({ action: "MatrixActions.accountData", event, previousEvent }); // flush setImmediate await flushPromises(); // only one call to update made for normalRoom expect(algorithmSpy).toHaveBeenCalledTimes(1); expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange); }); }); }); describe("Correctly tags rooms", () => { it("renders Public and Knock rooms in Conferences section", () => { const videoRoomPrivate = "!videoRoomPrivate_server"; const videoRoomPublic = "!videoRoomPublic_server"; const videoRoomKnock = "!videoRoomKnock_server"; const rooms: Room[] = []; RoomListStore.instance; mkRoom(client, videoRoomPrivate, rooms); mkRoom(client, videoRoomPublic, rooms); mkRoom(client, videoRoomKnock, rooms); mocked(client).getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null); mocked(client).getRooms.mockImplementation(() => rooms); const videoRoomKnockRoom = client.getRoom(videoRoomKnock); (videoRoomKnockRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Knock); const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate); (videoRoomPrivateRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Invite); const videoRoomPublicRoom = client.getRoom(videoRoomPublic); (videoRoomPublicRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Public); [videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => { (room!.isCallRoom as jest.Mock).mockReturnValue(true); }); expect( RoomListStore.instance .getTagsForRoom(client.getRoom(videoRoomPublic)!) .includes(DefaultTagID.Conference), ).toBeTruthy(); expect( RoomListStore.instance .getTagsForRoom(client.getRoom(videoRoomKnock)!) .includes(DefaultTagID.Conference), ).toBeTruthy(); expect( RoomListStore.instance .getTagsForRoom(client.getRoom(videoRoomPrivate)!) .includes(DefaultTagID.Conference), ).toBeFalsy(); }); }); });