riot-web/test/stores/room-list/RoomListStore-test.ts

395 lines
16 KiB
TypeScript

/*
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<any, any> } {
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[] = [];
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();
});
});
});