Simplified Sliding Sync (#28515)

* Experimental SSS

Working branch to get SSS functional on element-web.

Requires https://github.com/matrix-org/matrix-js-sdk/pull/4400

* Adjust tests to use new behaviour

* Remove well-known proxy URL lookup; always use native

This is actually required for SSS because otherwise it would use
the proxy over native support.

* Linting

* Debug logging

* Control the race condition when swapping between rooms

* Dont' filter by space as synapse doesn't support it

* Remove SS code related to registering lists and managing ranges

- Update the spidering code to spider all the relevant lists.
- Add canonical alias to the required_state to allow room name calcs to work.

Room sort order is busted because we don't yet look at `bump_stamp`.

* User bumpStamp if it is present

* Drop initial room load from 20 per list to 10

* Half the batch size to trickle more quickly

* Prettier

* prettier on tests too

* Remove proxy URL & unused import

* Hopefully fix tests to assert what the behaviour is supposed to be

* Move the singleton to the manager tyo fix import loop

* Very well, code, I will remove you

Why were you there in the first place?

* Strip out more unused stuff

* Fix playwright test

Seems like this lack of order updating unless a room is selected
was just always a bug with both regular and non-sliding sync. I
have no idea how the test passed on develop because it won't run.

* Fix test to do maybe what it was supposed to do... possibly?

* Remove test for old pre-simplified sliding sync behaviour

* Unused import

* Remove sliding sync proxy & test

I was wrong about what this test was asserting, it was suposed
to assert that notification dots aren't shown (because SS didn't
support them somehow I guess) but they are fine in SSS so the test
is just no longer relevant.

* Remove now pointless credentials

* Remove subscription removal as SSS doesn't do that

* Update tests

* add test

* Switch to new labs flag & break if old labs flag is enabled

* Remove unused import & fix test

* Fix other test

* Remove name & description from old labs flag

as they're not displayed anywhere so not useful

* Remove old sliding sync option

by making it not a feature

* Add back unread nindicator test but inverted

and minus the bit about disabling notification which surely would have
defeated the original point anyway?

* Reinstate test for room_subscriptions

...and also make tests actually use sliding sync

* Use UserFriendlyError

* Remove empty constructor

* Remove unrelated changes

* Unused import

* Fix import

* Avoid moving import

---------

Co-authored-by: Kegan Dougal <7190048+kegsay@users.noreply.github.com>
This commit is contained in:
David Baker
2025-03-18 17:54:32 +00:00
committed by GitHub
parent 4fa540962a
commit f59af3786e
21 changed files with 367 additions and 1418 deletions

View File

