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 <travisr@matrix.org>

* Update src/components/views/rooms/RoomSublist.tsx

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/settings/controllers/SlidingSyncController.ts

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/components/views/rooms/RoomSublist.tsx

Co-authored-by: Travis Ralston <travisr@matrix.org>

* 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 <travisr@matrix.org>

* Update src/stores/room-list/SlidingRoomListStore.ts

Co-authored-by: Travis Ralston <travisr@matrix.org>

* 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 <travisr@matrix.org>
This commit is contained in:
kegsay 2022-09-07 16:42:39 +01:00 committed by GitHub
parent 5bdae150fa
commit a215027c6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1632 additions and 51 deletions

View file

@ -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.
*/
/// <reference types="cypress" />
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<SynapseInstance>("@synapse"),
cy.get<ProxyInstance>("@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<SynapseInstance>("@synapse").then(cy.stopSynapse);
cy.get<ProxyInstance>("@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<string>(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<SynapseInstance>("@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<string>("@roomId"), cy.get<MatrixClient>("@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<string>("@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<string>("@roomId"), cy.get<MatrixClient>("@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<string>("@roomId"), cy.get<MatrixClient>("@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<string>("@roomId"), cy.get<MatrixClient>("@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<MatrixClient>("@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<MatrixClient>("@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",
]);
});
});

2
cypress/global.d.ts vendored
View file

@ -28,6 +28,7 @@ import type {
RoomStateEvent, RoomStateEvent,
Visibility, Visibility,
RoomMemberEvent, RoomMemberEvent,
ICreateClientOpts,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../src/dispatcher/dispatcher"; import type { MatrixDispatcher } from "../src/dispatcher/dispatcher";
import type PerformanceMonitor from "../src/performance"; import type PerformanceMonitor from "../src/performance";
@ -55,6 +56,7 @@ declare global {
MemoryCryptoStore: typeof MemoryCryptoStore; MemoryCryptoStore: typeof MemoryCryptoStore;
Visibility: typeof Visibility; Visibility: typeof Visibility;
Preset: typeof Preset; Preset: typeof Preset;
createClient(opts: ICreateClientOpts | string);
}; };
} }
} }

View file

@ -17,6 +17,7 @@ limitations under the License.
/// <reference types="cypress" /> /// <reference types="cypress" />
import * as os from "os"; import * as os from "os";
import * as crypto from "crypto";
import * as childProcess from "child_process"; import * as childProcess from "child_process";
import * as fse from "fs-extra"; import * as fse from "fs-extra";
@ -25,28 +26,32 @@ import PluginConfigOptions = Cypress.PluginConfigOptions;
// A cypress plugin to run docker commands // A cypress plugin to run docker commands
export function dockerRun(args: { export function dockerRun(opts: {
image: string; image: string;
containerName: string; containerName: string;
params?: string[]; params?: string[];
cmd?: string;
}): Promise<string> { }): Promise<string> {
const userInfo = os.userInfo(); 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 // 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}`); 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<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
childProcess.execFile('docker', [ childProcess.execFile("docker", args, (err, stdout) => {
"run",
"--name", args.containerName,
"-d",
...params,
args.image,
"run",
], (err, stdout) => {
if (err) reject(err); if (err) reject(err);
resolve(stdout.trim()); resolve(stdout.trim());
}); });
@ -122,6 +127,21 @@ export function dockerRm(args: {
}); });
} }
export function dockerIp(args: {
containerId: string;
}): Promise<string> {
return new Promise<string>((resolve, reject) => {
childProcess.execFile('docker', [
"inspect",
"-f", "{{ .NetworkSettings.IPAddress }}",
args.containerId,
], (err, stdout) => {
if (err) reject(err);
else resolve(stdout.trim());
});
});
}
/** /**
* @type {Cypress.PluginConfig} * @type {Cypress.PluginConfig}
*/ */
@ -132,5 +152,6 @@ export function docker(on: PluginEvents, config: PluginConfigOptions) {
dockerLogs, dockerLogs,
dockerStop, dockerStop,
dockerRm, dockerRm,
dockerIp,
}); });
} }

