/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { 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 }); 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 }); 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[] = []; 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(); }); }); });