@@ -661,6 +661,7 @@ export function mkStubRoom(
getUnreadNotificationCount: jest.fn(() => 0),
getRoomUnreadNotificationCount: jest.fn().mockReturnValue(0),
getVersion: jest.fn().mockReturnValue("1"),
getBumpStamp: jest.fn().mockReturnValue(0),
hasMembershipState: () => false,
isElementVideoRoom: jest.fn().mockReturnValue(false),
isSpaceRoom: jest.fn().mockReturnValue(false),

View File

@@ -6,18 +6,31 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { SlidingSync } from "matrix-js-sdk/src/sliding-sync";
import { type SlidingSync, SlidingSyncEvent, SlidingSyncState } from "matrix-js-sdk/src/sliding-sync";
import { mocked } from "jest-mock";
import { type MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { ClientEvent, type MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMockJest from "fetch-mock-jest";
import EventEmitter from "events";
import { waitFor } from "jest-matrix-react";
import { SlidingSyncManager } from "../../src/SlidingSyncManager";
import { stubClient } from "../test-utils";
import SlidingSyncController from "../../src/settings/controllers/SlidingSyncController";
import SettingsStore from "../../src/settings/SettingsStore";
import { mkStubRoom, stubClient } from "../test-utils";
jest.mock("matrix-js-sdk/src/sliding-sync");
const MockSlidingSync = <jest.Mock<SlidingSync>>(<unknown>SlidingSync);
class MockSlidingSync extends EventEmitter {
lists = {};
listModifiedCount = 0;
terminated = false;
needsResend = false;
modifyRoomSubscriptions = jest.fn();
getRoomSubscriptions = jest.fn();
useCustomSubscription = jest.fn();
getListParams = jest.fn();
setList = jest.fn();
setListRanges = jest.fn();
getListData = jest.fn();
extensions = jest.fn();
desiredRoomSubscriptions = jest.fn();
}
describe("SlidingSyncManager", () => {
let manager: SlidingSyncManager;
@@ -25,12 +38,12 @@ describe("SlidingSyncManager", () => {
let client: MatrixClient;
beforeEach(() => {
slidingSync = new MockSlidingSync();
slidingSync = new MockSlidingSync() as unknown as SlidingSync;
manager = new SlidingSyncManager();
client = stubClient();
// by default the client has no rooms: stubClient magically makes rooms annoyingly.
mocked(client.getRoom).mockReturnValue(null);
manager.configure(client, "invalid");
(manager as any).configure(client, "invalid");
manager.slidingSync = slidingSync;
fetchMockJest.reset();
fetchMockJest.get("https://proxy/client/server.json", {});
@@ -39,12 +52,13 @@ describe("SlidingSyncManager", () => {
describe("setRoomVisible", () => {
it("adds a subscription for the room", async () => {
const roomId = "!room:id";
mocked(client.getRoom).mockReturnValue(mkStubRoom(roomId, "foo", client));
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
await manager.setRoomVisible(roomId);
expect(slidingSync.modifyRoomSubscriptions).toHaveBeenCalledWith(new Set<string>([roomId]));
});
it("adds a custom subscription for a lazy-loadable room", async () => {
const roomId = "!lazy:id";
const room = new Room(roomId, client, client.getUserId()!);
@@ -67,19 +81,37 @@ describe("SlidingSyncManager", () => {
});
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
await manager.setRoomVisible(roomId);
expect(slidingSync.modifyRoomSubscriptions).toHaveBeenCalledWith(new Set<string>([roomId]));
// we aren't prescriptive about what the sub name is.
expect(slidingSync.useCustomSubscription).toHaveBeenCalledWith(roomId, expect.anything());
});
it("waits if the room is not yet known", async () => {
const roomId = "!room:id";
mocked(client.getRoom).mockReturnValue(null);
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
const setVisibleDone = jest.fn();
manager.setRoomVisible(roomId).then(setVisibleDone);
await waitFor(() => expect(client.getRoom).toHaveBeenCalledWith(roomId));
expect(setVisibleDone).not.toHaveBeenCalled();
const stubRoom = mkStubRoom(roomId, "foo", client);
mocked(client.getRoom).mockReturnValue(stubRoom);
client.emit(ClientEvent.Room, stubRoom);
await waitFor(() => expect(setVisibleDone).toHaveBeenCalled());
});
});
describe("ensureListRegistered", () => {
it("creates a new list based on the key", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue(null);
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
@@ -96,7 +128,6 @@ describe("SlidingSyncManager", () => {
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
@@ -114,7 +145,6 @@ describe("SlidingSyncManager", () => {
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 52]],
});
@@ -128,7 +158,6 @@ describe("SlidingSyncManager", () => {
ranges: [[0, 42]],
sort: ["by_recency"],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 42]],
sort: ["by_recency"],
@@ -139,183 +168,77 @@ describe("SlidingSyncManager", () => {
});
describe("startSpidering", () => {
it("requests in batchSizes", async () => {
it("requests in expanding batchSizes", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.setListRanges).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 64,
roomIndexToRoomId: {},
};
});
await manager.startSpidering(batchSize, gapMs);
await (manager as any).startSpidering(slidingSync, batchSize, gapMs);
// we expect calls for 10,19 -> 20,29 -> 30,39 -> 40,49 -> 50,59 -> 60,69
const wantWindows = [
[10, 19],
[20, 29],
[30, 39],
[40, 49],
[50, 59],
[60, 69],
[0, 10],
[0, 20],
[0, 30],
[0, 40],
[0, 50],
[0, 60],
[0, 70],
];
expect(slidingSync.getListData).toHaveBeenCalledTimes(wantWindows.length);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setListRanges).toHaveBeenCalledTimes(wantWindows.length - 1);
wantWindows.forEach((range, i) => {
if (i === 0) {
// eslint-disable-next-line jest/no-conditional-expect
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
// eslint-disable-next-line jest/no-conditional-expect
expect.objectContaining({
ranges: [[0, batchSize - 1], range],
}),
);
return;
}
expect(slidingSync.setListRanges).toHaveBeenCalledWith(SlidingSyncManager.ListSearch, [
[0, batchSize - 1],
range,
]);
});
for (let i = 1; i < wantWindows.length; ++i) {
// each time we emit, it should expand the range of all 5 lists by 10 until
// they all include all the rooms (64), which is 6 emits.
slidingSync.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, null, undefined);
await waitFor(() => expect(slidingSync.getListData).toHaveBeenCalledTimes(i * 5));
expect(slidingSync.setListRanges).toHaveBeenCalledTimes(i * 5);
expect(slidingSync.setListRanges).toHaveBeenCalledWith("spaces", [wantWindows[i]]);
}
});
it("handles accounts with zero rooms", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 0,
roomIndexToRoomId: {},
};
});
await manager.startSpidering(batchSize, gapMs);
expect(slidingSync.getListData).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
expect.objectContaining({
ranges: [
[0, batchSize - 1],
[batchSize, batchSize + batchSize - 1],
],
}),
);
});
it("continues even when setList rejects", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockRejectedValue("narp");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 0,
roomIndexToRoomId: {},
};
});
await manager.startSpidering(batchSize, gapMs);
expect(slidingSync.getListData).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
expect.objectContaining({
ranges: [
[0, batchSize - 1],
[batchSize, batchSize + batchSize - 1],
],
}),
);
await (manager as any).startSpidering(slidingSync, batchSize, gapMs);
slidingSync.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, null, undefined);
await waitFor(() => expect(slidingSync.getListData).toHaveBeenCalledTimes(5));
// should not have needed to expand the range
expect(slidingSync.setListRanges).not.toHaveBeenCalled();
});
});
describe("checkSupport", () => {
beforeEach(() => {
SlidingSyncController.serverSupportsSlidingSync = false;
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
SlidingSyncManager.serverSupportsSlidingSync = false;
});
it("shorts out if the server has 'native' sliding sync support", async () => {
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(true);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
expect(SlidingSyncManager.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(manager.getProxyFromWellKnown).not.toHaveBeenCalled(); // We return earlier
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
it("tries to find a sliding sync proxy url from the client well-known if there's no 'native' support", async () => {
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(false);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(manager.getProxyFromWellKnown).toHaveBeenCalled();
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
it("should query well-known on server_name not baseUrl", async () => {
fetchMockJest.get("https://matrix.org/.well-known/matrix/client", {
"m.homeserver": {
base_url: "https://matrix-client.matrix.org",
server: "matrix.org",
},
"org.matrix.msc3575.proxy": {
url: "https://proxy/",
},
});
fetchMockJest.get("https://matrix-client.matrix.org/_matrix/client/versions", { versions: ["v1.4"] });
mocked(manager.getProxyFromWellKnown).mockRestore();
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(false);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
expect(fetchMockJest).not.toHaveFetched("https://matrix-client.matrix.org/.well-known/matrix/client");
});
});
describe("nativeSlidingSyncSupport", () => {
beforeEach(() => {
SlidingSyncController.serverSupportsSlidingSync = false;
});
it("should make an OPTIONS request to avoid unintended side effects", async () => {
// See https://github.com/element-hq/element-web/issues/27426
const unstableSpy = jest
.spyOn(client, "doesServerSupportUnstableFeature")
.mockImplementation(async (feature: string) => {
expect(feature).toBe("org.matrix.msc3575");
return true;
});
const proxySpy = jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client); // first thing it does is call nativeSlidingSyncSupport
expect(proxySpy).not.toHaveBeenCalled();
expect(unstableSpy).toHaveBeenCalled();
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
expect(SlidingSyncManager.serverSupportsSlidingSync).toBeTruthy();
});
});
describe("setup", () => {
let untypedManager: any;
beforeEach(() => {
jest.spyOn(manager, "configure");
jest.spyOn(manager, "startSpidering");
untypedManager = manager;
jest.spyOn(untypedManager, "configure");
jest.spyOn(untypedManager, "startSpidering");
});
it("uses the baseUrl as a proxy if no proxy is set in the client well-known and the server has no native support", async () => {
it("uses the baseUrl", async () => {
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, client.baseUrl);
expect(manager.startSpidering).toHaveBeenCalled();
});
it("uses the proxy declared in the client well-known", async () => {
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, "https://proxy/");
expect(manager.startSpidering).toHaveBeenCalled();
});
it("uses the legacy `feature_sliding_sync_proxy_url` if it was set", async () => {
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
if (name === "feature_sliding_sync_proxy_url") return "legacy-proxy";
});
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, "legacy-proxy");
expect(manager.startSpidering).toHaveBeenCalled();
expect(untypedManager.configure).toHaveBeenCalled();
expect(untypedManager.configure).toHaveBeenCalledWith(client, client.baseUrl);
expect(untypedManager.startSpidering).toHaveBeenCalled();
});
});
});

