From f59af3786e9de82dc94585d55648e8f4050c0e27 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 18 Mar 2025 17:54:32 +0000 Subject: [PATCH] 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> --- .../e2e/sliding-sync/sliding-sync.spec.ts | 133 ++---- src/MatrixClientPeg.ts | 6 + src/SlidingSyncManager.ts | 370 +++++++++-------- src/Unread.ts | 7 - src/components/views/rooms/RoomSublist.tsx | 33 +- src/hooks/useSlidingSyncRoomSearch.ts | 82 ---- src/i18n/strings/en_EN.json | 1 + src/settings/Settings.tsx | 16 +- .../controllers/SlidingSyncController.ts | 7 +- src/stores/MemberListStore.ts | 4 +- src/stores/RoomViewStore.tsx | 15 +- src/stores/room-list/RoomListStore.ts | 17 +- src/stores/room-list/SlidingRoomListStore.ts | 391 ------------------ .../algorithms/tag-sorting/RecentAlgorithm.ts | 6 + test/test-utils/test-utils.ts | 1 + test/unit-tests/SlidingSyncManager-test.ts | 241 ++++------- .../roomlist/RoomListViewModel-test.tsx | 2 +- .../hooks/useSlidingSyncRoomSearch-test.tsx | 84 ---- .../unit-tests/stores/MemberListStore-test.ts | 2 +- test/unit-tests/stores/RoomViewStore-test.ts | 26 +- .../room-list/SlidingRoomListStore-test.ts | 341 --------------- 21 files changed, 367 insertions(+), 1418 deletions(-) delete mode 100644 src/hooks/useSlidingSyncRoomSearch.ts delete mode 100644 src/stores/room-list/SlidingRoomListStore.ts delete mode 100644 test/unit-tests/hooks/useSlidingSyncRoomSearch-test.tsx delete mode 100644 test/unit-tests/stores/room-list/SlidingRoomListStore-test.ts diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index bf992a0edd..118bd4585e 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -7,47 +7,15 @@ Please see LICENSE files in the repository root for full details. */ import { type Page, type Request } from "@playwright/test"; -import { GenericContainer, type StartedTestContainer, Wait } from "testcontainers"; import { test as base, expect } from "../../element-web-test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; import type { Bot } from "../../pages/bot"; const test = base.extend<{ - slidingSyncProxy: StartedTestContainer; testRoom: { roomId: string; name: string }; joinedBot: Bot; }>({ - slidingSyncProxy: async ({ logger, network, postgres, page, homeserver }, use, testInfo) => { - const container = await new GenericContainer("ghcr.io/matrix-org/sliding-sync:v0.99.3") - .withNetwork(network) - .withExposedPorts(8008) - .withLogConsumer(logger.getConsumer("sliding-sync-proxy")) - .withWaitStrategy(Wait.forHttp("/client/server.json", 8008)) - .withEnvironment({ - SYNCV3_SECRET: "bwahahaha", - SYNCV3_DB: `user=${postgres.getUsername()} dbname=postgres password=${postgres.getPassword()} host=postgres sslmode=disable`, - SYNCV3_SERVER: `http://homeserver:8008`, - }) - .start(); - - const proxyAddress = `http://${container.getHost()}:${container.getMappedPort(8008)}`; - await page.addInitScript((proxyAddress) => { - window.localStorage.setItem( - "mx_local_settings", - JSON.stringify({ - feature_sliding_sync_proxy_url: proxyAddress, - }), - ); - window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true"); - }, proxyAddress); - await use(container); - await container.stop(); - }, - // Ensure slidingSyncProxy is set up before the user fixture as it relies on an init script - credentials: async ({ slidingSyncProxy, credentials }, use) => { - await use(credentials); - }, testRoom: async ({ user, app }, use) => { const name = "Test Room"; const roomId = await app.client.createRoom({ name }); @@ -82,6 +50,14 @@ test.describe("Sliding Sync", () => { }); }; + test.use({ + config: { + features: { + feature_simplified_sliding_sync: true, + }, + }, + }); + // Load the user fixture for all tests test.beforeEach(({ user }) => {}); @@ -188,15 +164,7 @@ test.describe("Sliding Sync", () => { ).not.toBeAttached(); }); - test("should not show unread indicators", async ({ page, app, joinedBot: bot, testRoom }) => { - // TODO: for now. Later we should. - - // disable notifs in this room (TODO: CS API call?) - const locator = page.getByRole("treeitem", { name: "Test Room" }); - await locator.hover(); - await locator.getByRole("button", { name: "Notification options" }).click(); - await page.getByRole("menuitemradio", { name: "Mute room" }).click(); - + test("should show unread indicators", async ({ page, app, joinedBot: bot, testRoom }) => { // create a new room so we know when the message has been received as it'll re-shuffle the room list await app.client.createRoom({ name: "Dummy" }); @@ -207,9 +175,7 @@ test.describe("Sliding Sync", () => { // wait for this message to arrive, tell by the room list resorting await checkOrder(["Test Room", "Dummy"], page); - await expect( - page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge"), - ).not.toBeAttached(); + await expect(page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge")).toBeAttached(); }); test("should update user settings promptly", async ({ page, app }) => { @@ -221,6 +187,37 @@ test.describe("Sliding Sync", () => { await expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached(); }); + test("should send subscribe_rooms on room switch if room not already subscribed", async ({ page, app }) => { + // create rooms and check room names are correct + const roomIds: string[] = []; + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + const id = await app.client.createRoom({ name: fruit }); + roomIds.push(id); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + const [roomAId, roomPId] = roomIds; + + const matchRoomSubRequest = (subRoomId: string) => (request: Request) => { + if (!request.url().includes("/sync")) return false; + const body = request.postDataJSON(); + return body.room_subscriptions?.[subRoomId]; + }; + + // Select the Test Room and wait for playwright to get the request + const [request] = await Promise.all([ + page.waitForRequest(matchRoomSubRequest(roomAId)), + page.getByRole("treeitem", { name: "Apple", exact: true }).click(), + ]); + const roomSubscriptions = request.postDataJSON().room_subscriptions; + expect(roomSubscriptions, "room_subscriptions is object").toBeDefined(); + + // Switch to another room and wait for playwright to get the request + await Promise.all([ + page.waitForRequest(matchRoomSubRequest(roomPId)), + page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(), + ]); + }); + test("should show and be able to accept/reject/rescind invites", async ({ page, app, @@ -361,52 +358,4 @@ test.describe("Sliding Sync", () => { // ensure the reply-to does not disappear await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); }); - - test("should send unsubscribe_rooms for every room switch", async ({ page, app }) => { - // create rooms and check room names are correct - const roomIds: string[] = []; - for (const fruit of ["Apple", "Pineapple", "Orange"]) { - const id = await app.client.createRoom({ name: fruit }); - roomIds.push(id); - await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); - } - const [roomAId, roomPId, roomOId] = roomIds; - - const matchRoomSubRequest = (subRoomId: string) => (request: Request) => { - if (!request.url().includes("/sync")) return false; - const body = request.postDataJSON(); - return body.txn_id && body.room_subscriptions?.[subRoomId]; - }; - const matchRoomUnsubRequest = (unsubRoomId: string) => (request: Request) => { - if (!request.url().includes("/sync")) return false; - const body = request.postDataJSON(); - return ( - body.txn_id && body.unsubscribe_rooms?.includes(unsubRoomId) && !body.room_subscriptions?.[unsubRoomId] - ); - }; - - // Select the Test Room and wait for playwright to get the request - const [request] = await Promise.all([ - page.waitForRequest(matchRoomSubRequest(roomAId)), - page.getByRole("treeitem", { name: "Apple", exact: true }).click(), - ]); - const roomSubscriptions = request.postDataJSON().room_subscriptions; - expect(roomSubscriptions, "room_subscriptions is object").toBeDefined(); - - // Switch to another room and wait for playwright to get the request - await Promise.all([ - page.waitForRequest(matchRoomSubRequest(roomPId)), - page.waitForRequest(matchRoomUnsubRequest(roomAId)), - page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(), - ]); - - // And switch to even another room and wait for playwright to get the request - await Promise.all([ - page.waitForRequest(matchRoomSubRequest(roomOId)), - page.waitForRequest(matchRoomUnsubRequest(roomPId)), - page.getByRole("treeitem", { name: "Orange", exact: true }).click(), - ]); - - // TODO: Add tests for encrypted rooms - }); }); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 9682b41800..288878cddc 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -299,6 +299,12 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.threadSupport = true; if (SettingsStore.getValue("feature_sliding_sync")) { + throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported"); + } + + // If the user has enabled the labs feature for sliding sync, set it up + // otherwise check if the feature is supported + if (SettingsStore.getValue("feature_simplified_sliding_sync")) { opts.slidingSync = await SlidingSyncManager.instance.setup(this.matrixClient); } else { SlidingSyncManager.instance.checkSupport(this.matrixClient); diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index adfca7c3c3..88b839312d 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -36,45 +36,51 @@ Please see LICENSE files in the repository root for full details. * list ops) */ -import { type MatrixClient, EventType, AutoDiscovery, Method, timeoutSignal } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, ClientEvent, EventType, type Room } from "matrix-js-sdk/src/matrix"; import { type MSC3575Filter, type MSC3575List, + type MSC3575SlidingSyncResponse, MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_ME, MSC3575_WILDCARD, SlidingSync, + SlidingSyncEvent, + SlidingSyncState, } from "matrix-js-sdk/src/sliding-sync"; import { logger } from "matrix-js-sdk/src/logger"; import { defer, sleep } from "matrix-js-sdk/src/utils"; -import SettingsStore from "./settings/SettingsStore"; -import SlidingSyncController from "./settings/controllers/SlidingSyncController"; - // how long to long poll for const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000; +// The state events we will get for every single room/space/old room/etc +// This list is only augmented when a direct room subscription is made. (e.g you view a room) +const REQUIRED_STATE_LIST = [ + [EventType.RoomJoinRules, ""], // the public icon on the room list + [EventType.RoomAvatar, ""], // any room avatar + [EventType.RoomCanonicalAlias, ""], // for room name calculations + [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead + [EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly + [EventType.RoomCreate, ""], // for isSpaceRoom checks + [EventType.SpaceChild, MSC3575_WILDCARD], // all space children + [EventType.SpaceParent, MSC3575_WILDCARD], // all space parents + [EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room +]; + // the things to fetch when a user clicks on a room const DEFAULT_ROOM_SUBSCRIPTION_INFO = { timeline_limit: 50, // missing required_state which will change depending on the kind of room include_old_rooms: { timeline_limit: 0, - required_state: [ - // state needed to handle space navigation and tombstone chains - [EventType.RoomCreate, ""], - [EventType.RoomTombstone, ""], - [EventType.SpaceChild, MSC3575_WILDCARD], - [EventType.SpaceParent, MSC3575_WILDCARD], - [EventType.RoomMember, MSC3575_STATE_KEY_ME], - ], + required_state: REQUIRED_STATE_LIST, }, }; // lazy load room members so rooms like Matrix HQ don't take forever to load const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted"; const UNENCRYPTED_SUBSCRIPTION = { required_state: [ - [MSC3575_WILDCARD, MSC3575_WILDCARD], // all events [EventType.RoomMember, MSC3575_STATE_KEY_ME], // except for m.room.members, get our own membership [EventType.RoomMember, MSC3575_STATE_KEY_LAZY], // ...and lazy load the rest. ], @@ -90,6 +96,72 @@ const ENCRYPTED_SUBSCRIPTION = { ...DEFAULT_ROOM_SUBSCRIPTION_INFO, }; +// the complete set of lists made in SSS. The manager will spider all of these lists depending +// on the count for each one. +const sssLists: Record = { + spaces: { + ranges: [[0, 10]], + timeline_limit: 0, // we don't care about the most recent message for spaces + required_state: REQUIRED_STATE_LIST, + include_old_rooms: { + timeline_limit: 0, + required_state: REQUIRED_STATE_LIST, + }, + filters: { + room_types: ["m.space"], + }, + }, + invites: { + ranges: [[0, 10]], + timeline_limit: 1, // most recent message display + required_state: REQUIRED_STATE_LIST, + include_old_rooms: { + timeline_limit: 0, + required_state: REQUIRED_STATE_LIST, + }, + filters: { + is_invite: true, + }, + }, + favourites: { + ranges: [[0, 10]], + timeline_limit: 1, // most recent message display + required_state: REQUIRED_STATE_LIST, + include_old_rooms: { + timeline_limit: 0, + required_state: REQUIRED_STATE_LIST, + }, + filters: { + tags: ["m.favourite"], + }, + }, + dms: { + ranges: [[0, 10]], + timeline_limit: 1, // most recent message display + required_state: REQUIRED_STATE_LIST, + include_old_rooms: { + timeline_limit: 0, + required_state: REQUIRED_STATE_LIST, + }, + filters: { + is_dm: true, + is_invite: false, + // If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead + not_tags: ["m.favourite", "m.lowpriority"], + }, + }, + untagged: { + // SSS will dupe suppress invites/dms from here, so we don't need "not dms, not invites" + ranges: [[0, 10]], + timeline_limit: 1, // most recent message display + required_state: REQUIRED_STATE_LIST, + include_old_rooms: { + timeline_limit: 0, + required_state: REQUIRED_STATE_LIST, + }, + }, +}; + export type PartialSlidingSyncRequest = { filters?: MSC3575Filter; sort?: string[]; @@ -103,6 +175,8 @@ export type PartialSlidingSyncRequest = { * sync options and code. */ export class SlidingSyncManager { + public static serverSupportsSlidingSync: boolean; + public static readonly ListSpaces = "space_list"; public static readonly ListSearch = "search_list"; private static readonly internalInstance = new SlidingSyncManager(); @@ -116,48 +190,17 @@ export class SlidingSyncManager { return SlidingSyncManager.internalInstance; } - public configure(client: MatrixClient, proxyUrl: string): SlidingSync { + private configure(client: MatrixClient, proxyUrl: string): SlidingSync { this.client = client; + // create the set of lists we will use. + const lists = new Map(); + for (const listName in sssLists) { + lists.set(listName, sssLists[listName]); + } // by default use the encrypted subscription as that gets everything, which is a safer // default than potentially missing member events. - this.slidingSync = new SlidingSync( - proxyUrl, - new Map(), - ENCRYPTED_SUBSCRIPTION, - client, - SLIDING_SYNC_TIMEOUT_MS, - ); + this.slidingSync = new SlidingSync(proxyUrl, lists, ENCRYPTED_SUBSCRIPTION, client, SLIDING_SYNC_TIMEOUT_MS); this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION); - // set the space list - this.slidingSync.setList(SlidingSyncManager.ListSpaces, { - ranges: [[0, 20]], - sort: ["by_name"], - slow_get_all_rooms: true, - timeline_limit: 0, - required_state: [ - [EventType.RoomJoinRules, ""], // the public icon on the room list - [EventType.RoomAvatar, ""], // any room avatar - [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead - [EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly - [EventType.RoomCreate, ""], // for isSpaceRoom checks - [EventType.SpaceChild, MSC3575_WILDCARD], // all space children - [EventType.SpaceParent, MSC3575_WILDCARD], // all space parents - [EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room - ], - include_old_rooms: { - timeline_limit: 0, - required_state: [ - [EventType.RoomCreate, ""], - [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead - [EventType.SpaceChild, MSC3575_WILDCARD], // all space children - [EventType.SpaceParent, MSC3575_WILDCARD], // all space parents - [EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room - ], - }, - filters: { - room_types: ["m.space"], - }, - }); this.configureDefer.resolve(); return this.slidingSync; } @@ -220,99 +263,113 @@ export class SlidingSyncManager { return this.slidingSync!.getListParams(listKey)!; } - public async setRoomVisible(roomId: string, visible: boolean): Promise { + /** + * Announces that the user has chosen to view the given room and that room will now + * be displayed, so it should have more state loaded. + * @param roomId The room to set visible + */ + public async setRoomVisible(roomId: string): Promise { await this.configureDefer.promise; const subscriptions = this.slidingSync!.getRoomSubscriptions(); - if (visible) { - subscriptions.add(roomId); - } else { - subscriptions.delete(roomId); - } + if (subscriptions.has(roomId)) return; + + subscriptions.add(roomId); + const room = this.client?.getRoom(roomId); - let shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId)); - if (!room) { - // default to safety: request all state if we can't work it out. This can happen if you - // refresh the app whilst viewing a room: we call setRoomVisible before we know anything - // about the room. - shouldLazyLoad = false; + // default to safety: request all state if we can't work it out. This can happen if you + // refresh the app whilst viewing a room: we call setRoomVisible before we know anything + // about the room. + let shouldLazyLoad = false; + if (room) { + // do not lazy load encrypted rooms as we need the entire member list. + shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId)); } - logger.log("SlidingSync setRoomVisible:", roomId, visible, "shouldLazyLoad:", shouldLazyLoad); + logger.log("SlidingSync setRoomVisible:", roomId, "shouldLazyLoad:", shouldLazyLoad); if (shouldLazyLoad) { // lazy load this room this.slidingSync!.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_NAME); } - const p = this.slidingSync!.modifyRoomSubscriptions(subscriptions); + this.slidingSync!.modifyRoomSubscriptions(subscriptions); if (room) { - return roomId; // we have data already for this room, show immediately e.g it's in a list + return; // we have data already for this room, show immediately e.g it's in a list } - try { - // wait until the next sync before returning as RoomView may need to know the current state - await p; - } catch { - logger.warn("SlidingSync setRoomVisible:", roomId, visible, "failed to confirm transaction"); - } - return roomId; + // wait until we know about this room. This may take a little while. + return new Promise((resolve) => { + logger.log(`SlidingSync setRoomVisible room ${roomId} not found, waiting for ClientEvent.Room`); + const waitForRoom = (r: Room): void => { + if (r.roomId === roomId) { + this.client?.off(ClientEvent.Room, waitForRoom); + logger.log(`SlidingSync room ${roomId} found, resolving setRoomVisible`); + resolve(); + } + }; + this.client?.on(ClientEvent.Room, waitForRoom); + }); } /** - * Retrieve all rooms on the user's account. Used for pre-populating the local search cache. - * Retrieval is gradual over time. + * Retrieve all rooms on the user's account. Retrieval is gradual over time. + * This function MUST be called BEFORE the first sync request goes out. * @param batchSize The number of rooms to return in each request. * @param gapBetweenRequestsMs The number of milliseconds to wait between requests. */ - public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise { - await sleep(gapBetweenRequestsMs); // wait a bit as this is called on first render so let's let things load - let startIndex = batchSize; - let hasMore = true; - let firstTime = true; - while (hasMore) { - const endIndex = startIndex + batchSize - 1; - try { - const ranges = [ - [0, batchSize - 1], - [startIndex, endIndex], - ]; - if (firstTime) { - await this.slidingSync!.setList(SlidingSyncManager.ListSearch, { - // e.g [0,19] [20,39] then [0,19] [40,59]. We keep [0,20] constantly to ensure - // any changes to the list whilst spidering are caught. - ranges: ranges, - sort: [ - "by_recency", // this list isn't shown on the UI so just sorting by timestamp is enough - ], - timeline_limit: 0, // we only care about the room details, not messages in the room - required_state: [ - [EventType.RoomJoinRules, ""], // the public icon on the room list - [EventType.RoomAvatar, ""], // any room avatar - [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead - [EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly - [EventType.RoomCreate, ""], // for isSpaceRoom checks - [EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room - ], - // we don't include_old_rooms here in an effort to reduce the impact of spidering all rooms - // on the user's account. This means some data in the search dialog results may be inaccurate - // e.g membership of space, but this will be corrected when the user clicks on the room - // as the direct room subscription does include old room iterations. - filters: { - // we get spaces via a different list, so filter them out - not_room_types: ["m.space"], - }, - }); - } else { - await this.slidingSync!.setListRanges(SlidingSyncManager.ListSearch, ranges); - } - } catch { - // do nothing, as we reject only when we get interrupted but that's fine as the next - // request will include our data - } finally { - // gradually request more over time, even on errors. - await sleep(gapBetweenRequestsMs); + private async startSpidering( + slidingSync: SlidingSync, + batchSize: number, + gapBetweenRequestsMs: number, + ): Promise { + // The manager has created several lists (see `sssLists` in this file), all of which will be spidered simultaneously. + // There are multiple lists to ensure that we can populate invites/favourites/DMs sections immediately, rather than + // potentially waiting minutes if they are all very old rooms (and hence are returned last by the server). In this + // way, the lists are effectively priority requests. We don't actually care which room goes into which list at this + // point, as the RoomListStore will calculate this based on the returned data. + + // copy the initial set of list names and ranges, we'll keep this map updated. + const listToUpperBound = new Map( + Object.keys(sssLists).map((listName) => { + return [listName, sssLists[listName].ranges[0][1]]; + }), + ); + console.log("startSpidering:", listToUpperBound); + + // listen for a response from the server. ANY 200 OK will do here, as we assume that it is ACKing + // the request change we have sent out. TODO: this may not be true if you concurrently subscribe to a room :/ + // but in that case, for spidering at least, it isn't the end of the world as request N+1 includes all indexes + // from request N. + const lifecycle = async ( + state: SlidingSyncState, + _: MSC3575SlidingSyncResponse | null, + err?: Error, + ): Promise => { + if (state !== SlidingSyncState.Complete) { + return; } - const listData = this.slidingSync!.getListData(SlidingSyncManager.ListSearch)!; - hasMore = endIndex + 1 < listData.joinedCount; - startIndex += batchSize; - firstTime = false; - } + await sleep(gapBetweenRequestsMs); // don't tightloop; even on errors + if (err) { + return; + } + + // for all lists with total counts > range => increase the range + let hasSetRanges = false; + listToUpperBound.forEach((currentUpperBound, listName) => { + const totalCount = slidingSync.getListData(listName)?.joinedCount || 0; + if (currentUpperBound < totalCount) { + // increment the upper bound + const newUpperBound = currentUpperBound + batchSize; + console.log(`startSpidering: ${listName} ${currentUpperBound} => ${newUpperBound}`); + listToUpperBound.set(listName, newUpperBound); + // make the next request. This will only send the request when this callback has finished, so if + // we set all the list ranges at once we will only send 1 new request. + slidingSync.setListRanges(listName, [[0, newUpperBound]]); + hasSetRanges = true; + } + }); + if (!hasSetRanges) { + // finish spidering + slidingSync.off(SlidingSyncEvent.Lifecycle, lifecycle); + } + }; + slidingSync.on(SlidingSyncEvent.Lifecycle, lifecycle); } /** @@ -325,42 +382,10 @@ export class SlidingSyncManager { * @returns A working Sliding Sync or undefined */ public async setup(client: MatrixClient): Promise { - const baseUrl = client.baseUrl; - const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url"); - const wellKnownProxyUrl = await this.getProxyFromWellKnown(client); - - const slidingSyncEndpoint = proxyUrl || wellKnownProxyUrl || baseUrl; - - this.configure(client, slidingSyncEndpoint); - logger.info("Sliding sync activated at", slidingSyncEndpoint); - this.startSpidering(100, 50); // 100 rooms at a time, 50ms apart - - return this.slidingSync; - } - - /** - * Get the sliding sync proxy URL from the client well known - * @param client The MatrixClient to use - * @return The proxy url - */ - public async getProxyFromWellKnown(client: MatrixClient): Promise { - let proxyUrl: string | undefined; - - try { - const clientDomain = await client.getDomain(); - if (clientDomain === null) { - throw new RangeError("Homeserver domain is null"); - } - const clientWellKnown = await AutoDiscovery.findClientConfig(clientDomain); - proxyUrl = clientWellKnown?.["org.matrix.msc3575.proxy"]?.url; - } catch { - // Either client.getDomain() is null so we've shorted out, or is invalid so `AutoDiscovery.findClientConfig` has thrown - } - - if (proxyUrl != undefined) { - logger.log("getProxyFromWellKnown: client well-known declares sliding sync proxy at", proxyUrl); - } - return proxyUrl; + const slidingSync = this.configure(client, client.baseUrl); + logger.info("Simplified Sliding Sync activated at", client.baseUrl); + this.startSpidering(slidingSync, 50, 50); // 50 rooms at a time, 50ms apart + return slidingSync; } /** @@ -371,9 +396,9 @@ export class SlidingSyncManager { public async nativeSlidingSyncSupport(client: MatrixClient): Promise { // Per https://github.com/matrix-org/matrix-spec-proposals/pull/3575/files#r1589542561 // `client` can be undefined/null in tests for some reason. - const support = await client?.doesServerSupportUnstableFeature("org.matrix.msc3575"); + const support = await client?.doesServerSupportUnstableFeature("org.matrix.simplified_msc3575"); if (support) { - logger.log("nativeSlidingSyncSupport: sliding sync advertised as unstable"); + logger.log("nativeSlidingSyncSupport: org.matrix.simplified_msc3575 sliding sync advertised as unstable"); } return support; } @@ -387,20 +412,9 @@ export class SlidingSyncManager { */ public async checkSupport(client: MatrixClient): Promise { if (await this.nativeSlidingSyncSupport(client)) { - SlidingSyncController.serverSupportsSlidingSync = true; + SlidingSyncManager.serverSupportsSlidingSync = true; return; } - - const proxyUrl = await this.getProxyFromWellKnown(client); - if (proxyUrl != undefined) { - const response = await fetch(new URL("/client/server.json", proxyUrl), { - method: Method.Get, - signal: timeoutSignal(10 * 1000), // 10s - }); - if (response.status === 200) { - logger.log("checkSupport: well-known sliding sync proxy is up at", proxyUrl); - SlidingSyncController.serverSupportsSlidingSync = true; - } - } + SlidingSyncManager.serverSupportsSlidingSync = false; } } diff --git a/src/Unread.ts b/src/Unread.ts index 2c8fa0cff3..e8f4769e25 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -11,7 +11,6 @@ import { logger } from "matrix-js-sdk/src/logger"; import shouldHideEvent from "./shouldHideEvent"; import { haveRendererForEvent } from "./events/EventTileFactory"; -import SettingsStore from "./settings/SettingsStore"; import { RoomNotifState, getRoomNotifsState } from "./RoomNotifs"; /** @@ -44,12 +43,6 @@ export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent): } export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean { - if (SettingsStore.getValue("feature_sliding_sync")) { - // TODO: https://github.com/vector-im/element-web/issues/23207 - // Sliding Sync doesn't support unread indicator dots (yet...) - return false; - } - const toCheck: Array = [room]; if (includeThreads) { toCheck.push(...room.getThreads()); diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index de313246b1..3c54b98b0e 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -42,8 +42,6 @@ import ContextMenu, { } from "../../structures/ContextMenu"; import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; import type ExtraTile from "./ExtraTile"; -import SettingsStore from "../../../settings/SettingsStore"; -import { SlidingSyncManager } from "../../../SlidingSyncManager"; import NotificationBadge from "./NotificationBadge"; import RoomTile from "./RoomTile"; @@ -106,12 +104,8 @@ export default class RoomSublist extends React.Component { private heightAtStart: number; private notificationState: ListNotificationState; - private slidingSyncMode: boolean; - public constructor(props: IProps) { super(props); - // when this setting is toggled it restarts the app so it's safe to not watch this. - this.slidingSyncMode = SettingsStore.getValue("feature_sliding_sync"); this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId); this.heightAtStart = 0; @@ -165,9 +159,6 @@ export default class RoomSublist extends React.Component { } private get numVisibleTiles(): number { - if (this.slidingSyncMode) { - return this.state.rooms.length; - } const nVisible = Math.ceil(this.layout.visibleTiles); return Math.min(nVisible, this.numTiles); } @@ -329,12 +320,6 @@ export default class RoomSublist extends React.Component { }; private onShowAllClick = async (): Promise => { - if (this.slidingSyncMode) { - const count = RoomListStore.instance.getCount(this.props.tagId); - await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, { - ranges: [[0, count]], - }); - } // read number of visible tiles before we mutate it const numVisibleTiles = this.numVisibleTiles; const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding); @@ -554,13 +539,8 @@ export default class RoomSublist extends React.Component { let contextMenu: JSX.Element | undefined; if (this.state.contextMenuPosition) { - let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; - let isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; - if (this.slidingSyncMode) { - const slidingList = SlidingSyncManager.instance.slidingSync?.getListParams(this.props.tagId); - isAlphabetical = (slidingList?.sort || [])[0] === "by_name"; - isUnreadFirst = (slidingList?.sort || [])[0] === "by_notification_level"; - } + const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; + const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; // Invites don't get some nonsense options, so only add them if we have to. let otherSections: JSX.Element | undefined; @@ -763,17 +743,12 @@ export default class RoomSublist extends React.Component { // floats above the resize handle, if we have one present. If the user has all // tiles visible, it becomes 'show less'. let showNButton: JSX.Element | undefined; - const hasMoreSlidingSync = - this.slidingSyncMode && RoomListStore.instance.getCount(this.props.tagId) > this.state.rooms.length; - if (maxTilesPx > this.state.height || hasMoreSlidingSync) { + if (maxTilesPx > this.state.height) { // the height of all the tiles is greater than the section height: we need a 'show more' button const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT; const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight); - let numMissing = this.numTiles - amountFullyShown; - if (this.slidingSyncMode) { - numMissing = RoomListStore.instance.getCount(this.props.tagId) - amountFullyShown; - } + const numMissing = this.numTiles - amountFullyShown; const label = _t("room_list|show_n_more", { count: numMissing }); let showMoreText: ReactNode = {label}; if (this.props.isMinimized) showMoreText = null; diff --git a/src/hooks/useSlidingSyncRoomSearch.ts b/src/hooks/useSlidingSyncRoomSearch.ts deleted file mode 100644 index 17eca74474..0000000000 --- a/src/hooks/useSlidingSyncRoomSearch.ts +++ /dev/null @@ -1,82 +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 { useCallback, useState } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; - -import { MatrixClientPeg } from "../MatrixClientPeg"; -import { useLatestResult } from "./useLatestResult"; -import { SlidingSyncManager } from "../SlidingSyncManager"; - -export interface SlidingSyncRoomSearchOpts { - limit: number; - query: string; -} - -export const useSlidingSyncRoomSearch = (): { - loading: boolean; - rooms: Room[]; - search(opts: SlidingSyncRoomSearchOpts): Promise; -} => { - const [rooms, setRooms] = useState([]); - - const [loading, setLoading] = useState(false); - - const [updateQuery, updateResult] = useLatestResult<{ term: string; limit?: number }, Room[]>(setRooms); - - const search = useCallback( - async ({ limit = 100, query: term }: SlidingSyncRoomSearchOpts): Promise => { - const opts = { limit, term }; - updateQuery(opts); - - if (!term?.length) { - setRooms([]); - return true; - } - - try { - setLoading(true); - await SlidingSyncManager.instance.ensureListRegistered(SlidingSyncManager.ListSearch, { - ranges: [[0, limit]], - filters: { - room_name_like: term, - }, - }); - const rooms: Room[] = []; - const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync!.getListData( - SlidingSyncManager.ListSearch, - )!; - let i = 0; - while (roomIndexToRoomId[i]) { - const roomId = roomIndexToRoomId[i]; - const room = MatrixClientPeg.safeGet().getRoom(roomId); - if (room) { - rooms.push(room); - } - i++; - } - updateResult(opts, rooms); - return true; - } catch (e) { - console.error("Could not fetch sliding sync rooms for params", { limit, term }, e); - updateResult(opts, []); - return false; - } finally { - setLoading(false); - // TODO: delete the list? - } - }, - [updateQuery, updateResult], - ); - - return { - loading, - rooms, - search, - } as const; -}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 322fcf4c93..5607a613eb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3148,6 +3148,7 @@ "view": "Views room with given address", "whois": "Displays information about a user" }, + "sliding_sync_legacy_no_longer_supported": "Legacy sliding sync is no longer supported: please log out and back in to enable the new sliding sync flag", "space": { "add_existing_room_space": { "create": "Want to add a new room instead?", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 8784e12b6c..6984932fd9 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -198,7 +198,8 @@ export interface Settings { "feature_html_topic": IFeature; "feature_bridge_state": IFeature; "feature_jump_to_date": IFeature; - "feature_sliding_sync": IFeature; + "feature_sliding_sync": IBaseSetting; + "feature_simplified_sliding_sync": IFeature; "feature_element_call_video_rooms": IFeature; "feature_group_calls": IFeature; "feature_disable_call_per_sender_encryption": IFeature; @@ -210,7 +211,6 @@ export interface Settings { "feature_ask_to_join": IFeature; "feature_notifications": IFeature; // These are in the feature namespace but aren't actually features - "feature_sliding_sync_proxy_url": IBaseSetting; "feature_hidebold": IBaseSetting; "useOnlyCurrentProfiles": IBaseSetting; @@ -539,7 +539,14 @@ export const SETTINGS: Settings = { true, ), }, + // legacy sliding sync flag: no longer works, will error for anyone who's still using it "feature_sliding_sync": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + supportedLevelsAreOrdered: true, + shouldWarn: true, + default: false, + }, + "feature_simplified_sliding_sync": { isFeature: true, labsGroup: LabGroup.Developer, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, @@ -550,11 +557,6 @@ export const SETTINGS: Settings = { default: false, controller: new SlidingSyncController(), }, - "feature_sliding_sync_proxy_url": { - // This is not a distinct feature, it is a legacy setting for feature_sliding_sync above - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, - default: "", - }, "feature_element_call_video_rooms": { isFeature: true, labsGroup: LabGroup.VoiceAndVideo, diff --git a/src/settings/controllers/SlidingSyncController.ts b/src/settings/controllers/SlidingSyncController.ts index 11ae1a2ba0..44de3ba8ee 100644 --- a/src/settings/controllers/SlidingSyncController.ts +++ b/src/settings/controllers/SlidingSyncController.ts @@ -11,20 +11,19 @@ import SettingController from "./SettingController"; import PlatformPeg from "../../PlatformPeg"; import SettingsStore from "../SettingsStore"; import { _t } from "../../languageHandler"; +import { SlidingSyncManager } from "../../SlidingSyncManager"; export default class SlidingSyncController extends SettingController { - public static serverSupportsSlidingSync: boolean; - public async onChange(): Promise { PlatformPeg.get()?.reload(); } public get settingDisabled(): boolean | string { // Cannot be disabled once enabled, user has been warned and must log out and back in. - if (SettingsStore.getValue("feature_sliding_sync")) { + if (SettingsStore.getValue("feature_simplified_sliding_sync")) { return _t("labs|sliding_sync_disabled_notice"); } - if (!SlidingSyncController.serverSupportsSlidingSync) { + if (!SlidingSyncManager.serverSupportsSlidingSync) { return _t("labs|sliding_sync_server_no_support"); } diff --git a/src/stores/MemberListStore.ts b/src/stores/MemberListStore.ts index 04c5824d99..29269bac94 100644 --- a/src/stores/MemberListStore.ts +++ b/src/stores/MemberListStore.ts @@ -122,7 +122,7 @@ export class MemberListStore { * @returns True if enabled */ private async isLazyLoadingEnabled(roomId: string): Promise { - if (SettingsStore.getValue("feature_sliding_sync")) { + if (SettingsStore.getValue("feature_simplified_sliding_sync")) { // only unencrypted rooms use lazy loading return !(await this.stores.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId)); } @@ -134,7 +134,7 @@ export class MemberListStore { * @returns True if there is storage for lazy loading members */ private isLazyMemberStorageEnabled(): boolean { - if (SettingsStore.getValue("feature_sliding_sync")) { + if (SettingsStore.getValue("feature_simplified_sliding_sync")) { return false; } return this.stores.client!.hasLazyLoadMembersEnabled(); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 91edcbb412..633d55f3f6 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -372,11 +372,7 @@ export class RoomViewStore extends EventEmitter { if (prevRoomCall !== null && (!payload.view_call || payload.room_id !== this.state.roomId)) prevRoomCall.presented = false; - if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { - if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { - // unsubscribe from this room, but don't await it as we don't care when this gets done. - this.stores.slidingSyncManager.setRoomVisible(this.state.subscribingRoomId, false); - } + if (SettingsStore.getValue("feature_simplified_sliding_sync") && this.state.roomId !== payload.room_id) { this.setState({ subscribingRoomId: payload.room_id, roomId: payload.room_id, @@ -392,13 +388,8 @@ export class RoomViewStore extends EventEmitter { }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. - await this.stores.slidingSyncManager.setRoomVisible(payload.room_id, true); - // Whilst we were subscribing another room was viewed, so stop what we're doing and - // unsubscribe - if (this.state.subscribingRoomId !== payload.room_id) { - this.stores.slidingSyncManager.setRoomVisible(payload.room_id, false); - return; - } + await this.stores.slidingSyncManager.setRoomVisible(payload.room_id); + // Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now this.dis?.dispatch({ ...payload, diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index e1d2f7b7e8..6985e007bd 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -33,7 +33,6 @@ import { VisibilityProvider } from "./filters/VisibilityProvider"; import { SpaceWatcher } from "./SpaceWatcher"; import { type IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { type RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; -import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; import { SdkContextClass } from "../../contexts/SDKContext"; import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute"; @@ -406,6 +405,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient implem public setTagSorting(tagId: TagID, sort: SortAlgorithm): void { this.setAndPersistTagSorting(tagId, sort); + // We'll always need an update after changing the sort order, so mark for update and trigger + // immediately. + this.updateFn.mark(); this.updateFn.trigger(); } @@ -642,16 +644,9 @@ export default class RoomListStore { public static get instance(): Interface { if (!RoomListStore.internalInstance) { - if (SettingsStore.getValue("feature_sliding_sync")) { - logger.info("using SlidingRoomListStoreClass"); - const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance); - instance.start(); - RoomListStore.internalInstance = instance; - } else { - const instance = new RoomListStoreClass(defaultDispatcher); - instance.start(); - RoomListStore.internalInstance = instance; - } + const instance = new RoomListStoreClass(defaultDispatcher); + instance.start(); + RoomListStore.internalInstance = instance; } return this.internalInstance; diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts deleted file mode 100644 index b7ff70c671..0000000000 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ /dev/null @@ -1,391 +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 { type EmptyObject, type Room } from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; -import { type MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync"; -import { type Optional } from "matrix-events-sdk"; - -import { type RoomUpdateCause, type TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models"; -import { type ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; -import { type ActionPayload } from "../../dispatcher/payloads"; -import { type MatrixDispatcher } from "../../dispatcher/dispatcher"; -import { type IFilterCondition } from "./filters/IFilterCondition"; -import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; -import { type RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; -import { MetaSpace, type SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; -import { LISTS_LOADING_EVENT } from "./RoomListStore"; -import { UPDATE_EVENT } from "../AsyncStore"; -import { type SdkContextClass } from "../../contexts/SDKContext"; - -export const SlidingSyncSortToFilter: Record = { - [SortAlgorithm.Alphabetic]: ["by_name", "by_recency"], - [SortAlgorithm.Recent]: ["by_notification_level", "by_recency"], - [SortAlgorithm.Manual]: ["by_recency"], -}; - -const filterConditions: Record = { - [DefaultTagID.Invite]: { - is_invite: true, - }, - [DefaultTagID.Favourite]: { - tags: ["m.favourite"], - }, - [DefaultTagID.DM]: { - is_dm: true, - is_invite: false, - // If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead - not_tags: ["m.favourite", "m.lowpriority"], - }, - [DefaultTagID.Untagged]: { - is_dm: false, - is_invite: false, - not_room_types: ["m.space"], - not_tags: ["m.favourite", "m.lowpriority"], - // spaces filter added dynamically - }, - [DefaultTagID.LowPriority]: { - tags: ["m.lowpriority"], - // If a room has both Favourite & Low Prio tags then it'll be shown under Favourites - not_tags: ["m.favourite"], - }, - // TODO https://github.com/vector-im/element-web/issues/23207 - // DefaultTagID.ServerNotice, - // DefaultTagID.Suggested, - // DefaultTagID.Archived, -}; - -export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; - -export class SlidingRoomListStoreClass extends AsyncStoreWithClient implements Interface { - private tagIdToSortAlgo: Record = {}; - private tagMap: ITagMap = {}; - private counts: Record = {}; - private stickyRoomId: Optional; - - public constructor( - dis: MatrixDispatcher, - private readonly context: SdkContextClass, - ) { - super(dis); - this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares - } - - public async setTagSorting(tagId: TagID, sort: SortAlgorithm): Promise { - logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort); - this.tagIdToSortAlgo[tagId] = sort; - switch (sort) { - case SortAlgorithm.Alphabetic: - await this.context.slidingSyncManager.ensureListRegistered(tagId, { - sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], - }); - break; - case SortAlgorithm.Recent: - await this.context.slidingSyncManager.ensureListRegistered(tagId, { - sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], - }); - break; - case SortAlgorithm.Manual: - logger.error("cannot enable manual sort in sliding sync mode"); - break; - default: - logger.error("unknown sort mode: ", sort); - } - } - - public getTagSorting(tagId: TagID): SortAlgorithm { - let algo = this.tagIdToSortAlgo[tagId]; - if (!algo) { - logger.warn("SlidingRoomListStore.getTagSorting: no sort algorithm for tag ", tagId); - algo = SortAlgorithm.Recent; // why not, we have to do something.. - } - return algo; - } - - public getCount(tagId: TagID): number { - return this.counts[tagId] || 0; - } - - public setListOrder(tagId: TagID, order: ListAlgorithm): void { - // TODO: https://github.com/vector-im/element-web/issues/23207 - } - - public getListOrder(tagId: TagID): ListAlgorithm { - // TODO: handle unread msgs first? https://github.com/vector-im/element-web/issues/23207 - return ListAlgorithm.Natural; - } - - /** - * Adds a filter condition to the room list store. Filters may be applied async, - * and thus might not cause an update to the store immediately. - * @param {IFilterCondition} filter The filter condition to add. - */ - public async addFilter(filter: IFilterCondition): Promise { - // Do nothing, the filters are only used by SpaceWatcher to see if a room should appear - // in the room list. We do not support arbitrary code for filters in sliding sync. - } - - /** - * Removes a filter condition from the room list store. If the filter was - * not previously added to the room list store, this will no-op. The effects - * of removing a filter may be applied async and therefore might not cause - * an update right away. - * @param {IFilterCondition} filter The filter condition to remove. - */ - public removeFilter(filter: IFilterCondition): void { - // Do nothing, the filters are only used by SpaceWatcher to see if a room should appear - // in the room list. We do not support arbitrary code for filters in sliding sync. - } - - /** - * Gets the tags for a room identified by the store. The returned set - * should never be empty, and will contain DefaultTagID.Untagged if - * the store is not aware of any tags. - * @param room The room to get the tags for. - * @returns The tags for the room. - */ - public getTagsForRoom(room: Room): TagID[] { - // check all lists for each tag we know about and see if the room is there - const tags: TagID[] = []; - for (const tagId in this.tagIdToSortAlgo) { - const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId); - if (!listData) { - continue; - } - for (const roomIndex in listData.roomIndexToRoomId) { - const roomId = listData.roomIndexToRoomId[roomIndex]; - if (roomId === room.roomId) { - tags.push(tagId); - break; - } - } - } - return tags; - } - - /** - * Manually update a room with a given cause. This should only be used if the - * room list store would otherwise be incapable of doing the update itself. Note - * that this may race with the room list's regular operation. - * @param {Room} room The room to update. - * @param {RoomUpdateCause} cause The cause to update for. - */ - public async manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { - // TODO: this is only used when you forget a room, not that important for now. - } - - public get orderedLists(): ITagMap { - return this.tagMap; - } - - private refreshOrderedLists(tagId: string, roomIndexToRoomId: Record): void { - const tagMap = this.tagMap; - - // this room will not move due to it being viewed: it is sticky. This can be null to indicate - // no sticky room if you aren't viewing a room. - this.stickyRoomId = this.context.roomViewStore.getRoomId(); - let stickyRoomNewIndex = -1; - const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room): boolean => { - return room.roomId === this.stickyRoomId; - }); - - // order from low to high - const orderedRoomIndexes = Object.keys(roomIndexToRoomId) - .map((numStr) => { - return Number(numStr); - }) - .sort((a, b) => { - return a - b; - }); - const seenRoomIds = new Set(); - const orderedRoomIds = orderedRoomIndexes.map((i) => { - const rid = roomIndexToRoomId[i]; - if (seenRoomIds.has(rid)) { - logger.error("room " + rid + " already has an index position: duplicate room!"); - } - seenRoomIds.add(rid); - if (!rid) { - throw new Error("index " + i + " has no room ID: Map => " + JSON.stringify(roomIndexToRoomId)); - } - if (rid === this.stickyRoomId) { - stickyRoomNewIndex = i; - } - return rid; - }); - logger.debug( - `SlidingRoomListStore.refreshOrderedLists ${tagId} sticky: ${this.stickyRoomId}`, - `${stickyRoomOldIndex} -> ${stickyRoomNewIndex}`, - "rooms:", - orderedRoomIds.length < 30 ? orderedRoomIds : orderedRoomIds.length, - ); - - if (this.stickyRoomId && stickyRoomOldIndex >= 0 && stickyRoomNewIndex >= 0) { - // this update will move this sticky room from old to new, which we do not want. - // Instead, keep the sticky room ID index position as it is, swap it with - // whatever was in its place. - // Some scenarios with sticky room S and bump room B (other letters unimportant): - // A, S, C, B S, A, B - // B, A, S, C <---- without sticky rooms ---> B, S, A - // B, S, A, C <- with sticky rooms applied -> S, B, A - // In other words, we need to swap positions to keep it locked in place. - const inWayRoomId = orderedRoomIds[stickyRoomOldIndex]; - orderedRoomIds[stickyRoomOldIndex] = this.stickyRoomId; - orderedRoomIds[stickyRoomNewIndex] = inWayRoomId; - } - - // now set the rooms - const rooms: Room[] = []; - orderedRoomIds.forEach((roomId) => { - const room = this.matrixClient?.getRoom(roomId); - if (!room) { - return; - } - rooms.push(room); - }); - tagMap[tagId] = rooms; - this.tagMap = tagMap; - } - - private onSlidingSyncListUpdate(tagId: string, joinCount: number, roomIndexToRoomId: Record): void { - this.counts[tagId] = joinCount; - this.refreshOrderedLists(tagId, roomIndexToRoomId); - // let the UI update - this.emit(LISTS_UPDATE_EVENT); - } - - private onRoomViewStoreUpdated(): void { - // we only care about this to know when the user has clicked on a room to set the stickiness value - if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) { - return; - } - - let hasUpdatedAnyList = false; - - // every list with the OLD sticky room ID needs to be resorted because it now needs to take - // its proper place as it is no longer sticky. The newly sticky room can remain the same though, - // as we only actually care about its sticky status when we get list updates. - const oldStickyRoom = this.stickyRoomId; - // it's not safe to check the data in slidingSync as it is tracking the server's view of the - // room list. There's an edge case whereby the sticky room has gone outside the window and so - // would not be present in the roomIndexToRoomId map anymore, and hence clicking away from it - // will make it disappear eventually. We need to check orderedLists as that is the actual - // sorted renderable list of rooms which sticky rooms apply to. - for (const tagId in this.orderedLists) { - const list = this.orderedLists[tagId]; - const room = list.find((room) => { - return room.roomId === oldStickyRoom; - }); - if (room) { - // resort it based on the slidingSync view of the list. This may cause this old sticky - // room to cease to exist. - const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId); - if (!listData) { - continue; - } - this.refreshOrderedLists(tagId, listData.roomIndexToRoomId); - hasUpdatedAnyList = true; - } - } - // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. - this.stickyRoomId = this.context.roomViewStore.getRoomId(); - - if (hasUpdatedAnyList) { - this.emit(LISTS_UPDATE_EVENT); - } - } - - protected async onReady(): Promise { - logger.info("SlidingRoomListStore.onReady"); - // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. - this.context.slidingSyncManager.slidingSync!.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); - this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); - this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); - if (this.context.spaceStore.activeSpace) { - this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false); - } - - // sliding sync has an initial response for spaces. Now request all the lists. - // We do the spaces list _first_ to avoid potential flickering on DefaultTagID.Untagged list - // which would be caused by initially having no `spaces` filter set, and then suddenly setting one. - OrderedDefaultTagIDs.forEach((tagId) => { - const filter = filterConditions[tagId]; - if (!filter) { - logger.info("SlidingRoomListStore.onReady unsupported list ", tagId); - return; // we do not support this list yet. - } - const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config - this.tagIdToSortAlgo[tagId] = sort; - this.emit(LISTS_LOADING_EVENT, tagId, true); - this.context.slidingSyncManager - .ensureListRegistered(tagId, { - filters: filter, - sort: SlidingSyncSortToFilter[sort], - }) - .then(() => { - this.emit(LISTS_LOADING_EVENT, tagId, false); - }); - }); - } - - private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome: boolean): void => { - logger.info("SlidingRoomListStore.onSelectedSpaceUpdated", activeSpace); - // update the untagged filter - const tagId = DefaultTagID.Untagged; - const filters = filterConditions[tagId]; - const oldSpace = filters.spaces?.[0]; - filters.spaces = activeSpace && activeSpace != MetaSpace.Home ? [activeSpace] : undefined; - if (oldSpace !== activeSpace) { - // include subspaces in this list - this.context.spaceStore.traverseSpace( - activeSpace, - (roomId: string) => { - if (roomId === activeSpace) { - return; - } - if (!filters.spaces) { - filters.spaces = []; - } - filters.spaces.push(roomId); // add subspace - }, - false, - ); - - this.emit(LISTS_LOADING_EVENT, tagId, true); - this.context.slidingSyncManager - .ensureListRegistered(tagId, { - filters: filters, - }) - .then(() => { - this.emit(LISTS_LOADING_EVENT, tagId, false); - }); - } - }; - - // Intended for test usage - public async resetStore(): Promise { - // Test function - } - - /** - * Regenerates the room whole room list, discarding any previous results. - * - * Note: This is only exposed externally for the tests. Do not call this from within - * the app. - * @param trigger Set to false to prevent a list update from being sent. Should only - * be used if the calling code will manually trigger the update. - */ - public regenerateAllLists({ trigger = true }): void { - // Test function - } - - protected async onNotReady(): Promise { - await this.resetStore(); - } - - protected async onAction(payload: ActionPayload): Promise {} -} diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index e84f538993..e41eeee816 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -69,6 +69,12 @@ export const getLastTs = (r: Room, userId: string): number => { if (!r?.timeline) { return Number.MAX_SAFE_INTEGER; } + // MSC4186: Simplified Sliding Sync sets this. + // If it's present, sort by it. + const bumpStamp = r.getBumpStamp(); + if (bumpStamp) { + return bumpStamp; + } // If the room hasn't been joined yet, it probably won't have a timeline to // parse. We'll still fall back to the timeline if this fails, but chances diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 1f6351b7b9..75a42c86bc 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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), diff --git a/test/unit-tests/SlidingSyncManager-test.ts b/test/unit-tests/SlidingSyncManager-test.ts index ddc5911262..20d9110bcc 100644 --- a/test/unit-tests/SlidingSyncManager-test.ts +++ b/test/unit-tests/SlidingSyncManager-test.ts @@ -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 = >(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(); 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([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(); 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([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(); + 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(); }); }); }); diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx index 4b986f8d2d..f14a7e0acd 100644 --- a/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx @@ -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"; diff --git a/test/unit-tests/hooks/useSlidingSyncRoomSearch-test.tsx b/test/unit-tests/hooks/useSlidingSyncRoomSearch-test.tsx deleted file mode 100644 index d2ee9ad3bb..0000000000 --- a/test/unit-tests/hooks/useSlidingSyncRoomSearch-test.tsx +++ /dev/null @@ -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); - }); - }); -}); diff --git a/test/unit-tests/stores/MemberListStore-test.ts b/test/unit-tests/stores/MemberListStore-test.ts index 883bb13f11..9139dde85d 100644 --- a/test/unit-tests/stores/MemberListStore-test.ts +++ b/test/unit-tests/stores/MemberListStore-test.ts @@ -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(); }); diff --git a/test/unit-tests/stores/RoomViewStore-test.ts b/test/unit-tests/stores/RoomViewStore-test.ts index be08015fc1..b730853a89 100644 --- a/test/unit-tests/stores/RoomViewStore-test.ts +++ b/test/unit-tests/stores/RoomViewStore-test.ts @@ -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}`); } diff --git a/test/unit-tests/stores/room-list/SlidingRoomListStore-test.ts b/test/unit-tests/stores/room-list/SlidingRoomListStore-test.ts deleted file mode 100644 index dbe06ab326..0000000000 --- a/test/unit-tests/stores/room-list/SlidingRoomListStore-test.ts +++ /dev/null @@ -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 = >(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({ - 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 }> = { - [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]); - }); -});