From a215027c6b77d160685312cdc1df4275fa2b5853 Mon Sep 17 00:00:00 2001 From: kegsay Date: Wed, 7 Sep 2022 16:42:39 +0100 Subject: [PATCH] Implement MSC3575: Sliding Sync (#8328) * Add labs flag for sliding sync; add sliding_sync_proxy_url to config.json * Disable the labs toggle if sliding_sync_proxy_url is not set * Do validation checks on the sliding sync proxy URL before enabling it in Labs * Enable sliding sync and add SlidingSyncManager * Get room subscriptions working * Hijack renderSublists in sliding sync mode * Add support for sorting alphabetically/recency and room name filters * Filter out tombstoned rooms; start adding show more logic list ranges update but the UI doesn't * update the UI when the list is updated * bugfix: make sure the list sorts numerically * Get invites transitioning correctly * Force enable sliding sync and labs for now * Linting * Disable spotlight search * Initial cypress plugins for Sliding Sync Proxy * Use --rm when running Synapse in Docker for Cypress tests * Update src/MatrixClientPeg.ts Co-authored-by: Travis Ralston * Update src/components/views/rooms/RoomSublist.tsx Co-authored-by: Travis Ralston * Update src/settings/controllers/SlidingSyncController.ts Co-authored-by: Travis Ralston * Update src/components/views/rooms/RoomSublist.tsx Co-authored-by: Travis Ralston * WIP add room searching to spotlight search * Only read sliding sync results when there is a result, else use the local cache * Use feature_sliding_sync not slidingSync * Some review comments * More review comments * Use RoomViewStore to set room subscriptions * Comment why any * Update src/components/views/rooms/RoomSublist.tsx Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * Fix cypress docker abstraction * Iterate sliding sync proxy support * Stash mostly functional test * Update sliding sync proxy image * i18n * Add support for spaces; use list ID -> index mappings - Mappings are more reusable and easier to understand than racing for index positions. - Register for all spaces immediately on startup. * When the active space is updated, update the list registration * Set spaces filter in the correct place * Skeleton placeholder whilst loading the space * Filter out spaces from the room list * Use the new txn_id promises * Ensure we actually resolve list registrations * Fix matrix-org/sliding-sync#30: don't show tombstoned search results * Remove unused imports * Add SYNCV3_SECRET to proxy to ensure it starts up; correct aliases for SS test * Add another basic sliding sync e2e test * Unbreak netlify * Add more logging for debugging duplicate rooms * If sliding sync is enabled, always use the rooms result even if it's empty * Drop-in copy of RoomListStore for sliding sync * Remove conditionals from RoomListStore - we have SlidingRoomListStore now * WIP SlidingRoomListStore * Add most sliding sync logic to SlidingRoomListStore Still lots of logic in RoomSublist. Broken things: - Join count is wrong completely. - No skeleton placeholder when switching spaces. * Migrate joined count to SS RLS * Reinstate the skeleton UI when the list is loading * linting * Add support for sticky rooms based on the currently active room * Add a bunch of passing SS E2E tests; some WIP * Unbreak build from git merge * Suppress unread indicators in sliding sync mode * Add regression test for https://github.com/matrix-org/sliding-sync/issues/28 * Add invite test flows; show the invite list The refactor to SS RLS removed the invite list entirely. * Remove show more click as it wasn't the bug * Linting and i18n * only enable SS by default on netlify * Jest fixes; merge conflict fixes; remove debug logging; use right sort enum values * Actually fix jest tests * Add support for favourites and low priority * Bump sliding sync version * Update sliding sync labs to be user configurable * delint * To disable SS or change proxy URL the user has to log out * Review comments * Linting * Apply suggestions from code review Co-authored-by: Travis Ralston * Update src/stores/room-list/SlidingRoomListStore.ts Co-authored-by: Travis Ralston * Review comments * Add issue link for TODO markers * Linting * Apply suggestions from code review Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * More review comments * More review comments * stricter types Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Travis Ralston --- cypress/e2e/sliding-sync/sliding-sync.ts | 322 +++++++++++++++ cypress/global.d.ts | 2 + cypress/plugins/docker/index.ts | 43 +- cypress/plugins/index.ts | 2 + cypress/plugins/sliding-sync/index.ts | 128 ++++++ cypress/plugins/synapsedocker/index.ts | 3 +- cypress/support/e2e.ts | 1 + cypress/support/proxy.ts | 58 +++ src/MatrixClientPeg.ts | 14 + src/SlidingSyncManager.ts | 236 +++++++++++ src/Unread.ts | 6 + .../dialogs/SlidingSyncOptionsDialog.tsx | 130 ++++++ .../views/dialogs/TextInputDialog.tsx | 2 +- .../dialogs/spotlight/SpotlightDialog.tsx | 72 ++-- src/components/views/rooms/RoomSublist.tsx | 65 ++- src/hooks/useSlidingSyncRoomSearch.ts | 86 ++++ src/i18n/strings/en_EN.json | 9 + src/settings/Settings.tsx | 13 + .../controllers/SlidingSyncController.ts | 39 ++ src/stores/RoomViewStore.tsx | 28 ++ src/stores/room-list/Interface.ts | 12 + src/stores/room-list/RoomListStore.ts | 22 +- src/stores/room-list/SlidingRoomListStore.ts | 386 ++++++++++++++++++ src/stores/room-list/SpaceWatcher.ts | 4 +- test/stores/RoomViewStore-test.tsx | 2 + 25 files changed, 1633 insertions(+), 52 deletions(-) create mode 100644 cypress/e2e/sliding-sync/sliding-sync.ts create mode 100644 cypress/plugins/sliding-sync/index.ts create mode 100644 cypress/support/proxy.ts create mode 100644 src/SlidingSyncManager.ts create mode 100644 src/components/views/dialogs/SlidingSyncOptionsDialog.tsx create mode 100644 src/hooks/useSlidingSyncRoomSearch.ts create mode 100644 src/settings/controllers/SlidingSyncController.ts create mode 100644 src/stores/room-list/SlidingRoomListStore.ts diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts new file mode 100644 index 00000000000..5b5ec5145e0 --- /dev/null +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -0,0 +1,322 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import _ from "lodash"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { Layout } from "../../../src/settings/enums/Layout"; +import { ProxyInstance } from "../../plugins/sliding-sync"; + +describe("Sliding Sync", () => { + beforeEach(() => { + cy.startSynapse("default").as("synapse").then(synapse => { + cy.startProxy(synapse).as("proxy"); + }); + + cy.all([ + cy.get("@synapse"), + cy.get("@proxy"), + ]).then(([synapse, proxy]) => { + cy.enableLabsFeature("feature_sliding_sync"); + + cy.intercept("/config.json?cachebuster=*", req => { + return req.continue(res => { + res.send(200, { + ...res.body, + setting_defaults: { + feature_sliding_sync_proxy_url: `http://localhost:${proxy.port}`, + }, + }); + }); + }); + + cy.initTestUser(synapse, "Sloth").then(() => { + return cy.window({ log: false }).then(() => { + cy.createRoom({ name: "Test Room" }).as("roomId"); + }); + }); + }); + }); + + afterEach(() => { + cy.get("@synapse").then(cy.stopSynapse); + cy.get("@proxy").then(cy.stopProxy); + }); + + // assert order + const checkOrder = (wantOrder: string[]) => { + cy.contains(".mx_RoomSublist", "Rooms").find(".mx_RoomTile_title").should((elements) => { + expect(_.map(elements, (e) => { + return e.textContent; + }), "rooms are sorted").to.deep.equal(wantOrder); + }); + }; + const bumpRoom = (alias: string) => { + // Send a message into the given room, this should bump the room to the top + cy.get(alias).then((roomId) => { + return cy.sendEvent(roomId, null, "m.room.message", { + body: "Hello world", + msgtype: "m.text", + }); + }); + }; + const createAndJoinBob = () => { + // create a Bob user + cy.get("@synapse").then((synapse) => { + return cy.getBot(synapse, { + displayName: "Bob", + }).as("bob"); + }); + + // invite Bob to Test Room and accept then send a message. + cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { + return cy.inviteUser(roomId, bob.getUserId()).then(() => { + return bob.joinRoom(roomId); + }); + }); + }; + + // sanity check everything works + it("should correctly render expected messages", () => { + cy.get("@roomId").then(roomId => cy.visit("/#/room/" + roomId)); + cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + + // Wait until configuration is finished + cy.contains( + ".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", + "created and configured the room.", + ); + + // Click "expand" link button + cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click(); + }); + + it("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", () => { + // create rooms and check room names are correct + cy.createRoom({ name: "Apple" }).then(() => cy.contains(".mx_RoomSublist", "Apple")); + cy.createRoom({ name: "Pineapple" }).then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + cy.createRoom({ name: "Orange" }).then(() => cy.contains(".mx_RoomSublist", "Orange")); + // check the rooms are in the right order + cy.get(".mx_RoomTile").should('have.length', 4); // due to the Test Room in beforeEach + checkOrder([ + "Orange", "Pineapple", "Apple", "Test Room", + ]); + + cy.contains(".mx_RoomSublist", "Rooms").find(".mx_RoomSublist_menuButton").click({ force: true }); + cy.contains("A-Z").click(); + cy.get('.mx_StyledRadioButton_checked').should("contain.text", "A-Z"); + checkOrder([ + "Apple", "Orange", "Pineapple", "Test Room", + ]); + }); + + it("should move rooms around as new events arrive", () => { + // create rooms and check room names are correct + cy.createRoom({ name: "Apple" }).as("roomA").then(() => cy.contains(".mx_RoomSublist", "Apple")); + cy.createRoom({ name: "Pineapple" }).as("roomP").then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + cy.createRoom({ name: "Orange" }).as("roomO").then(() => cy.contains(".mx_RoomSublist", "Orange")); + + // Select the Test Room + cy.contains(".mx_RoomTile", "Test Room").click(); + + checkOrder([ + "Orange", "Pineapple", "Apple", "Test Room", + ]); + bumpRoom("@roomA"); + checkOrder([ + "Apple", "Orange", "Pineapple", "Test Room", + ]); + bumpRoom("@roomO"); + checkOrder([ + "Orange", "Apple", "Pineapple", "Test Room", + ]); + bumpRoom("@roomO"); + checkOrder([ + "Orange", "Apple", "Pineapple", "Test Room", + ]); + bumpRoom("@roomP"); + checkOrder([ + "Pineapple", "Orange", "Apple", "Test Room", + ]); + }); + + it("should not move the selected room: it should be sticky", () => { + // create rooms and check room names are correct + cy.createRoom({ name: "Apple" }).as("roomA").then(() => cy.contains(".mx_RoomSublist", "Apple")); + cy.createRoom({ name: "Pineapple" }).as("roomP").then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + cy.createRoom({ name: "Orange" }).as("roomO").then(() => cy.contains(".mx_RoomSublist", "Orange")); + + // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should + // turn into Apple, Pineapple, Orange - the index position of Pineapple never changes even though the list should technically + // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. + + // Select the Pineapple room + cy.contains(".mx_RoomTile", "Pineapple").click(); + checkOrder([ + "Orange", "Pineapple", "Apple", "Test Room", + ]); + + // Move Apple + bumpRoom("@roomA"); + checkOrder([ + "Apple", "Pineapple", "Orange", "Test Room", + ]); + + // Select the Test Room + cy.contains(".mx_RoomTile", "Test Room").click(); + + // the rooms reshuffle to match reality + checkOrder([ + "Apple", "Orange", "Pineapple", "Test Room", + ]); + }); + + it("should show the right unread notifications", () => { + createAndJoinBob(); + + // send a message in the test room: unread notif count shoould increment + cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { + return bob.sendTextMessage(roomId, "Hello World"); + }); + + // check that there is an unread notification (grey) as 1 + cy.contains(".mx_RoomTile", "Test Room").contains(".mx_NotificationBadge_count", "1"); + cy.get(".mx_NotificationBadge").should("not.have.class", "mx_NotificationBadge_highlighted"); + + // send an @mention: highlight count (red) should be 2. + cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { + return bob.sendTextMessage(roomId, "Hello Sloth"); + }); + cy.contains(".mx_RoomTile", "Test Room").contains(".mx_NotificationBadge_count", "2"); + cy.get(".mx_NotificationBadge").should("have.class", "mx_NotificationBadge_highlighted"); + + // click on the room, the notif counts should disappear + cy.contains(".mx_RoomTile", "Test Room").click(); + cy.contains(".mx_RoomTile", "Test Room").should("not.have.class", "mx_NotificationBadge_count"); + }); + + it("should not show unread indicators", () => { // TODO: for now. Later we should. + createAndJoinBob(); + + // disable notifs in this room (TODO: CS API call?) + cy.contains(".mx_RoomTile", "Test Room").find(".mx_RoomTile_notificationsButton").click({ force: true }); + cy.contains("None").click(); + + // create a new room so we know when the message has been received as it'll re-shuffle the room list + cy.createRoom({ + name: "Dummy", + }); + checkOrder([ + "Dummy", "Test Room", + ]); + + cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { + return bob.sendTextMessage(roomId, "Do you read me?"); + }); + // wait for this message to arrive, tell by the room list resorting + checkOrder([ + "Test Room", "Dummy", + ]); + + cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.exist"); + }); + + it("should update user settings promptly", () => { + cy.get(".mx_UserMenu_userAvatar").click(); + cy.contains("All settings").click(); + cy.contains("Preferences").click(); + cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format").should("exist").find( + ".mx_ToggleSwitch_on").should("not.exist"); + cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format").should("exist").find( + ".mx_ToggleSwitch_ball").click(); + cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format", { timeout: 2000 }).should("exist").find( + ".mx_ToggleSwitch_on", { timeout: 2000 }, + ).should("exist"); + }); + + it("should show and be able to accept/reject/rescind invites", () => { + createAndJoinBob(); + + let clientUserId; + cy.getClient().then((cli) => { + clientUserId = cli.getUserId(); + }); + + // invite Sloth into 3 rooms: + // - roomJoin: will join this room + // - roomReject: will reject the invite + // - roomRescind: will make Bob rescind the invite + let roomJoin; let roomReject; let roomRescind; let bobClient; + cy.get("@bob").then((bob) => { + bobClient = bob; + return Promise.all([ + bob.createRoom({ name: "Join" }), + bob.createRoom({ name: "Reject" }), + bob.createRoom({ name: "Rescind" }), + ]); + }).then(([join, reject, rescind]) => { + roomJoin = join.room_id; + roomReject = reject.room_id; + roomRescind = rescind.room_id; + return Promise.all([ + bobClient.invite(roomJoin, clientUserId), + bobClient.invite(roomReject, clientUserId), + bobClient.invite(roomRescind, clientUserId), + ]); + }); + + // wait for them all to be on the UI + cy.get(".mx_RoomTile").should('have.length', 4); // due to the Test Room in beforeEach + + cy.contains(".mx_RoomTile", "Join").click(); + cy.contains(".mx_AccessibleButton", "Accept").click(); + + checkOrder([ + "Join", "Test Room", + ]); + + cy.contains(".mx_RoomTile", "Reject").click(); + cy.get(".mx_RoomView").contains(".mx_AccessibleButton", "Reject").click(); + + // wait for the rejected room to disappear + cy.get(".mx_RoomTile").should('have.length', 3); + + // check the lists are correct + checkOrder([ + "Join", "Test Room", + ]); + cy.contains(".mx_RoomSublist", "Invites").find(".mx_RoomTile_title").should((elements) => { + expect(_.map(elements, (e) => { + return e.textContent; + }), "rooms are sorted").to.deep.equal(["Rescind"]); + }); + + // now rescind the invite + cy.get("@bob").then((bob) => { + return bob.kick(roomRescind, clientUserId); + }); + + // wait for the rescind to take effect and check the joined list once more + cy.get(".mx_RoomTile").should('have.length', 2); + checkOrder([ + "Join", "Test Room", + ]); + }); +}); diff --git a/cypress/global.d.ts b/cypress/global.d.ts index 18f4314d1c1..da5b3b8cd71 100644 --- a/cypress/global.d.ts +++ b/cypress/global.d.ts @@ -28,6 +28,7 @@ import type { RoomStateEvent, Visibility, RoomMemberEvent, + ICreateClientOpts, } from "matrix-js-sdk/src/matrix"; import type { MatrixDispatcher } from "../src/dispatcher/dispatcher"; import type PerformanceMonitor from "../src/performance"; @@ -55,6 +56,7 @@ declare global { MemoryCryptoStore: typeof MemoryCryptoStore; Visibility: typeof Visibility; Preset: typeof Preset; + createClient(opts: ICreateClientOpts | string); }; } } diff --git a/cypress/plugins/docker/index.ts b/cypress/plugins/docker/index.ts index 98f8c2584db..cf1290e0e87 100644 --- a/cypress/plugins/docker/index.ts +++ b/cypress/plugins/docker/index.ts @@ -17,6 +17,7 @@ limitations under the License. /// import * as os from "os"; +import * as crypto from "crypto"; import * as childProcess from "child_process"; import * as fse from "fs-extra"; @@ -25,28 +26,32 @@ import PluginConfigOptions = Cypress.PluginConfigOptions; // A cypress plugin to run docker commands -export function dockerRun(args: { +export function dockerRun(opts: { image: string; containerName: string; params?: string[]; + cmd?: string; }): Promise { const userInfo = os.userInfo(); - const params = args.params ?? []; + const params = opts.params ?? []; - if (userInfo.uid >= 0) { + if (params?.includes("-v") && userInfo.uid >= 0) { // On *nix we run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult params.push("-u", `${userInfo.uid}:${userInfo.gid}`); } + const args = [ + "run", + "--name", `${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`, + "-d", + ...params, + opts.image, + ]; + + if (opts.cmd) args.push(opts.cmd); + return new Promise((resolve, reject) => { - childProcess.execFile('docker', [ - "run", - "--name", args.containerName, - "-d", - ...params, - args.image, - "run", - ], (err, stdout) => { + childProcess.execFile("docker", args, (err, stdout) => { if (err) reject(err); resolve(stdout.trim()); }); @@ -122,6 +127,21 @@ export function dockerRm(args: { }); } +export function dockerIp(args: { + containerId: string; +}): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile('docker', [ + "inspect", + "-f", "{{ .NetworkSettings.IPAddress }}", + args.containerId, + ], (err, stdout) => { + if (err) reject(err); + else resolve(stdout.trim()); + }); + }); +} + /** * @type {Cypress.PluginConfig} */ @@ -132,5 +152,6 @@ export function docker(on: PluginEvents, config: PluginConfigOptions) { dockerLogs, dockerStop, dockerRm, + dockerIp, }); } diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index 44dd93b829b..09b2bdb53b5 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -20,6 +20,7 @@ import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; import { performance } from "./performance"; import { synapseDocker } from "./synapsedocker"; +import { slidingSyncProxyDocker } from "./sliding-sync"; import { webserver } from "./webserver"; import { docker } from "./docker"; import { log } from "./log"; @@ -31,6 +32,7 @@ export default function(on: PluginEvents, config: PluginConfigOptions) { docker(on, config); performance(on, config); synapseDocker(on, config); + slidingSyncProxyDocker(on, config); webserver(on, config); log(on, config); } diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts new file mode 100644 index 00000000000..61a62aad13c --- /dev/null +++ b/cypress/plugins/sliding-sync/index.ts @@ -0,0 +1,128 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; +import { dockerExec, dockerIp, dockerRun, dockerStop } from "../docker"; +import { getFreePort } from "../utils/port"; +import { SynapseInstance } from "../synapsedocker"; + +// A cypress plugins to add command to start & stop https://github.com/matrix-org/sliding-sync + +export interface ProxyInstance { + containerId: string; + postgresId: string; + port: number; +} + +const instances = new Map(); + +const PG_PASSWORD = "p4S5w0rD"; + +async function proxyStart(synapse: SynapseInstance): Promise { + console.log(new Date(), "Starting sliding sync proxy..."); + + const postgresId = await dockerRun({ + image: "postgres", + containerName: "react-sdk-cypress-sliding-sync-postgres", + params: [ + "--rm", + "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`, + ], + }); + + const postgresIp = await dockerIp({ containerId: postgresId }); + const synapseIp = await dockerIp({ containerId: synapse.synapseId }); + console.log(new Date(), "postgres container up"); + + const waitTimeMillis = 30000; + const startTime = new Date().getTime(); + let lastErr: Error; + while ((new Date().getTime() - startTime) < waitTimeMillis) { + try { + await dockerExec({ + containerId: postgresId, + params: [ + "pg_isready", + "-U", "postgres", + ], + }); + lastErr = null; + break; + } catch (err) { + console.log("pg_isready: failed"); + lastErr = err; + } + } + if (lastErr) { + console.log("rethrowing"); + throw lastErr; + } + + const port = await getFreePort(); + console.log(new Date(), "starting proxy container..."); + const containerId = await dockerRun({ + image: "ghcr.io/matrix-org/sliding-sync-proxy:v0.4.0", + containerName: "react-sdk-cypress-sliding-sync-proxy", + params: [ + "--rm", + "-p", `${port}:8008/tcp`, + "-e", "SYNCV3_SECRET=bwahahaha", + "-e", `SYNCV3_SERVER=http://${synapseIp}:8008`, + "-e", `SYNCV3_DB=user=postgres dbname=postgres password=${PG_PASSWORD} host=${postgresIp} sslmode=disable`, + ], + }); + console.log(new Date(), "started!"); + + const instance: ProxyInstance = { containerId, postgresId, port }; + instances.set(containerId, instance); + return instance; +} + +async function proxyStop(instance: ProxyInstance): Promise { + await dockerStop({ + containerId: instance.containerId, + }); + await dockerStop({ + containerId: instance.postgresId, + }); + + instances.delete(instance.containerId); + + console.log(new Date(), "Stopped sliding sync proxy."); + // cypress deliberately fails if you return 'undefined', so + // return null to signal all is well, and we've handled the task. + return null; +} + +/** + * @type {Cypress.PluginConfig} + */ +export function slidingSyncProxyDocker(on: PluginEvents, config: PluginConfigOptions) { + on("task", { + proxyStart, + proxyStop, + }); + + on("after:spec", async (spec) => { + for (const instance of instances.values()) { + console.warn(`Cleaning up proxy on port ${instance.port} after ${spec.name}`); + await proxyStop(instance); + } + }); +} diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 5227b5e4ac8..bfbda1e3607 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -101,12 +101,13 @@ async function synapseStart(template: string): Promise { const synapseId = await dockerRun({ image: "matrixdotorg/synapse:develop", - containerName: `react-sdk-cypress-synapse-${crypto.randomBytes(4).toString("hex")}`, + containerName: `react-sdk-cypress-synapse`, params: [ "--rm", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`, ], + cmd: "run", }); console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 8dbcc97753a..899d41c5b8d 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -36,4 +36,5 @@ import "./iframes"; import "./timeline"; import "./network"; import "./composer"; +import "./proxy"; import "./axe"; diff --git a/cypress/support/proxy.ts b/cypress/support/proxy.ts new file mode 100644 index 00000000000..e6b1ae96f1d --- /dev/null +++ b/cypress/support/proxy.ts @@ -0,0 +1,58 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import Chainable = Cypress.Chainable; +import AUTWindow = Cypress.AUTWindow; +import { ProxyInstance } from '../plugins/sliding-sync'; +import { SynapseInstance } from "../plugins/synapsedocker"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Start a sliding sync proxy instance. + * @param synapse the synapse instance returned by startSynapse + */ + startProxy(synapse: SynapseInstance): Chainable; + + /** + * Custom command wrapping task:proxyStop whilst preventing uncaught exceptions + * for if Docker stopping races with the app's background sync loop. + * @param proxy the proxy instance returned by startProxy + */ + stopProxy(proxy: ProxyInstance): Chainable; + } + } +} + +function startProxy(synapse: SynapseInstance): Chainable { + return cy.task("proxyStart", synapse); +} + +function stopProxy(proxy?: ProxyInstance): Chainable { + if (!proxy) return; + // Navigate away from app to stop the background network requests which will race with Synapse shutting down + return cy.window({ log: false }).then((win) => { + win.location.href = 'about:blank'; + cy.task("proxyStop", proxy); + }); +} + +Cypress.Commands.add("startProxy", startProxy); +Cypress.Commands.add("stopProxy", stopProxy); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 1fba136942c..358a4f3decf 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -36,6 +36,7 @@ import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; import SecurityCustomisations from "./customisations/Security"; +import { SlidingSyncManager } from './SlidingSyncManager'; import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog"; import { _t } from "./languageHandler"; @@ -237,6 +238,19 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread"); + if (SettingsStore.getValue("feature_sliding_sync")) { + const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url"); + if (proxyUrl) { + logger.log("Activating sliding sync using proxy at ", proxyUrl); + } else { + logger.log("Activating sliding sync"); + } + opts.slidingSync = SlidingSyncManager.instance.configure( + this.matrixClient, + proxyUrl || this.matrixClient.baseUrl, + ); + } + // Connect the matrix client to the dispatcher and setting handlers MatrixActionCreators.start(this.matrixClient); MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient; diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts new file mode 100644 index 00000000000..0e5736465ea --- /dev/null +++ b/src/SlidingSyncManager.ts @@ -0,0 +1,236 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* + * Sliding Sync Architecture - MSC https://github.com/matrix-org/matrix-spec-proposals/pull/3575 + * + * This is a holistic summary of the changes made to Element-Web / React SDK / JS SDK to enable sliding sync. + * This summary will hopefully signpost where developers need to look if they want to make changes to this code. + * + * At the lowest level, the JS SDK contains an HTTP API wrapper function in client.ts. This is used by + * a SlidingSync class in JS SDK, which contains code to handle list operations (INSERT/DELETE/SYNC/etc) + * and contains the main request API bodies, but has no code to control updating JS SDK structures: it just + * exposes an EventEmitter to listen for updates. When MatrixClient.startClient is called, callers need to + * provide a SlidingSync instance as this contains the main request API params (timeline limit, required state, + * how many lists, etc). + * + * The SlidingSyncSdk INTERNAL class in JS SDK attaches listeners to SlidingSync to update JS SDK Room objects, + * and it conveniently exposes an identical public API to SyncApi (to allow it to be a drop-in replacement). + * + * At the highest level, SlidingSyncManager contains mechanisms to tell UI lists which rooms to show, + * and contains the core request API params used in Element-Web. It does this by listening for events + * emitted by the SlidingSync class and by modifying the request API params on the SlidingSync class. + * + * (entry point) (updates JS SDK) + * SlidingSyncManager SlidingSyncSdk + * | | + * +------------------.------------------+ + * listens | listens + * SlidingSync + * (sync loop, + * list ops) + */ + +import { MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; +import { + MSC3575Filter, + MSC3575List, + SlidingSync, +} from 'matrix-js-sdk/src/sliding-sync'; +import { logger } from "matrix-js-sdk/src/logger"; +import { IDeferred, defer } from 'matrix-js-sdk/src/utils'; + +// how long to long poll for +const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000; + +// the things to fetch when a user clicks on a room +const DEFAULT_ROOM_SUBSCRIPTION_INFO = { + timeline_limit: 50, + required_state: [ + ["*", "*"], // all events + ], +}; + +export type PartialSlidingSyncRequest = { + filters?: MSC3575Filter; + sort?: string[]; + ranges?: [startIndex: number, endIndex: number][]; +}; + +/** + * This class manages the entirety of sliding sync at a high UI/UX level. It controls the placement + * of placeholders in lists, controls updating sliding window ranges, and controls which events + * are pulled down when. The intention behind this manager is be the single place to look for sliding + * sync options and code. + */ +export class SlidingSyncManager { + public static readonly ListSpaces = "space_list"; + public static readonly ListSearch = "search_list"; + private static readonly internalInstance = new SlidingSyncManager(); + + public slidingSync: SlidingSync; + private client: MatrixClient; + private listIdToIndex: Record; + + private configureDefer: IDeferred; + + public constructor() { + this.listIdToIndex = {}; + this.configureDefer = defer(); + } + + public static get instance(): SlidingSyncManager { + return SlidingSyncManager.internalInstance; + } + + public configure(client: MatrixClient, proxyUrl: string): SlidingSync { + this.client = client; + this.listIdToIndex = {}; + this.slidingSync = new SlidingSync( + proxyUrl, [], DEFAULT_ROOM_SUBSCRIPTION_INFO, client, SLIDING_SYNC_TIMEOUT_MS, + ); + // set the space list + this.slidingSync.setList(this.getOrAllocateListIndex(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, "*"], // all space children + [EventType.SpaceParent, "*"], // all space parents + [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room + ], + filters: { + room_types: ["m.space"], + }, + }); + this.configureDefer.resolve(); + return this.slidingSync; + } + + public listIdForIndex(index: number): string | null { + for (const listId in this.listIdToIndex) { + if (this.listIdToIndex[listId] === index) { + return listId; + } + } + return null; + } + + /** + * Allocate or retrieve the list index for an arbitrary list ID. For example SlidingSyncManager.ListSpaces + * @param listId A string which represents the list. + * @returns The index to use when registering lists or listening for callbacks. + */ + public getOrAllocateListIndex(listId: string): number { + let index = this.listIdToIndex[listId]; + if (index === undefined) { + // assign next highest index + index = -1; + for (const id in this.listIdToIndex) { + const listIndex = this.listIdToIndex[id]; + if (listIndex > index) { + index = listIndex; + } + } + index++; + this.listIdToIndex[listId] = index; + } + return index; + } + + /** + * Ensure that this list is registered. + * @param listIndex The list index to register + * @param updateArgs The fields to update on the list. + * @returns The complete list request params + */ + public async ensureListRegistered( + listIndex: number, updateArgs: PartialSlidingSyncRequest, + ): Promise { + logger.debug("ensureListRegistered:::", listIndex, updateArgs); + await this.configureDefer.promise; + let list = this.slidingSync.getList(listIndex); + if (!list) { + list = { + ranges: [[0, 20]], + sort: [ + "by_highlight_count", "by_notification_count", "by_recency", + ], + timeline_limit: 1, // most recent message display: though this seems to only be needed for favourites? + 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, this.client.getUserId()], // lets the client calculate that we are in fact in the room + ], + }; + list = Object.assign(list, updateArgs); + } else { + const updatedList = Object.assign({}, list, updateArgs); + // cannot use objectHasDiff as we need to do deep diff checking + if (JSON.stringify(list) === JSON.stringify(updatedList)) { + logger.debug("list matches, not sending, update => ", updateArgs); + return list; + } + list = updatedList; + } + + try { + // if we only have range changes then call a different function so we don't nuke the list from before + if (updateArgs.ranges && Object.keys(updateArgs).length === 1) { + await this.slidingSync.setListRanges(listIndex, updateArgs.ranges); + } else { + await this.slidingSync.setList(listIndex, list); + } + } catch (err) { + logger.debug("ensureListRegistered: update failed txn_id=", err); + } + return this.slidingSync.getList(listIndex); + } + + public async setRoomVisible(roomId: string, visible: boolean): Promise { + await this.configureDefer.promise; + const subscriptions = this.slidingSync.getRoomSubscriptions(); + if (visible) { + subscriptions.add(roomId); + } else { + subscriptions.delete(roomId); + } + logger.log("SlidingSync setRoomVisible:", roomId, visible); + const p = this.slidingSync.modifyRoomSubscriptions(subscriptions); + if (this.client.getRoom(roomId)) { + return roomId; // 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 (err) { + logger.warn("SlidingSync setRoomVisible:", roomId, visible, "failed to confirm transaction"); + } + return roomId; + } +} diff --git a/src/Unread.ts b/src/Unread.ts index 19dc07f4141..b9b3409c66c 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -55,6 +55,12 @@ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { } export function doesRoomHaveUnreadMessages(room: Room): 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 myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. diff --git a/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx new file mode 100644 index 00000000000..ed793231b21 --- /dev/null +++ b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx @@ -0,0 +1,130 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { logger } from 'matrix-js-sdk/src/logger'; + +import { _t } from '../../../languageHandler'; +import { IDialogProps } from "./IDialogProps"; +import SettingsStore from "../../../settings/SettingsStore"; +import TextInputDialog from "./TextInputDialog"; +import withValidation from "../elements/Validation"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; +import { SettingLevel } from "../../../settings/SettingLevel"; + +/** + * Check that the server natively supports sliding sync. + * @param cli The MatrixClient of the logged in user. + * @throws if the proxy server is unreachable or not configured to the given homeserver + */ +async function syncHealthCheck(cli: MatrixClient): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), 10 * 1000); // 10s + const url = cli.http.getUrl("/sync", {}, "/_matrix/client/unstable/org.matrix.msc3575"); + const res = await fetch(url, { + signal: controller.signal, + method: "POST", + }); + clearTimeout(id); + if (res.status != 200) { + throw new Error(`syncHealthCheck: server returned HTTP ${res.status}`); + } + logger.info("server natively support sliding sync OK"); +} + +/** + * Check that the proxy url is in fact a sliding sync proxy endpoint and it is up. + * @param endpoint The proxy endpoint url + * @param hsUrl The homeserver url of the logged in user. + * @throws if the proxy server is unreachable or not configured to the given homeserver + */ +async function proxyHealthCheck(endpoint: string, hsUrl?: string): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), 10 * 1000); // 10s + const res = await fetch(endpoint + "/client/server.json", { + signal: controller.signal, + }); + clearTimeout(id); + if (res.status != 200) { + throw new Error(`proxyHealthCheck: proxy server returned HTTP ${res.status}`); + } + const body = await res.json(); + if (body.server !== hsUrl) { + throw new Error(`proxyHealthCheck: client using ${hsUrl} but server is as ${body.server}`); + } + logger.info("sliding sync proxy is OK"); +} + +export const SlidingSyncOptionsDialog: React.FC = ({ onFinished }) => { + const cli = MatrixClientPeg.get(); + const currentProxy = SettingsStore.getValue("feature_sliding_sync_proxy_url"); + const hasNativeSupport = useAsyncMemo(() => syncHealthCheck(cli).then(() => true, () => false), [], null); + + let nativeSupport: string; + if (hasNativeSupport === null) { + nativeSupport = _t("Checking..."); + } else { + nativeSupport = hasNativeSupport + ? _t("Your server has native support") + : _t("Your server lacks native support"); + } + + const validProxy = withValidation({ + async deriveData({ value }): Promise<{ error?: Error }> { + try { + await proxyHealthCheck(value, MatrixClientPeg.get().baseUrl); + return {}; + } catch (error) { + return { error }; + } + }, + rules: [ + { + key: "required", + test: async ({ value }) => !!value || hasNativeSupport, + invalid: () => _t("Your server lacks native support, you must specify a proxy"), + }, { + key: "working", + final: true, + test: async (_, { error }) => !error, + valid: () => _t("Looks good"), + invalid: ({ error }) => error?.message, + }, + ], + }); + + return +
{ _t("To disable you will need to log out and back in, use with caution!") }
+ { nativeSupport } + } + placeholder={hasNativeSupport ? _t('Proxy URL (optional)') : _t('Proxy URL')} + value={currentProxy} + button={_t("Enable")} + validator={validProxy} + onFinished={(enable: boolean, proxyUrl: string) => { + if (enable) { + SettingsStore.setValue("feature_sliding_sync_proxy_url", null, SettingLevel.DEVICE, proxyUrl); + onFinished(true); + } else { + onFinished(false); + } + }} + />; +}; diff --git a/src/components/views/dialogs/TextInputDialog.tsx b/src/components/views/dialogs/TextInputDialog.tsx index 7a13fd3d453..6de0c74ba5c 100644 --- a/src/components/views/dialogs/TextInputDialog.tsx +++ b/src/components/views/dialogs/TextInputDialog.tsx @@ -32,7 +32,7 @@ interface IProps extends IDialogProps { busyMessage?: string; // pass _td string focus?: boolean; hasCancel?: boolean; - validator?: (fieldState: IFieldState) => IValidationResult; // result of withValidation + validator?: (fieldState: IFieldState) => Promise; // result of withValidation fixedWidth?: boolean; } diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 3d8b16c1f4f..95d945aa2ef 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -92,6 +92,7 @@ import { RoomResultContextMenus } from "./RoomResultContextMenus"; import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { TooltipOption } from "./TooltipOption"; import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; +import { useSlidingSyncRoomSearch } from "../../../../hooks/useSlidingSyncRoomSearch"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -242,7 +243,7 @@ export const useWebSearchMetrics = (numResults: number, queryLength: number, via }, [numResults, queryLength, viaSpotlight]); }; -const findVisibleRooms = (cli: MatrixClient) => { +const findVisibleRooms = (cli: MatrixClient): Room[] => { return cli.getVisibleRooms().filter(room => { // Do not show local rooms if (isLocalRoom(room)) return false; @@ -340,25 +341,43 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n searchProfileInfo, searchParams, ); + const isSlidingSyncEnabled = SettingsStore.getValue("feature_sliding_sync"); + let { + loading: slidingSyncRoomSearchLoading, + rooms: slidingSyncRooms, + search: searchRoomsServerside, + } = useSlidingSyncRoomSearch(); + useDebouncedCallback(isSlidingSyncEnabled, searchRoomsServerside, searchParams); + if (!isSlidingSyncEnabled) { + slidingSyncRoomSearchLoading = false; + } + const possibleResults = useMemo( () => { - const roomResults = findVisibleRooms(cli).map(toRoomResult); - // If we already have a DM with the user we're looking for, we will - // show that DM instead of the user themselves - const alreadyAddedUserIds = roomResults.reduce((userIds, result) => { - const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId); - if (!userId) return userIds; - if (result.room.getJoinedMemberCount() > 2) return userIds; - userIds.add(userId); - return userIds; - }, new Set()); - const userResults = []; - for (const user of [...findVisibleRoomMembers(cli), ...users]) { - // Make sure we don't have any user more than once - if (alreadyAddedUserIds.has(user.userId)) continue; - alreadyAddedUserIds.add(user.userId); - - userResults.push(toMemberResult(user)); + const userResults: IMemberResult[] = []; + let roomResults: IRoomResult[]; + let alreadyAddedUserIds: Set; + if (isSlidingSyncEnabled) { + // use the rooms sliding sync returned as the server has already worked it out for us + roomResults = slidingSyncRooms.map(toRoomResult); + } else { + roomResults = findVisibleRooms(cli).map(toRoomResult); + // If we already have a DM with the user we're looking for, we will + // show that DM instead of the user themselves + alreadyAddedUserIds = roomResults.reduce((userIds, result) => { + const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId); + if (!userId) return userIds; + if (result.room.getJoinedMemberCount() > 2) return userIds; + userIds.add(userId); + return userIds; + }, new Set()); + for (const user of [...findVisibleRoomMembers(cli), ...users]) { + // Make sure we don't have any user more than once + if (alreadyAddedUserIds.has(user.userId)) continue; + alreadyAddedUserIds.add(user.userId); + + userResults.push(toMemberResult(user)); + } } return [ @@ -382,7 +401,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n ...publicRooms.map(toPublicRoomResult), ].filter(result => filter === null || result.filter.includes(filter)); }, - [cli, users, profile, publicRooms, filter], + [cli, users, profile, publicRooms, slidingSyncRooms, isSlidingSyncEnabled, filter], ); const results = useMemo>(() => { @@ -401,10 +420,13 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n possibleResults.forEach(entry => { if (isRoomResult(entry)) { - if (!entry.room.normalizedName?.includes(normalizedQuery) && - !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && - !entry.query?.some(q => q.includes(lcQuery)) - ) return; // bail, does not match query + // sliding sync gives the correct rooms in the list so we don't need to filter + if (!isSlidingSyncEnabled) { + if (!entry.room.normalizedName?.includes(normalizedQuery) && + !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && + !entry.query?.some(q => q.includes(lcQuery)) + ) return; // bail, does not match query + } } else if (isMemberResult(entry)) { if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query } else if (isPublicRoomResult(entry)) { @@ -455,7 +477,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } return results; - }, [trimmedQuery, filter, cli, possibleResults, memberComparator]); + }, [trimmedQuery, filter, cli, possibleResults, isSlidingSyncEnabled, memberComparator]); const numResults = sum(Object.values(results).map(it => it.length)); useWebSearchMetrics(numResults, query.length, true); @@ -1208,7 +1230,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n aria-label={_t("Search")} aria-describedby="mx_SpotlightDialog_keyboardPrompt" /> - { (publicRoomsLoading || peopleLoading || profileLoading) && ( + { (publicRoomsLoading || peopleLoading || profileLoading || slidingSyncRoomSearchLoading) && ( ) } diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index bd09ecb4a01..9e890e9c219 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -16,9 +16,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { Room } from "matrix-js-sdk/src/models/room"; import classNames from 'classnames'; import { Dispatcher } from "flux"; -import { Room } from "matrix-js-sdk/src/models/room"; import { Enable, Resizable } from "re-resizable"; import { Direction } from "re-resizable/lib/resizer"; import * as React from "react"; @@ -39,7 +39,7 @@ import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorith import { ListLayout } from "../../../stores/room-list/ListLayout"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore"; -import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; +import RoomListStore, { LISTS_UPDATE_EVENT, LISTS_LOADING_EVENT } from "../../../stores/room-list/RoomListStore"; import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays"; import { objectExcluding, objectHasDiff } from "../../../utils/objects"; import ResizeNotifier from "../../../utils/ResizeNotifier"; @@ -52,6 +52,8 @@ import ContextMenu, { import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ExtraTile from "./ExtraTile"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SlidingSyncManager } from "../../../SlidingSyncManager"; import NotificationBadge from "./NotificationBadge"; import RoomTile from "./RoomTile"; @@ -98,6 +100,7 @@ interface IState { isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered height: number; rooms: Room[]; + roomsLoading: boolean; } export default class RoomSublist extends React.Component { @@ -109,8 +112,12 @@ export default class RoomSublist extends React.Component { private heightAtStart: number; private notificationState: ListNotificationState; + private slidingSyncMode: boolean; + 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; @@ -121,6 +128,7 @@ export default class RoomSublist extends React.Component { isExpanded: !this.layout.isCollapsed, height: 0, // to be fixed in a moment, we need `rooms` to calculate this. rooms: arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []), + roomsLoading: false, }; // Why Object.assign() and not this.state.height? Because TypeScript says no. this.state = Object.assign(this.state, { height: this.calculateInitialHeight() }); @@ -167,6 +175,9 @@ 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); } @@ -239,6 +250,8 @@ export default class RoomSublist extends React.Component { public componentDidMount() { this.dispatcherRef = defaultDispatcher.register(this.onAction); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated); + RoomListStore.instance.on(LISTS_LOADING_EVENT, this.onListsLoading); + // Using the passive option to not block the main thread // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners this.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true }); @@ -247,9 +260,19 @@ export default class RoomSublist extends React.Component { public componentWillUnmount() { defaultDispatcher.unregister(this.dispatcherRef); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated); + RoomListStore.instance.off(LISTS_LOADING_EVENT, this.onListsLoading); this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent); } + private onListsLoading = (tagId: TagID, isLoading: boolean) => { + if (this.props.tagId !== tagId) { + return; + } + this.setState({ + roomsLoading: isLoading, + }); + }; + private onListsUpdated = () => { const stateUpdates: IState & any = {}; // &any is to avoid a cast on the initializer @@ -315,7 +338,16 @@ export default class RoomSublist extends React.Component { this.setState({ isResizing: false, height: newHeight }); }; - private onShowAllClick = () => { + private onShowAllClick = async () => { + if (this.slidingSyncMode) { + const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(this.props.tagId); + const count = RoomListStore.instance.getCount(this.props.tagId); + await SlidingSyncManager.instance.ensureListRegistered(slidingSyncIndex, { + 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); @@ -531,8 +563,17 @@ export default class RoomSublist extends React.Component { let contextMenu = null; if (this.state.contextMenuPosition) { - const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; - const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; + let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; + let isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; + if (this.slidingSyncMode) { + const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(this.props.tagId); + const slidingList = SlidingSyncManager.instance.slidingSync.getList(slidingSyncIndex); + isAlphabetical = slidingList.sort[0] === "by_name"; + isUnreadFirst = ( + slidingList.sort[0] === "by_highlight_count" || + slidingList.sort[0] === "by_notification_count" + ); + } // Invites don't get some nonsense options, so only add them if we have to. let otherSections = null; @@ -717,7 +758,9 @@ export default class RoomSublist extends React.Component { }); let content = null; - if (visibleTiles.length > 0 && this.props.forceExpanded) { + if (this.state.roomsLoading) { + content =
; + } else if (visibleTiles.length > 0 && this.props.forceExpanded) { content =
{ visibleTiles } @@ -739,12 +782,18 @@ 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 = null; + const hasMoreSlidingSync = ( + this.slidingSyncMode && (RoomListStore.instance.getCount(this.props.tagId) > this.state.rooms.length) + ); - if (maxTilesPx > this.state.height) { + if ((maxTilesPx > this.state.height) || hasMoreSlidingSync) { // 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); - const numMissing = this.numTiles - amountFullyShown; + let numMissing = this.numTiles - amountFullyShown; + if (this.slidingSyncMode) { + numMissing = RoomListStore.instance.getCount(this.props.tagId) - amountFullyShown; + } const label = _t("Show %(count)s more", { count: numMissing }); let showMoreText = ( diff --git a/src/hooks/useSlidingSyncRoomSearch.ts b/src/hooks/useSlidingSyncRoomSearch.ts new file mode 100644 index 00000000000..6ba08dc1a7c --- /dev/null +++ b/src/hooks/useSlidingSyncRoomSearch.ts @@ -0,0 +1,86 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useCallback, useState } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { useLatestResult } from "./useLatestResult"; +import { SlidingSyncManager } from "../SlidingSyncManager"; + +export interface SlidingSyncRoomSearchOpts { + limit: number; + query?: string; +} + +export const useSlidingSyncRoomSearch = () => { + const [rooms, setRooms] = useState([]); + + const [loading, setLoading] = useState(false); + const listIndex = SlidingSyncManager.instance.getOrAllocateListIndex(SlidingSyncManager.ListSearch); + + 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(listIndex, { + ranges: [[0, limit]], + filters: { + room_name_like: term, + is_tombstoned: false, + }, + }); + const rooms = []; + const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(listIndex); + let i = 0; + while (roomIndexToRoomId[i]) { + const roomId = roomIndexToRoomId[i]; + const room = MatrixClientPeg.get().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, listIndex]); + + return { + loading, + rooms, + search, + } as const; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6afb549b160..c2ee03575de 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -907,6 +907,7 @@ "Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)", "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", "Send read receipts": "Send read receipts", + "Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Use new session manager (under active development)": "Use new session manager (under active development)", @@ -2856,6 +2857,14 @@ "Link to selected message": "Link to selected message", "Link to room": "Link to room", "Command Help": "Command Help", + "Checking...": "Checking...", + "Your server has native support": "Your server has native support", + "Your server lacks native support": "Your server lacks native support", + "Your server lacks native support, you must specify a proxy": "Your server lacks native support, you must specify a proxy", + "Sliding Sync configuration": "Sliding Sync configuration", + "To disable you will need to log out and back in, use with caution!": "To disable you will need to log out and back in, use with caution!", + "Proxy URL (optional)": "Proxy URL (optional)", + "Proxy URL": "Proxy URL", "Sections to show": "Sections to show", "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.", "Space settings": "Space settings", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index e374de12d1d..16759682579 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -41,6 +41,7 @@ import IncompatibleController from "./controllers/IncompatibleController"; import { ImageSize } from "./enums/ImageSize"; import { MetaSpace } from "../stores/spaces"; import SdkConfig from "../SdkConfig"; +import SlidingSyncController from './controllers/SlidingSyncController'; import ThreadBetaController from './controllers/ThreadBetaController'; import { FontWatcher } from "./watchers/FontWatcher"; @@ -406,6 +407,18 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Send read receipts"), default: true, }, + "feature_sliding_sync": { + isFeature: true, + labsGroup: LabGroup.Developer, + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + displayName: _td('Sliding Sync mode (under active development, cannot be disabled)'), + default: false, + controller: new SlidingSyncController(), + }, + "feature_sliding_sync_proxy_url": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + default: "", + }, "feature_location_share_live": { isFeature: true, labsGroup: LabGroup.Messaging, diff --git a/src/settings/controllers/SlidingSyncController.ts b/src/settings/controllers/SlidingSyncController.ts new file mode 100644 index 00000000000..fdbea7bda0a --- /dev/null +++ b/src/settings/controllers/SlidingSyncController.ts @@ -0,0 +1,39 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SettingController from "./SettingController"; +import PlatformPeg from "../../PlatformPeg"; +import { SettingLevel } from "../SettingLevel"; +import { SlidingSyncOptionsDialog } from "../../components/views/dialogs/SlidingSyncOptionsDialog"; +import Modal from "../../Modal"; +import SettingsStore from "../SettingsStore"; + +export default class SlidingSyncController extends SettingController { + public async beforeChange(level: SettingLevel, roomId: string, newValue: any): Promise { + const { finished } = Modal.createDialog(SlidingSyncOptionsDialog); + const [value] = await finished; + return newValue === value; // abort the operation if we're already in the state the user chose via modal + } + + public async onChange(): Promise { + PlatformPeg.get().reload(); + } + + public get settingDisabled(): boolean { + // Cannot be disabled once enabled, user has been warned and must log out and back in. + return SettingsStore.getValue("feature_sliding_sync"); + } +} diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 4f0e7d5b13b..c9324edc5d3 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -47,6 +47,8 @@ import { JoinRoomErrorPayload } from "../dispatcher/payloads/JoinRoomErrorPayloa import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayload"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; +import SettingsStore from "../settings/SettingsStore"; +import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; const NUM_JOIN_RETRY = 5; @@ -278,6 +280,32 @@ export class RoomViewStore extends Store { activeSpace, }); } + if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { + if (this.state.roomId) { + // unsubscribe from this room, but don't await it as we don't care when this gets done. + SlidingSyncManager.instance.setRoomVisible(this.state.roomId, false); + } + this.setState({ + roomId: payload.room_id, + initialEventId: null, + initialEventPixelOffset: null, + isInitialEventHighlighted: null, + initialEventScrollIntoView: true, + roomAlias: null, + roomLoading: true, + roomLoadError: null, + viaServers: payload.via_servers, + wasContextSwitch: payload.context_switch, + }); + // 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 SlidingSyncManager.instance.setRoomVisible(payload.room_id, true); + // Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now + dis.dispatch({ + ...payload, + }); + return; + } const newState = { roomId: payload.room_id, diff --git a/src/stores/room-list/Interface.ts b/src/stores/room-list/Interface.ts index ab538709896..55de9dd3ad2 100644 --- a/src/stores/room-list/Interface.ts +++ b/src/stores/room-list/Interface.ts @@ -23,6 +23,9 @@ import { IFilterCondition } from "./filters/IFilterCondition"; export enum RoomListStoreEvent { // The event/channel which is called when the room lists have been changed. ListsUpdate = "lists_update", + // The event which is called when the room list is loading. + // Called with the (tagId, bool) which is true when the list is loading, else false. + ListsLoading = "lists_loading", } export interface RoomListStore extends EventEmitter { @@ -33,6 +36,15 @@ export interface RoomListStore extends EventEmitter { */ get orderedLists(): ITagMap; + /** + * Return the total number of rooms in this list. Prefer this method to + * RoomListStore.orderedLists[tagId].length because the client may not + * be aware of all the rooms in this list (e.g in Sliding Sync). + * @param tagId the tag to get the room count for. + * @returns the number of rooms in this list, or 0 if the list is unknown. + */ + getCount(tagId: TagID): number; + /** * Set the sort algorithm for the specified tag. * @param tagId the tag to set the algorithm for diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index f80839f66f8..c74a58494ae 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -38,12 +38,14 @@ import { VisibilityProvider } from "./filters/VisibilityProvider"; import { SpaceWatcher } from "./SpaceWatcher"; import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; +import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; interface IState { // state is tracked in underlying classes } export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; +export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore export class RoomListStoreClass extends AsyncStoreWithClient implements Interface { /** @@ -585,6 +587,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements return algorithmTags; } + public getCount(tagId: TagID): number { + // The room list store knows about all the rooms, so just return the length. + return this.orderedLists[tagId].length || 0; + } + /** * 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 @@ -602,10 +609,17 @@ export default class RoomListStore { private static internalInstance: Interface; public static get instance(): Interface { - if (!this.internalInstance) { - const instance = new RoomListStoreClass(); - instance.start(); - this.internalInstance = instance; + if (!RoomListStore.internalInstance) { + if (SettingsStore.getValue("feature_sliding_sync")) { + logger.info("using SlidingRoomListStoreClass"); + const instance = new SlidingRoomListStoreClass(); + instance.start(); + RoomListStore.internalInstance = instance; + } else { + const instance = new RoomListStoreClass(); + instance.start(); + RoomListStore.internalInstance = instance; + } } return this.internalInstance; diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts new file mode 100644 index 00000000000..ccf4946886d --- /dev/null +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -0,0 +1,386 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/models/room"; +import { logger } from "matrix-js-sdk/src/logger"; +import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync"; + +import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models"; +import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; +import { ActionPayload } from "../../dispatcher/payloads"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { IFilterCondition } from "./filters/IFilterCondition"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; +import { SlidingSyncManager } from "../../SlidingSyncManager"; +import SpaceStore from "../spaces/SpaceStore"; +import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; +import { LISTS_LOADING_EVENT } from "./RoomListStore"; +import { RoomViewStore } from "../RoomViewStore"; + +interface IState { + // state is tracked in underlying classes +} + +export const SlidingSyncSortToFilter: Record = { + [SortAlgorithm.Alphabetic]: ["by_name", "by_recency"], + [SortAlgorithm.Recent]: ["by_highlight_count", "by_notification_count", "by_recency"], + [SortAlgorithm.Manual]: ["by_recency"], +}; + +const filterConditions: Record = { + [DefaultTagID.Invite]: { + is_invite: true, + }, + [DefaultTagID.Favourite]: { + tags: ["m.favourite"], + is_tombstoned: false, + }, + // TODO https://github.com/vector-im/element-web/issues/23207 + // DefaultTagID.SavedItems, + [DefaultTagID.DM]: { + is_dm: true, + is_invite: false, + is_tombstoned: false, + }, + [DefaultTagID.Untagged]: { + is_dm: false, + is_invite: false, + is_tombstoned: false, + not_room_types: ["m.space"], + not_tags: ["m.favourite", "m.lowpriority"], + // spaces filter added dynamically + }, + [DefaultTagID.LowPriority]: { + tags: ["m.lowpriority"], + is_tombstoned: false, + }, + // 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: string | null; + + public constructor() { + super(defaultDispatcher); + this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares + } + + public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { + logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort); + this.tagIdToSortAlgo[tagId] = sort; + const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); + switch (sort) { + case SortAlgorithm.Alphabetic: + await SlidingSyncManager.instance.ensureListRegistered( + slidingSyncIndex, { + sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], + }, + ); + break; + case SortAlgorithm.Recent: + await SlidingSyncManager.instance.ensureListRegistered( + slidingSyncIndex, { + 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) { + // 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 index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); + const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); + for (const roomIndex in roomIndexToRoomId) { + const roomId = 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) { + // 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 = RoomViewStore.instance.getRoomId(); + let stickyRoomNewIndex = -1; + const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => { + 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 = orderedRoomIds.map((roomId) => { + return this.matrixClient.getRoom(roomId); + }); + tagMap[tagId] = rooms; + this.tagMap = tagMap; + } + + private onSlidingSyncListUpdate(listIndex: number, joinCount: number, roomIndexToRoomId: Record) { + const tagId = SlidingSyncManager.instance.listIdForIndex(listIndex); + this.counts[tagId]= joinCount; + this.refreshOrderedLists(tagId, roomIndexToRoomId); + // let the UI update + this.emit(LISTS_UPDATE_EVENT); + } + + private onRoomViewStoreUpdated() { + // we only care about this to know when the user has clicked on a room to set the stickiness value + if (RoomViewStore.instance.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 index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); + const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); + this.refreshOrderedLists(tagId, roomIndexToRoomId); + hasUpdatedAnyList = true; + } + } + // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. + this.stickyRoomId = RoomViewStore.instance.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. + SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); + RoomViewStore.instance.addListener(this.onRoomViewStoreUpdated.bind(this)); + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); + if (SpaceStore.instance.activeSpace) { + this.onSelectedSpaceUpdated(SpaceStore.instance.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); + const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); + SlidingSyncManager.instance.ensureListRegistered(index, { + filters: filter, + sort: SlidingSyncSortToFilter[sort], + }).then(() => { + this.emit(LISTS_LOADING_EVENT, tagId, false); + }); + }); + } + + private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome: boolean) => { + 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) { + this.emit(LISTS_LOADING_EVENT, tagId, true); + SlidingSyncManager.instance.ensureListRegistered( + SlidingSyncManager.instance.getOrAllocateListIndex(tagId), + { + filters: filters, + }, + ).then(() => { + this.emit(LISTS_LOADING_EVENT, tagId, false); + }); + } + }; + + // Intended for test usage + public async resetStore() { + // 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 }) { + // Test function + } + + protected async onNotReady(): Promise { + await this.resetStore(); + } + + protected async onAction(payload: ActionPayload) { + } + + protected async onDispatchAsync(payload: ActionPayload) { + } +} diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index af46a921cb8..31513b3de2b 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RoomListStoreClass } from "./RoomListStore"; +import { RoomListStore as Interface } from "./Interface"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import SpaceStore from "../spaces/SpaceStore"; import { MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; @@ -28,7 +28,7 @@ export class SpaceWatcher { private activeSpace: SpaceKey = SpaceStore.instance.activeSpace; private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome; - constructor(private store: RoomListStoreClass) { + constructor(private store: Interface) { if (SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome)) { this.updateFilter(); store.addFilter(this.filter); diff --git a/test/stores/RoomViewStore-test.tsx b/test/stores/RoomViewStore-test.tsx index 15137835597..b505fc6e6db 100644 --- a/test/stores/RoomViewStore-test.tsx +++ b/test/stores/RoomViewStore-test.tsx @@ -41,6 +41,7 @@ describe('RoomViewStore', function() { joinRoom: jest.fn(), getRoom: jest.fn(), getRoomIdForAlias: jest.fn(), + isGuest: jest.fn(), }); const room = new Room('!room:server', mockClient, userId); @@ -49,6 +50,7 @@ describe('RoomViewStore', function() { mockClient.credentials = { userId: "@test:example.com" }; mockClient.joinRoom.mockResolvedValue(room); mockClient.getRoom.mockReturnValue(room); + mockClient.isGuest.mockReturnValue(false); // Reset the state of the store RoomViewStore.instance.reset();