2022-09-07 16:42:39 +01:00
/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
2022-09-07 16:42:39 +01:00
Copyright 2022 The Matrix.org Foundation C.I.C.
2025-01-06 11:18:54 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2022-09-07 16:42:39 +01:00
*/
/*
* 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)
*/
2024-04-30 19:11:11 +01:00
import { MatrixClient , EventType , AutoDiscovery , Method , timeoutSignal } from "matrix-js-sdk/src/matrix" ;
2022-09-07 16:42:39 +01:00
import {
MSC3575Filter ,
MSC3575List ,
2022-11-18 19:05:00 +00:00
MSC3575_STATE_KEY_LAZY ,
MSC3575_STATE_KEY_ME ,
MSC3575_WILDCARD ,
2022-09-07 16:42:39 +01:00
SlidingSync ,
} from "matrix-js-sdk/src/sliding-sync" ;
import { logger } from "matrix-js-sdk/src/logger" ;
2023-07-07 09:02:13 +01:00
import { defer , sleep } from "matrix-js-sdk/src/utils" ;
2022-09-07 16:42:39 +01:00
2024-04-30 19:11:11 +01:00
import SettingsStore from "./settings/SettingsStore" ;
import SlidingSyncController from "./settings/controllers/SlidingSyncController" ;
2022-09-07 16:42:39 +01:00
// 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 ,
2022-11-18 19:05:00 +00:00
// missing required_state which will change depending on the kind of room
2022-10-26 14:04:03 +01:00
include_old_rooms : {
timeline_limit : 0 ,
required_state : [
// state needed to handle space navigation and tombstone chains
[ EventType . RoomCreate , "" ],
[ EventType . RoomTombstone , "" ],
2022-11-18 19:05:00 +00:00
[ EventType . SpaceChild , MSC3575_WILDCARD ],
[ EventType . SpaceParent , MSC3575_WILDCARD ],
[ EventType . RoomMember , MSC3575_STATE_KEY_ME ],
2022-10-26 14:04:03 +01:00
],
},
2022-09-07 16:42:39 +01:00
};
2022-11-18 19:05:00 +00:00
// lazy load room members so rooms like Matrix HQ don't take forever to load
const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted" ;
2024-10-16 17:38:22 +01:00
const UNENCRYPTED_SUBSCRIPTION = {
required_state : [
[ MSC3575_WILDCARD , MSC3575_WILDCARD ], // all events
[ EventType . RoomMember , MSC3575_STATE_KEY_ME ], // except for m.room.members, get our own membership
[ EventType . RoomMember , MSC3575_STATE_KEY_LAZY ], // ...and lazy load the rest.
],
... DEFAULT_ROOM_SUBSCRIPTION_INFO ,
};
2022-11-18 19:05:00 +00:00
// we need all the room members in encrypted rooms because we need to know which users to encrypt
// messages for.
2024-10-16 17:38:22 +01:00
const ENCRYPTED_SUBSCRIPTION = {
required_state : [
[ MSC3575_WILDCARD , MSC3575_WILDCARD ], // all events
],
... DEFAULT_ROOM_SUBSCRIPTION_INFO ,
};
2022-09-07 16:42:39 +01:00
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 ();
2023-07-07 09:02:13 +01:00
public slidingSync? : SlidingSync ;
2023-05-16 14:25:43 +01:00
private client? : MatrixClient ;
2022-09-07 16:42:39 +01:00
2023-07-07 09:02:13 +01:00
private configureDefer = defer < void >();
2022-09-07 16:42:39 +01:00
public static get instance () : SlidingSyncManager {
return SlidingSyncManager . internalInstance ;
}
public configure ( client : MatrixClient , proxyUrl : string ) : SlidingSync {
this . client = client ;
2022-11-18 19:05:00 +00:00
// by default use the encrypted subscription as that gets everything, which is a safer
// default than potentially missing member events.
2023-01-19 11:02:43 +00:00
this . slidingSync = new SlidingSync (
proxyUrl ,
new Map (),
ENCRYPTED_SUBSCRIPTION ,
client ,
SLIDING_SYNC_TIMEOUT_MS ,
);
2022-11-18 19:05:00 +00:00
this . slidingSync . addCustomSubscription ( UNENCRYPTED_SUBSCRIPTION_NAME , UNENCRYPTED_SUBSCRIPTION );
2022-09-07 16:42:39 +01:00
// set the space list
2023-01-18 17:19:12 +00:00
this . slidingSync . setList ( SlidingSyncManager . ListSpaces , {
2022-09-07 16:42:39 +01:00
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
2022-11-18 19:05:00 +00:00
[ EventType . SpaceChild , MSC3575_WILDCARD ], // all space children
[ EventType . SpaceParent , MSC3575_WILDCARD ], // all space parents
[ EventType . RoomMember , MSC3575_STATE_KEY_ME ], // lets the client calculate that we are in fact in the room
2022-09-07 16:42:39 +01:00
],
2022-10-26 14:04:03 +01:00
include_old_rooms : {
timeline_limit : 0 ,
required_state : [
[ EventType . RoomCreate , "" ],
[ EventType . RoomTombstone , "" ], // lets JS SDK hide rooms which are dead
2022-11-18 19:05:00 +00:00
[ EventType . SpaceChild , MSC3575_WILDCARD ], // all space children
[ EventType . SpaceParent , MSC3575_WILDCARD ], // all space parents
[ EventType . RoomMember , MSC3575_STATE_KEY_ME ], // lets the client calculate that we are in fact in the room
2022-10-26 14:04:03 +01:00
],
},
2022-09-07 16:42:39 +01:00
filters : {
room_types : [ "m.space" ],
},
});
this . configureDefer . resolve ();
return this . slidingSync ;
}
/**
* Ensure that this list is registered.
2023-01-18 17:19:12 +00:00
* @param listKey The list key to register
2022-09-07 16:42:39 +01:00
* @param updateArgs The fields to update on the list.
* @returns The complete list request params
*/
2023-01-18 17:19:12 +00:00
public async ensureListRegistered ( listKey : string , updateArgs : PartialSlidingSyncRequest ) : Promise < MSC3575List > {
logger . debug ( "ensureListRegistered:::" , listKey , updateArgs );
2022-09-07 16:42:39 +01:00
await this . configureDefer . promise ;
2023-07-07 09:02:13 +01:00
let list = this . slidingSync ! . getListParams ( listKey );
2022-09-07 16:42:39 +01:00
if ( ! list ) {
list = {
ranges : [[ 0 , 20 ]],
2022-10-26 14:04:03 +01:00
sort : [ "by_notification_level" , "by_recency" ],
2022-09-07 16:42:39 +01:00
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
2022-11-18 19:05:00 +00:00
[ EventType . RoomMember , MSC3575_STATE_KEY_ME ], // lets the client calculate that we are in fact in the room
2022-09-07 16:42:39 +01:00
],
2022-10-26 14:04:03 +01:00
include_old_rooms : {
timeline_limit : 0 ,
required_state : [
[ EventType . RoomCreate , "" ],
[ EventType . RoomTombstone , "" ], // lets JS SDK hide rooms which are dead
2022-11-18 19:05:00 +00:00
[ EventType . SpaceChild , MSC3575_WILDCARD ], // all space children
[ EventType . SpaceParent , MSC3575_WILDCARD ], // all space parents
[ EventType . RoomMember , MSC3575_STATE_KEY_ME ], // lets the client calculate that we are in fact in the room
2022-10-26 14:04:03 +01:00
],
},
2024-10-16 17:38:22 +01:00
... updateArgs ,
2022-09-07 16:42:39 +01:00
};
} else {
2024-10-16 17:38:22 +01:00
const updatedList = { ... list , ... updateArgs };
2022-09-07 16:42:39 +01:00
// 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 ) {
2023-07-07 09:02:13 +01:00
await this . slidingSync ! . setListRanges ( listKey , updateArgs . ranges );
2022-09-07 16:42:39 +01:00
} else {
2023-07-07 09:02:13 +01:00
await this . slidingSync ! . setList ( listKey , list );
2022-09-07 16:42:39 +01:00
}
} catch ( err ) {
logger . debug ( "ensureListRegistered: update failed txn_id=" , err );
}
2023-07-07 09:02:13 +01:00
return this . slidingSync ! . getListParams ( listKey ) ! ;
2022-09-07 16:42:39 +01:00
}
public async setRoomVisible ( roomId : string , visible : boolean ) : Promise < string > {
await this . configureDefer . promise ;
2023-07-07 09:02:13 +01:00
const subscriptions = this . slidingSync ! . getRoomSubscriptions ();
2022-09-07 16:42:39 +01:00
if ( visible ) {
subscriptions . add ( roomId );
} else {
subscriptions . delete ( roomId );
}
2023-05-16 14:25:43 +01:00
const room = this . client ? . getRoom ( roomId );
2024-11-19 11:09:25 +01:00
let shouldLazyLoad = ! ( await this . client ? . getCrypto () ? . isEncryptionEnabledInRoom ( roomId ));
2022-11-18 19:05:00 +00:00
if ( ! room ) {
// default to safety: request all state if we can't work it out. This can happen if you
// refresh the app whilst viewing a room: we call setRoomVisible before we know anything
// about the room.
shouldLazyLoad = false ;
}
logger . log ( "SlidingSync setRoomVisible:" , roomId , visible , "shouldLazyLoad:" , shouldLazyLoad );
if ( shouldLazyLoad ) {
// lazy load this room
2023-07-07 09:02:13 +01:00
this . slidingSync ! . useCustomSubscription ( roomId , UNENCRYPTED_SUBSCRIPTION_NAME );
2022-11-18 19:05:00 +00:00
}
2023-07-07 09:02:13 +01:00
const p = this . slidingSync ! . modifyRoomSubscriptions ( subscriptions );
2022-11-18 19:05:00 +00:00
if ( room ) {
2022-09-07 16:42:39 +01:00
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 ;
2024-10-16 16:43:07 +01:00
} catch {
2022-09-07 16:42:39 +01:00
logger . warn ( "SlidingSync setRoomVisible:" , roomId , visible , "failed to confirm transaction" );
}
return roomId ;
}
2022-11-01 10:27:03 +00:00
/**
* Retrieve all rooms on the user's account. Used for pre-populating the local search cache.
* Retrieval is gradual over time.
* @param batchSize The number of rooms to return in each request.
* @param gapBetweenRequestsMs The number of milliseconds to wait between requests.
*/
2023-01-12 13:25:14 +00:00
public async startSpidering ( batchSize : number , gapBetweenRequestsMs : number ) : Promise < void > {
2022-11-01 10:27:03 +00:00
await sleep ( gapBetweenRequestsMs ); // wait a bit as this is called on first render so let's let things load
let startIndex = batchSize ;
let hasMore = true ;
let firstTime = true ;
while ( hasMore ) {
const endIndex = startIndex + batchSize - 1 ;
try {
const ranges = [
[ 0 , batchSize - 1 ],
[ startIndex , endIndex ],
];
if ( firstTime ) {
2023-07-07 09:02:13 +01:00
await this . slidingSync ! . setList ( SlidingSyncManager . ListSearch , {
2022-11-01 10:27:03 +00:00
// e.g [0,19] [20,39] then [0,19] [40,59]. We keep [0,20] constantly to ensure
// any changes to the list whilst spidering are caught.
ranges : ranges ,
sort : [
"by_recency" , // this list isn't shown on the UI so just sorting by timestamp is enough
],
timeline_limit : 0 , // we only care about the room details, not messages in the room
required_state : [
[ EventType . RoomJoinRules , "" ], // the public icon on the room list
[ EventType . RoomAvatar , "" ], // any room avatar
[ EventType . RoomTombstone , "" ], // lets JS SDK hide rooms which are dead
[ EventType . RoomEncryption , "" ], // lets rooms be configured for E2EE correctly
[ EventType . RoomCreate , "" ], // for isSpaceRoom checks
2022-11-18 19:05:00 +00:00
[ EventType . RoomMember , MSC3575_STATE_KEY_ME ], // lets the client calculate that we are in fact in the room
2022-11-01 10:27:03 +00:00
],
// we don't include_old_rooms here in an effort to reduce the impact of spidering all rooms
// on the user's account. This means some data in the search dialog results may be inaccurate
// e.g membership of space, but this will be corrected when the user clicks on the room
// as the direct room subscription does include old room iterations.
filters : {
// we get spaces via a different list, so filter them out
not_room_types : [ "m.space" ],
},
});
} else {
2023-07-07 09:02:13 +01:00
await this . slidingSync ! . setListRanges ( SlidingSyncManager . ListSearch , ranges );
2022-11-01 10:27:03 +00:00
}
2024-10-16 16:43:07 +01:00
} catch {
2022-11-01 10:27:03 +00:00
// do nothing, as we reject only when we get interrupted but that's fine as the next
// request will include our data
2023-01-20 10:31:44 +00:00
} finally {
// gradually request more over time, even on errors.
await sleep ( gapBetweenRequestsMs );
2022-11-01 10:27:03 +00:00
}
2023-07-07 09:02:13 +01:00
const listData = this . slidingSync ! . getListData ( SlidingSyncManager . ListSearch ) ! ;
2023-01-20 10:31:44 +00:00
hasMore = endIndex + 1 < listData . joinedCount ;
2022-11-01 10:27:03 +00:00
startIndex += batchSize ;
firstTime = false ;
}
}
2024-04-30 19:11:11 +01:00
/**
* Set up the Sliding Sync instance; configures the end point and starts spidering.
* The sliding sync endpoint is derived the following way:
* 1. The user-defined sliding sync proxy URL (legacy, for backwards compatibility)
* 2. The client `well-known` sliding sync proxy URL [declared at the unstable prefix](https://github.com/matrix-org/matrix-spec-proposals/blob/kegan/sync-v3/proposals/3575-sync.md#unstable-prefix)
* 3. The homeserver base url (for native server support)
* @param client The MatrixClient to use
* @returns A working Sliding Sync or undefined
*/
public async setup ( client : MatrixClient ) : Promise < SlidingSync | undefined > {
const baseUrl = client . baseUrl ;
const proxyUrl = SettingsStore . getValue ( "feature_sliding_sync_proxy_url" );
const wellKnownProxyUrl = await this . getProxyFromWellKnown ( client );
const slidingSyncEndpoint = proxyUrl || wellKnownProxyUrl || baseUrl ;
this . configure ( client , slidingSyncEndpoint );
logger . info ( "Sliding sync activated at" , slidingSyncEndpoint );
this . startSpidering ( 100 , 50 ); // 100 rooms at a time, 50ms apart
return this . slidingSync ;
}
/**
* Get the sliding sync proxy URL from the client well known
* @param client The MatrixClient to use
* @return The proxy url
*/
public async getProxyFromWellKnown ( client : MatrixClient ) : Promise < string | undefined > {
let proxyUrl : string | undefined ;
try {
2024-05-21 14:49:12 +01:00
const clientDomain = await client . getDomain ();
if ( clientDomain === null ) {
throw new RangeError ( "Homeserver domain is null" );
}
const clientWellKnown = await AutoDiscovery . findClientConfig ( clientDomain );
2024-04-30 19:11:11 +01:00
proxyUrl = clientWellKnown ? .[ "org.matrix.msc3575.proxy" ] ? . url ;
2024-10-16 16:43:07 +01:00
} catch {
2024-05-21 14:49:12 +01:00
// Either client.getDomain() is null so we've shorted out, or is invalid so `AutoDiscovery.findClientConfig` has thrown
2024-04-30 19:11:11 +01:00
}
if ( proxyUrl != undefined ) {
logger . log ( "getProxyFromWellKnown: client well-known declares sliding sync proxy at" , proxyUrl );
}
return proxyUrl ;
}
/**
2024-05-03 12:45:26 -06:00
* Check if the server "natively" supports sliding sync (with an unstable endpoint).
2024-04-30 19:11:11 +01:00
* @param client The MatrixClient to use
2024-05-03 12:45:26 -06:00
* @return Whether the "native" (unstable) endpoint is supported
2024-04-30 19:11:11 +01:00
*/
public async nativeSlidingSyncSupport ( client : MatrixClient ) : Promise < boolean > {
2024-05-03 12:45:26 -06:00
// Per https://github.com/matrix-org/matrix-spec-proposals/pull/3575/files#r1589542561
// `client` can be undefined/null in tests for some reason.
const support = await client ? . doesServerSupportUnstableFeature ( "org.matrix.msc3575" );
if ( support ) {
logger . log ( "nativeSlidingSyncSupport: sliding sync advertised as unstable" );
2024-04-30 19:11:11 +01:00
}
2024-05-03 12:45:26 -06:00
return support ;
2024-04-30 19:11:11 +01:00
}
/**
* Check whether our homeserver has sliding sync support, that the endpoint is up, and
* is a sliding sync endpoint.
*
* Sets static member `SlidingSyncController.serverSupportsSlidingSync`
* @param client The MatrixClient to use
*/
public async checkSupport ( client : MatrixClient ) : Promise < void > {
if ( await this . nativeSlidingSyncSupport ( client )) {
SlidingSyncController . serverSupportsSlidingSync = true ;
return ;
}
const proxyUrl = await this . getProxyFromWellKnown ( client );
if ( proxyUrl != undefined ) {
2024-05-14 13:40:10 +01:00
const response = await fetch ( new URL ( "/client/server.json" , proxyUrl ), {
2024-04-30 19:11:11 +01:00
method : Method.Get ,
signal : timeoutSignal ( 10 * 1000 ), // 10s
});
if ( response . status === 200 ) {
logger . log ( "checkSupport: well-known sliding sync proxy is up at" , proxyUrl );
SlidingSyncController . serverSupportsSlidingSync = true ;
}
}
}
2022-09-07 16:42:39 +01:00
}