View file

@ -20,6 +20,7 @@ import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions; import PluginConfigOptions = Cypress.PluginConfigOptions;
import { performance } from "./performance"; import { performance } from "./performance";
import { synapseDocker } from "./synapsedocker"; import { synapseDocker } from "./synapsedocker";
import { slidingSyncProxyDocker } from "./sliding-sync";
import { webserver } from "./webserver"; import { webserver } from "./webserver";
import { docker } from "./docker"; import { docker } from "./docker";
import { log } from "./log"; import { log } from "./log";
@ -31,6 +32,7 @@ export default function(on: PluginEvents, config: PluginConfigOptions) {
docker(on, config); docker(on, config);
performance(on, config); performance(on, config);
synapseDocker(on, config); synapseDocker(on, config);
slidingSyncProxyDocker(on, config);
webserver(on, config); webserver(on, config);
log(on, config); log(on, config);
} }

View file

@ -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.
*/
/// <reference types="cypress" />
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<string, ProxyInstance>();
const PG_PASSWORD = "p4S5w0rD";
async function proxyStart(synapse: SynapseInstance): Promise<ProxyInstance> {
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<void> {
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);
}
});
}

View file

@ -101,12 +101,13 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
const synapseId = await dockerRun({ const synapseId = await dockerRun({
image: "matrixdotorg/synapse:develop", image: "matrixdotorg/synapse:develop",
containerName: `react-sdk-cypress-synapse-${crypto.randomBytes(4).toString("hex")}`, containerName: `react-sdk-cypress-synapse`,
params: [ params: [
"--rm", "--rm",
"-v", `${synCfg.configDir}:/data`, "-v", `${synCfg.configDir}:/data`,
"-p", `${synCfg.port}:8008/tcp`, "-p", `${synCfg.port}:8008/tcp`,
], ],
cmd: "run",
}); });
console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`);

View file

@ -36,4 +36,5 @@ import "./iframes";
import "./timeline"; import "./timeline";
import "./network"; import "./network";
import "./composer"; import "./composer";
import "./proxy";
import "./axe"; import "./axe";

58
cypress/support/proxy.ts Normal file
View file

@ -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.
*/
/// <reference types="cypress" />
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<ProxyInstance>;
/**
* 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<AUTWindow>;
}
}
}
function startProxy(synapse: SynapseInstance): Chainable<ProxyInstance> {
return cy.task<ProxyInstance>("proxyStart", synapse);
}
function stopProxy(proxy?: ProxyInstance): Chainable<AUTWindow> {
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);

View file

@ -36,6 +36,7 @@ import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient'; import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
import SecurityCustomisations from "./customisations/Security"; import SecurityCustomisations from "./customisations/Security";
import { SlidingSyncManager } from './SlidingSyncManager';
import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog"; import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog";
import { _t } from "./languageHandler"; import { _t } from "./languageHandler";
@ -237,6 +238,19 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread"); 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 // Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient); MatrixActionCreators.start(this.matrixClient);
MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient; MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient;

236
src/SlidingSyncManager.ts Normal file
View file

@ -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<string, number>;
private configureDefer: IDeferred<void>;
public constructor() {
this.listIdToIndex = {};
this.configureDefer = defer<void>();
}
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<MSC3575List> {
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<string> {
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;
}
}

View file

@ -55,6 +55,12 @@ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
} }
export function doesRoomHaveUnreadMessages(room: Room): 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(); const myUserId = MatrixClientPeg.get().getUserId();
// get the most recent read receipt sent by our account. // get the most recent read receipt sent by our account.

View file

@ -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<void> {
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<void> {
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<IDialogProps> = ({ 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<undefined, { error?: Error }>({
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 <TextInputDialog
title={_t("Sliding Sync configuration")}
description={<div>
<div><b>{ _t("To disable you will need to log out and back in, use with caution!") }</b></div>
{ nativeSupport }
</div>}
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);
}
}}
/>;
};

View file

@ -32,7 +32,7 @@ interface IProps extends IDialogProps {
busyMessage?: string; // pass _td string busyMessage?: string; // pass _td string
focus?: boolean; focus?: boolean;
hasCancel?: boolean; hasCancel?: boolean;
validator?: (fieldState: IFieldState) => IValidationResult; // result of withValidation validator?: (fieldState: IFieldState) => Promise<IValidationResult>; // result of withValidation
fixedWidth?: boolean; fixedWidth?: boolean;
} }

View file

@ -92,6 +92,7 @@ import { RoomResultContextMenus } from "./RoomResultContextMenus";
import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { RoomContextDetails } from "../../rooms/RoomContextDetails";
import { TooltipOption } from "./TooltipOption"; import { TooltipOption } from "./TooltipOption";
import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom";
import { useSlidingSyncRoomSearch } from "../../../../hooks/useSlidingSyncRoomSearch";
const MAX_RECENT_SEARCHES = 10; const MAX_RECENT_SEARCHES = 10;
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons 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]); }, [numResults, queryLength, viaSpotlight]);
}; };
const findVisibleRooms = (cli: MatrixClient) => { const findVisibleRooms = (cli: MatrixClient): Room[] => {
return cli.getVisibleRooms().filter(room => { return cli.getVisibleRooms().filter(room => {
// Do not show local rooms // Do not show local rooms
if (isLocalRoom(room)) return false; if (isLocalRoom(room)) return false;
@ -340,25 +341,43 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
searchProfileInfo, searchProfileInfo,
searchParams, 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<Result[]>( const possibleResults = useMemo<Result[]>(
() => { () => {
const roomResults = findVisibleRooms(cli).map(toRoomResult); const userResults: IMemberResult[] = [];
// If we already have a DM with the user we're looking for, we will let roomResults: IRoomResult[];
// show that DM instead of the user themselves let alreadyAddedUserIds: Set<string>;
const alreadyAddedUserIds = roomResults.reduce((userIds, result) => { if (isSlidingSyncEnabled) {
const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId); // use the rooms sliding sync returned as the server has already worked it out for us
if (!userId) return userIds; roomResults = slidingSyncRooms.map(toRoomResult);
if (result.room.getJoinedMemberCount() > 2) return userIds; } else {
userIds.add(userId); roomResults = findVisibleRooms(cli).map(toRoomResult);
return userIds; // If we already have a DM with the user we're looking for, we will
}, new Set<string>()); // show that DM instead of the user themselves
const userResults = []; alreadyAddedUserIds = roomResults.reduce((userIds, result) => {
for (const user of [...findVisibleRoomMembers(cli), ...users]) { const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId);
// Make sure we don't have any user more than once if (!userId) return userIds;
if (alreadyAddedUserIds.has(user.userId)) continue; if (result.room.getJoinedMemberCount() > 2) return userIds;
alreadyAddedUserIds.add(user.userId); userIds.add(userId);
return userIds;
}, new Set<string>());
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)); userResults.push(toMemberResult(user));
}
} }
return [ return [
@ -382,7 +401,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
...publicRooms.map(toPublicRoomResult), ...publicRooms.map(toPublicRoomResult),
].filter(result => filter === null || result.filter.includes(filter)); ].filter(result => filter === null || result.filter.includes(filter));
}, },
[cli, users, profile, publicRooms, filter], [cli, users, profile, publicRooms, slidingSyncRooms, isSlidingSyncEnabled, filter],
); );
const results = useMemo<Record<Section, Result[]>>(() => { const results = useMemo<Record<Section, Result[]>>(() => {
@ -401,10 +420,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
possibleResults.forEach(entry => { possibleResults.forEach(entry => {
if (isRoomResult(entry)) { if (isRoomResult(entry)) {
if (!entry.room.normalizedName?.includes(normalizedQuery) && // sliding sync gives the correct rooms in the list so we don't need to filter
!entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && if (!isSlidingSyncEnabled) {
!entry.query?.some(q => q.includes(lcQuery)) if (!entry.room.normalizedName?.includes(normalizedQuery) &&
) return; // bail, does not match query !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) &&
!entry.query?.some(q => q.includes(lcQuery))
) return; // bail, does not match query
}
} else if (isMemberResult(entry)) { } else if (isMemberResult(entry)) {
if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query
} else if (isPublicRoomResult(entry)) { } else if (isPublicRoomResult(entry)) {
@ -455,7 +477,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
} }
return results; return results;
}, [trimmedQuery, filter, cli, possibleResults, memberComparator]); }, [trimmedQuery, filter, cli, possibleResults, isSlidingSyncEnabled, memberComparator]);
const numResults = sum(Object.values(results).map(it => it.length)); const numResults = sum(Object.values(results).map(it => it.length));
useWebSearchMetrics(numResults, query.length, true); useWebSearchMetrics(numResults, query.length, true);
@ -1208,7 +1230,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
aria-label={_t("Search")} aria-label={_t("Search")}
aria-describedby="mx_SpotlightDialog_keyboardPrompt" aria-describedby="mx_SpotlightDialog_keyboardPrompt"
/> />
{ (publicRoomsLoading || peopleLoading || profileLoading) && ( { (publicRoomsLoading || peopleLoading || profileLoading || slidingSyncRoomSearchLoading) && (
<Spinner w={24} h={24} /> <Spinner w={24} h={24} />
) } ) }
</div> </div>

View file

@ -16,9 +16,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from 'classnames'; import classNames from 'classnames';
import { Dispatcher } from "flux"; import { Dispatcher } from "flux";
import { Room } from "matrix-js-sdk/src/models/room";
import { Enable, Resizable } from "re-resizable"; import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer"; import { Direction } from "re-resizable/lib/resizer";
import * as React from "react"; 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 { ListLayout } from "../../../stores/room-list/ListLayout";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore"; 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 { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
import { objectExcluding, objectHasDiff } from "../../../utils/objects"; import { objectExcluding, objectHasDiff } from "../../../utils/objects";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
@ -52,6 +52,8 @@ import ContextMenu, {
import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleButton from "../../views/elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ExtraTile from "./ExtraTile"; import ExtraTile from "./ExtraTile";
import SettingsStore from "../../../settings/SettingsStore";
import { SlidingSyncManager } from "../../../SlidingSyncManager";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import RoomTile from "./RoomTile"; 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 isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number; height: number;
rooms: Room[]; rooms: Room[];
roomsLoading: boolean;
} }
export default class RoomSublist extends React.Component<IProps, IState> { export default class RoomSublist extends React.Component<IProps, IState> {
@ -109,8 +112,12 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private heightAtStart: number; private heightAtStart: number;
private notificationState: ListNotificationState; private notificationState: ListNotificationState;
private slidingSyncMode: boolean;
constructor(props: IProps) { constructor(props: IProps) {
super(props); 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.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0; this.heightAtStart = 0;
@ -121,6 +128,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
isExpanded: !this.layout.isCollapsed, isExpanded: !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this. height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
rooms: arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []), rooms: arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []),
roomsLoading: false,
}; };
// Why Object.assign() and not this.state.height? Because TypeScript says no. // Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, { height: this.calculateInitialHeight() }); this.state = Object.assign(this.state, { height: this.calculateInitialHeight() });
@ -167,6 +175,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
} }
private get numVisibleTiles(): number { private get numVisibleTiles(): number {
if (this.slidingSyncMode) {
return this.state.rooms.length;
}
const nVisible = Math.ceil(this.layout.visibleTiles); const nVisible = Math.ceil(this.layout.visibleTiles);
return Math.min(nVisible, this.numTiles); return Math.min(nVisible, this.numTiles);
} }
@ -239,6 +250,8 @@ export default class RoomSublist extends React.Component<IProps, IState> {
public componentDidMount() { public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated); 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 // 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 // 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 }); this.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true });
@ -247,9 +260,19 @@ export default class RoomSublist extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef); defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
RoomListStore.instance.off(LISTS_LOADING_EVENT, this.onListsLoading);
this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent); 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 = () => { private onListsUpdated = () => {
const stateUpdates: IState & any = {}; // &any is to avoid a cast on the initializer const stateUpdates: IState & any = {}; // &any is to avoid a cast on the initializer
@ -315,7 +338,16 @@ export default class RoomSublist extends React.Component<IProps, IState> {
this.setState({ isResizing: false, height: newHeight }); 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 // read number of visible tiles before we mutate it
const numVisibleTiles = this.numVisibleTiles; const numVisibleTiles = this.numVisibleTiles;
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding); const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
@ -531,8 +563,17 @@ export default class RoomSublist extends React.Component<IProps, IState> {
let contextMenu = null; let contextMenu = null;
if (this.state.contextMenuPosition) { if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; 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. // Invites don't get some nonsense options, so only add them if we have to.
let otherSections = null; let otherSections = null;
@ -717,7 +758,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
}); });
let content = null; let content = null;
if (visibleTiles.length > 0 && this.props.forceExpanded) { if (this.state.roomsLoading) {
content = <div className="mx_RoomSublist_skeletonUI" />;
} else if (visibleTiles.length > 0 && this.props.forceExpanded) {
content = <div className="mx_RoomSublist_resizeBox mx_RoomSublist_resizeBox_forceExpanded"> content = <div className="mx_RoomSublist_resizeBox mx_RoomSublist_resizeBox_forceExpanded">
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}> <div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{ visibleTiles } { visibleTiles }
@ -739,12 +782,18 @@ export default class RoomSublist extends React.Component<IProps, IState> {
// floats above the resize handle, if we have one present. If the user has all // floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'. // tiles visible, it becomes 'show less'.
let showNButton = null; 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 // 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 nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight); 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 }); const label = _t("Show %(count)s more", { count: numMissing });
let showMoreText = ( let showMoreText = (
<span className='mx_RoomSublist_showNButtonText'> <span className='mx_RoomSublist_showNButtonText'>

View file

@ -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<Room[]>([]);
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<boolean> => {
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;
};

View file

@ -907,6 +907,7 @@
"Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)", "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)", "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", "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)", "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)", "Favourite Messages (under active development)": "Favourite Messages (under active development)",
"Use new session manager (under active development)": "Use new session manager (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 selected message": "Link to selected message",
"Link to room": "Link to room", "Link to room": "Link to room",
"Command Help": "Command Help", "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", "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.", "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", "Space settings": "Space settings",

View file

@ -41,6 +41,7 @@ import IncompatibleController from "./controllers/IncompatibleController";
import { ImageSize } from "./enums/ImageSize"; import { ImageSize } from "./enums/ImageSize";
import { MetaSpace } from "../stores/spaces"; import { MetaSpace } from "../stores/spaces";
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
import SlidingSyncController from './controllers/SlidingSyncController';
import ThreadBetaController from './controllers/ThreadBetaController'; import ThreadBetaController from './controllers/ThreadBetaController';
import { FontWatcher } from "./watchers/FontWatcher"; import { FontWatcher } from "./watchers/FontWatcher";
@ -406,6 +407,18 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Send read receipts"), displayName: _td("Send read receipts"),
default: true, 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": { "feature_location_share_live": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,

View file

@ -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<boolean> {
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<void> {
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");
}
}

View file

@ -47,6 +47,8 @@ import { JoinRoomErrorPayload } from "../dispatcher/payloads/JoinRoomErrorPayloa
import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayload"; import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayload";
import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload";
import SettingsStore from "../settings/SettingsStore";
import { SlidingSyncManager } from "../SlidingSyncManager";
import { awaitRoomDownSync } from "../utils/RoomUpgrade"; import { awaitRoomDownSync } from "../utils/RoomUpgrade";
const NUM_JOIN_RETRY = 5; const NUM_JOIN_RETRY = 5;
@ -278,6 +280,32 @@ export class RoomViewStore extends Store<ActionPayload> {
activeSpace, 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 = { const newState = {
roomId: payload.room_id, roomId: payload.room_id,

View file

@ -23,6 +23,9 @@ import { IFilterCondition } from "./filters/IFilterCondition";
export enum RoomListStoreEvent { export enum RoomListStoreEvent {
// The event/channel which is called when the room lists have been changed. // The event/channel which is called when the room lists have been changed.
ListsUpdate = "lists_update", 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 { export interface RoomListStore extends EventEmitter {
@ -33,6 +36,15 @@ export interface RoomListStore extends EventEmitter {
*/ */
get orderedLists(): ITagMap; 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. * Set the sort algorithm for the specified tag.
* @param tagId the tag to set the algorithm for * @param tagId the tag to set the algorithm for

View file

@ -38,12 +38,14 @@ import { VisibilityProvider } from "./filters/VisibilityProvider";
import { SpaceWatcher } from "./SpaceWatcher"; import { SpaceWatcher } from "./SpaceWatcher";
import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators";
import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { SlidingRoomListStoreClass } from "./SlidingRoomListStore";
interface IState { interface IState {
// state is tracked in underlying classes // state is tracked in underlying classes
} }
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore
export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements Interface { export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements Interface {
/** /**
@ -585,6 +587,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements
return algorithmTags; 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 * 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 * 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; private static internalInstance: Interface;
public static get instance(): Interface { public static get instance(): Interface {
if (!this.internalInstance) { if (!RoomListStore.internalInstance) {
const instance = new RoomListStoreClass(); if (SettingsStore.getValue("feature_sliding_sync")) {
instance.start(); logger.info("using SlidingRoomListStoreClass");
this.internalInstance = instance; const instance = new SlidingRoomListStoreClass();
instance.start();
RoomListStore.internalInstance = instance;
} else {
const instance = new RoomListStoreClass();
instance.start();
RoomListStore.internalInstance = instance;
}
} }
return this.internalInstance; return this.internalInstance;

View file

@ -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, string[]> = {
[SortAlgorithm.Alphabetic]: ["by_name", "by_recency"],
[SortAlgorithm.Recent]: ["by_highlight_count", "by_notification_count", "by_recency"],
[SortAlgorithm.Manual]: ["by_recency"],
};
const filterConditions: Record<TagID, MSC3575Filter> = {
[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<IState> implements Interface {
private tagIdToSortAlgo: Record<TagID, SortAlgorithm> = {};
private tagMap: ITagMap = {};
private counts: Record<TagID, number> = {};
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<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.
}
/**
* 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<number, string>): 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<string>();
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<number, string>) {
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<any> {
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<any> {
await this.resetStore();
}
protected async onAction(payload: ActionPayload) {
}
protected async onDispatchAsync(payload: ActionPayload) {
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { RoomListStoreClass } from "./RoomListStore"; import { RoomListStore as Interface } from "./Interface";
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
import SpaceStore from "../spaces/SpaceStore"; import SpaceStore from "../spaces/SpaceStore";
import { MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; 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 activeSpace: SpaceKey = SpaceStore.instance.activeSpace;
private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome; private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome;
constructor(private store: RoomListStoreClass) { constructor(private store: Interface) {
if (SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome)) { if (SpaceWatcher.needsFilter(this.activeSpace, this.allRoomsInHome)) {
this.updateFilter(); this.updateFilter();
store.addFilter(this.filter); store.addFilter(this.filter);

View file

@ -41,6 +41,7 @@ describe('RoomViewStore', function() {
joinRoom: jest.fn(), joinRoom: jest.fn(),
getRoom: jest.fn(), getRoom: jest.fn(),
getRoomIdForAlias: jest.fn(), getRoomIdForAlias: jest.fn(),
isGuest: jest.fn(),
}); });
const room = new Room('!room:server', mockClient, userId); const room = new Room('!room:server', mockClient, userId);
@ -49,6 +50,7 @@ describe('RoomViewStore', function() {
mockClient.credentials = { userId: "@test:example.com" }; mockClient.credentials = { userId: "@test:example.com" };
mockClient.joinRoom.mockResolvedValue(room); mockClient.joinRoom.mockResolvedValue(room);
mockClient.getRoom.mockReturnValue(room); mockClient.getRoom.mockReturnValue(room);
mockClient.isGuest.mockReturnValue(false);
// Reset the state of the store // Reset the state of the store
RoomViewStore.instance.reset(); RoomViewStore.instance.reset();