View File

@@ -11,7 +11,7 @@ import { mocked } from "jest-mock";
import RoomListStoreV3 from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkStubRoom } from "../../../../test-utils";
import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/SlidingRoomListStore";
import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/RoomListStore";
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
import { SecondaryFilters } from "../../../../../src/components/viewmodels/roomlist/useFilteredRooms";

View File

@@ -1,84 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { waitFor, renderHook, act } from "jest-matrix-react";
import { mocked } from "jest-mock";
import { type SlidingSync } from "matrix-js-sdk/src/sliding-sync";
import { Room } from "matrix-js-sdk/src/matrix";
import { useSlidingSyncRoomSearch } from "../../../src/hooks/useSlidingSyncRoomSearch";
import { MockEventEmitter, stubClient } from "../../test-utils";
import { SlidingSyncManager } from "../../../src/SlidingSyncManager";
describe("useSlidingSyncRoomSearch", () => {
afterAll(() => {
jest.restoreAllMocks();
});
it("should display rooms when searching", async () => {
const client = stubClient();
const roomA = new Room("!a:localhost", client, client.getUserId()!);
const roomB = new Room("!b:localhost", client, client.getUserId()!);
const slidingSync = mocked(
new MockEventEmitter({
getListData: jest.fn(),
}) as unknown as SlidingSync,
);
jest.spyOn(SlidingSyncManager.instance, "ensureListRegistered").mockResolvedValue({
ranges: [[0, 9]],
});
SlidingSyncManager.instance.slidingSync = slidingSync;
mocked(slidingSync.getListData).mockReturnValue({
joinedCount: 2,
roomIndexToRoomId: {
0: roomA.roomId,
1: roomB.roomId,
},
});
mocked(client.getRoom).mockImplementation((roomId) => {
switch (roomId) {
case roomA.roomId:
return roomA;
case roomB.roomId:
return roomB;
default:
return null;
}
});
// first check that everything is empty
const { result } = renderHook(() => useSlidingSyncRoomSearch());
const query = {
limit: 10,
query: "foo",
};
expect(result.current.loading).toBe(false);
expect(result.current.rooms).toEqual([]);
// run the query
act(() => {
result.current.search(query);
});
// wait for loading to finish
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// now we expect there to be rooms
expect(result.current.rooms).toEqual([roomA, roomB]);
// run the query again
act(() => {
result.current.search(query);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
});
});

View File

@@ -161,7 +161,7 @@ describe("MemberListStore", () => {
describe("sliding sync", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, value) => {
return settingName === "feature_sliding_sync"; // this is enabled, everything else is disabled.
return settingName === "feature_simplified_sliding_sync"; // this is enabled, everything else is disabled.
});
client.members = jest.fn();
});

View File

@@ -383,43 +383,35 @@ describe("RoomViewStore", function () {
describe("Sliding Sync", function () {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, value) => {
return settingName === "feature_sliding_sync"; // this is enabled, everything else is disabled.
return settingName === "feature_simplified_sliding_sync"; // this is enabled, everything else is disabled.
});
});
it("subscribes to the room", async () => {
const setRoomVisible = jest
.spyOn(slidingSyncManager, "setRoomVisible")
.mockReturnValue(Promise.resolve(""));
const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue(Promise.resolve());
const subscribedRoomId = "!sub1:localhost";
dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId });
await untilDispatch(Action.ActiveRoomChanged, dis);
expect(roomViewStore.getRoomId()).toBe(subscribedRoomId);
expect(setRoomVisible).toHaveBeenCalledWith(subscribedRoomId, true);
expect(setRoomVisible).toHaveBeenCalledWith(subscribedRoomId);
});
// Regression test for an in-the-wild bug where rooms would rapidly switch forever in sliding sync mode
// Previously a regression test for an in-the-wild bug where rooms would rapidly switch forever in sliding sync mode
// although that was before the complexity was removed with similified mode. I've removed the complexity but kept the
// test anyway.
it("doesn't get stuck in a loop if you view rooms quickly", async () => {
const setRoomVisible = jest
.spyOn(slidingSyncManager, "setRoomVisible")
.mockReturnValue(Promise.resolve(""));
const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue(Promise.resolve());
const subscribedRoomId = "!sub1:localhost";
const subscribedRoomId2 = "!sub2:localhost";
dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId }, true);
dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId2 }, true);
await untilDispatch(Action.ActiveRoomChanged, dis);
// sub(1) then unsub(1) sub(2), unsub(1)
const wantCalls = [
[subscribedRoomId, true],
[subscribedRoomId, false],
[subscribedRoomId2, true],
[subscribedRoomId, false],
];
// should view 1, then 2
const wantCalls = [[subscribedRoomId], [subscribedRoomId2]];
expect(setRoomVisible).toHaveBeenCalledTimes(wantCalls.length);
wantCalls.forEach((v, i) => {
try {
expect(setRoomVisible.mock.calls[i][0]).toEqual(v[0]);
expect(setRoomVisible.mock.calls[i][1]).toEqual(v[1]);
} catch {
throw new Error(`i=${i} got ${setRoomVisible.mock.calls[i]} want ${v}`);
}

View File

@@ -1,341 +0,0 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { type SlidingSync, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync";
import { Room } from "matrix-js-sdk/src/matrix";
import {
LISTS_UPDATE_EVENT,
SlidingRoomListStoreClass,
SlidingSyncSortToFilter,
} from "../../../../src/stores/room-list/SlidingRoomListStore";
import { type SpaceStoreClass } from "../../../../src/stores/spaces/SpaceStore";
import { MockEventEmitter, stubClient, untilEmission } from "../../../test-utils";
import { TestSdkContext } from "../../TestSdkContext";
import { SlidingSyncManager } from "../../../../src/SlidingSyncManager";
import { type RoomViewStore } from "../../../../src/stores/RoomViewStore";
import { MatrixDispatcher } from "../../../../src/dispatcher/dispatcher";
import { SortAlgorithm } from "../../../../src/stores/room-list/algorithms/models";
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list/models";
import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces";
import { LISTS_LOADING_EVENT } from "../../../../src/stores/room-list/RoomListStore";
import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore";
jest.mock("../../../../src/SlidingSyncManager");
const MockSlidingSyncManager = <jest.Mock<SlidingSyncManager>>(<unknown>SlidingSyncManager);
describe("SlidingRoomListStore", () => {
let store: SlidingRoomListStoreClass;
let context: TestSdkContext;
let dis: MatrixDispatcher;
let activeSpace: string;
beforeEach(async () => {
context = new TestSdkContext();
context.client = stubClient();
context._SpaceStore = new MockEventEmitter<SpaceStoreClass>({
traverseSpace: jest.fn(),
get activeSpace() {
return activeSpace;
},
}) as SpaceStoreClass;
context._SlidingSyncManager = new MockSlidingSyncManager();
context._SlidingSyncManager.slidingSync = mocked(
new MockEventEmitter({
getListData: jest.fn(),
}) as unknown as SlidingSync,
);
context._RoomViewStore = mocked(
new MockEventEmitter({
getRoomId: jest.fn(),
}) as unknown as RoomViewStore,
);
mocked(context._SlidingSyncManager.ensureListRegistered).mockResolvedValue({
ranges: [[0, 10]],
});
dis = new MatrixDispatcher();
store = new SlidingRoomListStoreClass(dis, context);
});
describe("spaces", () => {
it("alters 'filters.spaces' on the DefaultTagID.Untagged list when the selected space changes", async () => {
await store.start(); // call onReady
const spaceRoomId = "!foo:bar";
const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => {
return listName === DefaultTagID.Untagged && !isLoading;
});
// change the active space
activeSpace = spaceRoomId;
context._SpaceStore!.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false);
await p;
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(DefaultTagID.Untagged, {
filters: expect.objectContaining({
spaces: [spaceRoomId],
}),
});
});
it("gracefully handles subspaces in the home metaspace", async () => {
const subspace = "!sub:space";
mocked(context._SpaceStore!.traverseSpace).mockImplementation(
(spaceId: string, fn: (roomId: string) => void) => {
fn(subspace);
},
);
activeSpace = MetaSpace.Home;
await store.start(); // call onReady
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(DefaultTagID.Untagged, {
filters: expect.objectContaining({
spaces: [subspace],
}),
});
});
it("alters 'filters.spaces' on the DefaultTagID.Untagged list if it loads with an active space", async () => {
// change the active space before we are ready
const spaceRoomId = "!foo2:bar";
activeSpace = spaceRoomId;
const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => {
return listName === DefaultTagID.Untagged && !isLoading;
});
await store.start(); // call onReady
await p;
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(
DefaultTagID.Untagged,
expect.objectContaining({
filters: expect.objectContaining({
spaces: [spaceRoomId],
}),
}),
);
});
it("includes subspaces in 'filters.spaces' when the selected space has subspaces", async () => {
await store.start(); // call onReady
const spaceRoomId = "!foo:bar";
const subSpace1 = "!ss1:bar";
const subSpace2 = "!ss2:bar";
const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => {
return listName === DefaultTagID.Untagged && !isLoading;
});
mocked(context._SpaceStore!.traverseSpace).mockImplementation(
(spaceId: string, fn: (roomId: string) => void) => {
if (spaceId === spaceRoomId) {
fn(subSpace1);
fn(subSpace2);
}
},
);
// change the active space
activeSpace = spaceRoomId;
context._SpaceStore!.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false);
await p;
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(DefaultTagID.Untagged, {
filters: expect.objectContaining({
spaces: [spaceRoomId, subSpace1, subSpace2],
}),
});
});
});
it("setTagSorting alters the 'sort' option in the list", async () => {
const tagId: TagID = "foo";
await store.setTagSorting(tagId, SortAlgorithm.Alphabetic);
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
});
expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Alphabetic);
await store.setTagSorting(tagId, SortAlgorithm.Recent);
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
});
expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Recent);
});
it("getTagsForRoom gets the tags for the room", async () => {
await store.start();
const roomA = "!a:localhost";
const roomB = "!b:localhost";
const keyToListData: Record<string, { joinedCount: number; roomIndexToRoomId: Record<number, string> }> = {
[DefaultTagID.Untagged]: {
joinedCount: 10,
roomIndexToRoomId: {
0: roomA,
1: roomB,
},
},
[DefaultTagID.Favourite]: {
joinedCount: 2,
roomIndexToRoomId: {
0: roomB,
},
},
};
mocked(context._SlidingSyncManager!.slidingSync!.getListData).mockImplementation((key: string) => {
return keyToListData[key] || null;
});
expect(store.getTagsForRoom(new Room(roomA, context.client!, context.client!.getUserId()!))).toEqual([
DefaultTagID.Untagged,
]);
expect(store.getTagsForRoom(new Room(roomB, context.client!, context.client!.getUserId()!))).toEqual([
DefaultTagID.Favourite,
DefaultTagID.Untagged,
]);
});
it("emits LISTS_UPDATE_EVENT when slidingSync lists update", async () => {
await store.start();
const roomA = "!a:localhost";
const roomB = "!b:localhost";
const roomC = "!c:localhost";
const tagId = DefaultTagID.Favourite;
const joinCount = 10;
const roomIndexToRoomId = {
// mixed to ensure we sort
1: roomB,
2: roomC,
0: roomA,
};
const rooms = [
new Room(roomA, context.client!, context.client!.getUserId()!),
new Room(roomB, context.client!, context.client!.getUserId()!),
new Room(roomC, context.client!, context.client!.getUserId()!),
];
mocked(context.client!.getRoom).mockImplementation((roomId: string) => {
switch (roomId) {
case roomA:
return rooms[0];
case roomB:
return rooms[1];
case roomC:
return rooms[2];
}
return null;
});
const p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync!.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p;
expect(store.getCount(tagId)).toEqual(joinCount);
expect(store.orderedLists[tagId]).toEqual(rooms);
});
it("sets the sticky room on the basis of the viewed room in RoomViewStore", async () => {
await store.start();
// seed the store with 3 rooms
const roomIdA = "!a:localhost";
const roomIdB = "!b:localhost";
const roomIdC = "!c:localhost";
const tagId = DefaultTagID.Favourite;
const joinCount = 10;
const roomIndexToRoomId = {
// mixed to ensure we sort
1: roomIdB,
2: roomIdC,
0: roomIdA,
};
const roomA = new Room(roomIdA, context.client!, context.client!.getUserId()!);
const roomB = new Room(roomIdB, context.client!, context.client!.getUserId()!);
const roomC = new Room(roomIdC, context.client!, context.client!.getUserId()!);
mocked(context.client!.getRoom).mockImplementation((roomId: string) => {
switch (roomId) {
case roomIdA:
return roomA;
case roomIdB:
return roomB;
case roomIdC:
return roomC;
}
return null;
});
mocked(context._SlidingSyncManager!.slidingSync!.getListData).mockImplementation((key: string) => {
if (key !== tagId) {
return null;
}
return {
roomIndexToRoomId: roomIndexToRoomId,
joinedCount: joinCount,
};
});
let p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync!.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p;
expect(store.orderedLists[tagId]).toEqual([roomA, roomB, roomC]);
// make roomB sticky and inform the store
mocked(context.roomViewStore.getRoomId).mockReturnValue(roomIdB);
context.roomViewStore.emit(UPDATE_EVENT);
// bump room C to the top, room B should not move from i=1 despite the list update saying to
roomIndexToRoomId[0] = roomIdC;
roomIndexToRoomId[1] = roomIdA;
roomIndexToRoomId[2] = roomIdB;
p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync!.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p;
// check that B didn't move and that A was put below B
expect(store.orderedLists[tagId]).toEqual([roomC, roomB, roomA]);
// make room C sticky: rooms should move as a result, without needing an additional list update
mocked(context.roomViewStore.getRoomId).mockReturnValue(roomIdC);
p = untilEmission(store, LISTS_UPDATE_EVENT);
context.roomViewStore.emit(UPDATE_EVENT);
await p;
expect(store.orderedLists[tagId].map((r) => r.roomId)).toEqual([roomC, roomA, roomB].map((r) => r.roomId));
});
it("gracefully handles unknown room IDs", async () => {
await store.start();
const roomIdA = "!a:localhost";
const roomIdB = "!b:localhost"; // does not exist
const roomIdC = "!c:localhost";
const roomIndexToRoomId = {
0: roomIdA,
1: roomIdB, // does not exist
2: roomIdC,
};
const tagId = DefaultTagID.Favourite;
const joinCount = 10;
// seed the store with 2 rooms
const roomA = new Room(roomIdA, context.client!, context.client!.getUserId()!);
const roomC = new Room(roomIdC, context.client!, context.client!.getUserId()!);
mocked(context.client!.getRoom).mockImplementation((roomId: string) => {
switch (roomId) {
case roomIdA:
return roomA;
case roomIdC:
return roomC;
}
return null;
});
mocked(context._SlidingSyncManager!.slidingSync!.getListData).mockImplementation((key: string) => {
if (key !== tagId) {
return null;
}
return {
roomIndexToRoomId: roomIndexToRoomId,
joinedCount: joinCount,
};
});
const p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync!.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p;
expect(store.orderedLists[tagId]).toEqual([roomA, roomC]);
});
});