diff --git a/.changeset/feat-sliding-sync-presence.md b/.changeset/feat-sliding-sync-presence.md new file mode 100644 index 000000000..136556540 --- /dev/null +++ b/.changeset/feat-sliding-sync-presence.md @@ -0,0 +1,13 @@ +--- +sable: minor +--- + +Rewrites the sliding sync implementation to match the Element Web approach (MSC4186). + +- Room list sorted by notification level, recency, then name +- `include_old_rooms` added so tombstoned rooms pass predecessor state to replacements +- Active-room custom subscription: focused room receives `timeline_limit=50` +- `subscribeToRoom` / `unsubscribeFromRoom` API on `SlidingSyncManager` +- `useSlidingSyncActiveRoom` hook + `SlidingSyncActiveRoomSubscriber` component +- Registers a custom `ExtensionPresence` so `m.presence` events from the server are processed into the SDK's `User` model — fixes components using `useUserPresence` always showing stale/default presence +- Always reinitialises the timeline on `TimelineRefresh` events to fix a silent hang where the room timeline stops updating after a reconnect diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index ffbb9a117..f8552e19a 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -445,9 +445,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { Proxy: {syncDiagnostics.sliding.proxyBaseUrl} - Timeline limit: {syncDiagnostics.sliding.timelineLimit} | page - size: {syncDiagnostics.sliding.listPageSize} | adaptive:{' '} - {syncDiagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'} + Room timeline: {syncDiagnostics.sliding.timelineLimit} + {syncDiagnostics.sliding.adaptiveTimeline + ? ' (adaptive)' + : ''}{' '} + | page size: {syncDiagnostics.sliding.listPageSize} ) : ( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 030accab0..668b33f7b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -858,10 +858,33 @@ export function RoomTimeline({ useLiveTimelineRefresh( room, useCallback(() => { - if (liveTimelineLinked || timeline.linkedTimelines.length === 0) { - setTimeline(getInitialTimeline(room)); - } - }, [room, liveTimelineLinked, timeline.linkedTimelines.length]) + // Always reinitialize on TimelineRefresh. With sliding sync, a limited + // response replaces the room's live EventTimeline with a brand-new object, + // firing TimelineRefresh. At that moment liveTimelineLinked is stale-false + // (the stored linkedTimelines still reference the old detached object), + // so the previous guard `if (liveTimelineLinked || ...)` would silently + // skip reinit. Back-pagination then calls paginateEventTimeline against + // the dead old timeline, which no-ops, and the IntersectionObserver never + // re-fires because intersection state didn't change — causing a permanent + // hang at the top of the timeline with no spinner and no history loaded. + // Unconditionally reinitializing is correct: TimelineRefresh signals that + // the SDK has replaced the timeline chain, so any stored range/indices + // against the old chain are invalid anyway. + // + // Also force atBottom=true and queue a scroll-to-bottom. The SDK fires + // TimelineRefresh before adding new events to the fresh live timeline, so + // getInitialTimeline captures range.end=0. Once events arrive the + // rangeAtEnd self-heal useEffect needs atBottom=true to run; the + // IntersectionObserver may have transiently fired isIntersecting=false + // during the render transition, leaving atBottom=false and causing the + // "Jump to Latest" button to stick permanently. Forcing atBottom here is + // correct: TimelineRefresh always reinits to the live end, so the user + // should be repositioned to the bottom regardless. + setTimeline(getInitialTimeline(room)); + setAtBottom(true); + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = false; + }, [room, setAtBottom]) ); // Re-render when non-live Replace relations arrive (bundled/historical edits @@ -882,6 +905,21 @@ export function RoomTimeline({ setTimeline(getInitialTimeline(room)); }, [eventId, room, timeline.linkedTimelines.length]); + // Fix stale rangeAtEnd after a sliding sync TimelineRefresh. The SDK fires + // TimelineRefresh before adding new events to the freshly-created live + // EventTimeline, so getInitialTimeline captures range.end=0. New events then + // arrive via useLiveEventArrive, but its atLiveEndRef guard is stale-false + // (hasn't re-rendered yet), bypassing the range-advance path. The next render + // ends up with liveTimelineLinked=true but rangeAtEnd=false, making the + // "Jump to Latest" button appear while the user is already at the bottom. + // Re-running getInitialTimeline post-render (after events were added to the + // live EventTimeline object) snaps range.end to the correct event count. + useEffect(() => { + if (liveTimelineLinked && !rangeAtEnd && atBottom) { + setTimeline(getInitialTimeline(room)); + } + }, [liveTimelineLinked, rangeAtEnd, atBottom, room]); + // Stay at bottom when room editor resize useResizeObserver( useMemo(() => { @@ -2127,6 +2165,19 @@ export function RoomTimeline({ ); } else if (timelineItems.length === 0) { + // When eventsLength===0 AND liveTimelineLinked the live EventTimeline was + // just reset by a sliding sync TimelineRefresh and new events haven't + // arrived yet. Attaching the IntersectionObserver anchor here would + // immediately fire a server-side /messages request before current events + // land — potentially causing a "/messages hangs → spinner stuck" scenario. + // Suppressing the anchor for this transient state is safe: the rangeAtEnd + // self-heal useEffect will call getInitialTimeline once events arrive, and + // at that point the correct anchor (below) will be re-observed. + // eventsLength>0 covers the range={K,K} case from recalibratePagination + // where items=0 but events exist — that needs the anchor for local range + // extension (no server call since start>0). + const placeholderBackAnchor = + eventsLength > 0 || !liveTimelineLinked ? observeBackAnchor : undefined; backPaginationJSX = messageLayout === MessageLayout.Compact ? ( <> @@ -2142,7 +2193,7 @@ export function RoomTimeline({ - + @@ -2154,7 +2205,7 @@ export function RoomTimeline({ - + diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index 6c5c1b270..07824c859 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -199,18 +199,9 @@ export function SyncDiagnostics() { Sliding proxy: {diagnostics.sliding.proxyBaseUrl} - Timeline limit: {diagnostics.sliding.timelineLimit} (page size:{' '} - {diagnostics.sliding.listPageSize}) - - - Adaptive timeline: {diagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'} - - - Device/network: saveData {diagnostics.sliding.device.saveData ? 'on' : 'off'} | - effectiveType {diagnostics.sliding.device.effectiveType ?? 'unknown'} | memory{' '} - {diagnostics.sliding.device.deviceMemoryGb ?? 'unknown'} GB | mobile{' '} - {diagnostics.sliding.device.mobile ? 'yes' : 'no'} | missing signals{' '} - {diagnostics.sliding.device.missingSignals} + Room timeline limit: {diagnostics.sliding.timelineLimit} (adaptive:{' '} + {diagnostics.sliding.adaptiveTimeline ? 'yes' : 'no'}) | page size:{' '} + {diagnostics.sliding.listPageSize} {diagnostics.sliding.lists.map((list) => ( diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts new file mode 100644 index 000000000..f138a6148 --- /dev/null +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; + +/** + * Subscribes the currently selected room to the sliding sync "active room" + * custom subscription (higher timeline limit) for the duration the room is open. + * + * Safe to call unconditionally — it is a no-op when classic sync is in use + * (i.e. when there is no SlidingSyncManager for the client). + */ +export const useSlidingSyncActiveRoom = (): void => { + const mx = useMatrixClient(); + const roomId = useSelectedRoom(); + + useEffect(() => { + if (!roomId) return undefined; + const manager = getSlidingSyncManager(mx); + if (!manager) return undefined; + + manager.subscribeToRoom(roomId); + return () => { + manager.unsubscribeFromRoom(roomId); + }; + }, [mx, roomId]); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f5a948013..580135e64 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -37,6 +37,7 @@ import { resolveNotificationPreviewText, } from '$utils/notificationStyle'; import { mobileOrTablet } from '$utils/user-agent'; +import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -369,7 +370,7 @@ function MessageNotifications() { if (document.visibilityState !== 'visible') return; // Page is visible — show the themed in-app notification banner. - if (showNotifications && (isHighlightByRule || loudByRule || isDM)) { + if (showNotifications && (isHighlightByRule || isLoud)) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const roomAvatar = avatarMxc @@ -569,6 +570,11 @@ function SyncNotificationSettingsWithServiceWorker() { return null; } +function SlidingSyncActiveRoomSubscriber() { + useSlidingSyncActiveRoom(); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { return ( <> @@ -580,6 +586,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} ); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 16835d4fe..47c8ba29d 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -23,6 +23,7 @@ import { SlidingSyncConfig, SlidingSyncDiagnostics, SlidingSyncManager } from '. const log = createLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); const FAST_SYNC_POLL_TIMEOUT_MS = 10000; +const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; type SyncTransport = 'classic' | 'sliding'; type SyncTransportReason = | 'sliding_active' @@ -340,6 +341,9 @@ export const stopClient = (mx: MatrixClient): void => { mx.stopClient(); }; +export const getSlidingSyncManager = (mx: MatrixClient): SlidingSyncManager | undefined => + slidingSyncByClient.get(mx); + export const startClient = async (mx: MatrixClient, config?: StartClientConfig) => { log.log('startClient', mx.getUserId()); disposeSlidingSync(mx); @@ -419,16 +423,11 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) } const resolvedProxyBaseUrl = proxyBaseUrl; - const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, { - ...(slidingConfig ?? {}), - includeInviteList: true, - pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? FAST_SYNC_POLL_TIMEOUT_MS, - }); - const supported = await SlidingSyncManager.probe( - mx, - resolvedProxyBaseUrl, - manager.probeTimeoutMs - ); + const probeTimeoutMs = (() => { + const v = slidingConfig?.probeTimeoutMs; + return typeof v === 'number' && !Number.isNaN(v) && v > 0 ? Math.round(v) : 5000; + })(); + const supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); log.log('startClient sliding probe result', { userId: mx.getUserId(), requestedEnabled: slidingRequested, @@ -442,7 +441,15 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) return; } + const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, { + ...(slidingConfig ?? {}), + includeInviteList: true, + pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, + }); manager.attach(); + // Begin background spidering so all rooms are eventually indexed. + // Not awaited — this runs incrementally in the background. + manager.startSpidering(100, 50); slidingSyncByClient.set(mx, manager); syncTransportByClient.set(mx, { transport: 'sliding', diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 0c8cf98e3..0029a63c9 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -1,29 +1,60 @@ +/* eslint-disable max-classes-per-file */ import { + ClientEvent, + Extension, + ExtensionState, MatrixClient, MSC3575List, MSC3575RoomSubscription, + MSC3575_WILDCARD, SlidingSync, SlidingSyncEvent, SlidingSyncState, MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_ME, EventType, + User, } from '$types/matrix-sdk'; -import { StateEvent } from '$types/matrix/room'; import { createLogger } from '$utils/debug'; const log = createLogger('slidingSync'); -const LIST_JOINED = 'joined'; -const LIST_INVITES = 'invites'; +export const LIST_JOINED = 'joined'; +export const LIST_INVITES = 'invites'; +export const LIST_DMS = 'dms'; +export const LIST_SEARCH = 'search'; +// Separate key for live room-name filtering; avoids conflicting with the spidering list. +export const LIST_ROOM_SEARCH = 'room_search'; +// Dynamic list key used for space-scoped room views. +export const LIST_SPACE = 'space'; +// One event of timeline per list room is enough to compute unread counts; +// the full history is loaded when the user opens the room. +const LIST_TIMELINE_LIMIT = 1; const DEFAULT_LIST_PAGE_SIZE = 250; -const DEFAULT_TIMELINE_LIMIT = 30; -const TIMELINE_LIMIT_LOW = 10; -const TIMELINE_LIMIT_MEDIUM = 15; -const TIMELINE_LIMIT_HIGH = 30; -const DEFAULT_POLL_TIMEOUT_MS = 10000; +const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; +// Sort order for MSC4186 (Simplified Sliding Sync): most recently active first, +// then alphabetical as a tiebreaker. by_notification_level is MSC3575-only and +// not supported by Synapse's native MSC4186 implementation. +const LIST_SORT_ORDER = ['by_recency', 'by_name']; + +// Subscription key for the room the user is actively viewing. +// Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members. +const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; +// Adaptive timeline limits for the room the user is actively viewing. +// Lower limits reduce initial bandwidth on constrained devices/connections; +// the user can always paginate further once the room is open. +const ACTIVE_ROOM_TIMELINE_LIMIT_LOW = 20; +const ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM = 35; +const ACTIVE_ROOM_TIMELINE_LIMIT_HIGH = 50; + +export type PartialSlidingSyncRequest = { + filters?: MSC3575List['filters']; + sort?: string[]; + ranges?: [number, number][]; +}; + export type SlidingSyncConfig = { enabled?: boolean; proxyBaseUrl?: string; @@ -42,20 +73,11 @@ export type SlidingSyncListDiagnostics = { rangeEnd: number; }; -export type SlidingSyncDeviceDiagnostics = { - saveData: boolean; - effectiveType: string | null; - deviceMemoryGb: number | null; - mobile: boolean; - missingSignals: number; -}; - export type SlidingSyncDiagnostics = { proxyBaseUrl: string; timelineLimit: number; - listPageSize: number; adaptiveTimeline: boolean; - device: SlidingSyncDeviceDiagnostics; + listPageSize: number; lists: SlidingSyncListDiagnostics[]; }; @@ -64,27 +86,30 @@ const clampPositive = (value: number | undefined, fallback: number): number => { return Math.round(value); }; -type AdaptiveSignals = SlidingSyncDeviceDiagnostics; +type AdaptiveSignals = { + saveData: boolean; + effectiveType: string | null; + deviceMemoryGb: number | null; + mobile: boolean; + missingSignals: number; +}; const readAdaptiveSignals = (): AdaptiveSignals => { const navigatorLike = typeof navigator !== 'undefined' ? navigator : undefined; const connection = (navigatorLike as any)?.connection; const effectiveType = connection?.effectiveType; const deviceMemory = (navigatorLike as any)?.deviceMemory; - const uaMobile = (navigatorLike as any)?.userAgentData?.mobile; const fallbackMobileUA = navigatorLike?.userAgent ?? ''; const mobileByUA = typeof uaMobile === 'boolean' ? uaMobile : /Mobi|Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(fallbackMobileUA); - const saveData = connection?.saveData === true; const normalizedEffectiveType = typeof effectiveType === 'string' ? effectiveType : null; const normalizedDeviceMemory = typeof deviceMemory === 'number' ? deviceMemory : null; const missingSignals = Number(normalizedEffectiveType === null) + Number(normalizedDeviceMemory === null); - return { saveData, effectiveType: normalizedEffectiveType, @@ -94,89 +119,117 @@ const readAdaptiveSignals = (): AdaptiveSignals => { }; }; -const resolveAdaptiveTimelineLimit = ( +// Resolve the timeline limit for the active-room subscription based on device/network. +// The list subscription always uses LIST_TIMELINE_LIMIT=1 regardless of conditions. +const resolveAdaptiveRoomTimelineLimit = ( configuredLimit: number | undefined, - pageSize: number, signals: AdaptiveSignals ): number => { if (typeof configuredLimit === 'number' && configuredLimit > 0) { - return clampPositive(configuredLimit, DEFAULT_TIMELINE_LIMIT); + return clampPositive(configuredLimit, ACTIVE_ROOM_TIMELINE_LIMIT_HIGH); } - if (signals.saveData || signals.effectiveType === 'slow-2g' || signals.effectiveType === '2g') { - return Math.min(pageSize, TIMELINE_LIMIT_LOW); + return ACTIVE_ROOM_TIMELINE_LIMIT_LOW; } - if ( signals.effectiveType === '3g' || (signals.deviceMemoryGb !== null && signals.deviceMemoryGb <= 4) ) { - return Math.min(pageSize, TIMELINE_LIMIT_MEDIUM); + return ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM; } - - // Mobile PWAs/browsers often omit NetworkInformation and/or device memory APIs. - // If any key adaptive signals are missing on mobile, pick a conservative medium limit. if (signals.mobile && signals.missingSignals > 0) { - return Math.min(pageSize, TIMELINE_LIMIT_MEDIUM); + return ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM; } - - return Math.min(pageSize, TIMELINE_LIMIT_HIGH); + return ACTIVE_ROOM_TIMELINE_LIMIT_HIGH; }; -const buildDefaultSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ +// Minimal required_state for list entries; enough to render the room list sidebar, +// compute unread state, and build the space hierarchy without fetching full room history. +// Notes: +// - RoomName/RoomCanonicalAlias are omitted: sliding sync returns the room name as a +// top-level field in every list response, so fetching them as state events is redundant. +// - MSC3575_STATE_KEY_LAZY is omitted: lazy-loading members is only needed when the +// user is actively viewing a room; loading them for every list entry wastes bandwidth. +// - SpaceChild with wildcard is required: the roomToParents atom reads m.space.child +// state events (one per child, keyed by child room ID) to build the space hierarchy. +// Without these events the SDK has no parent→child mapping, so all rooms appear as +// orphans in the Home view and spaces appear empty. +// - im.ponies.room_emotes with wildcard is required: custom emoji/sticker packs are +// stored as im.ponies.room_emotes state events (one per pack, keyed by pack state key). +// getGlobalImagePacks reads these from pack rooms listed in im.ponies.emote_rooms +// account data; imagePackRooms also reads them from parent spaces. Without these +// events all list-entry rooms would show no emoji or sticker packs. +// - m.room.topic is required: topics are displayed for joined child rooms in space +// lobby (RoomItem → LocalRoomSummaryLoader → useLocalRoomSummary) and in the +// invite list. Without this event the topic always shows as blank for non-active +// rooms. +// - m.room.canonical_alias is required: getCanonicalAlias() is used in several places +// for non-active rooms — notification serverName extraction, mention autocomplete +// alias display, and getCanonicalAliasOrRoomId for navigation. Without it, aliases +// fall back silently to room IDs. +const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [ + [EventType.RoomJoinRules, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomTombstone, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomCreate, ''], + [EventType.RoomTopic, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomMember, MSC3575_STATE_KEY_ME], + ['m.space.child', MSC3575_WILDCARD], + ['im.ponies.room_emotes', MSC3575_WILDCARD], +]; + +// For an active encrypted room: fetch everything so the client can decrypt all events. +const buildEncryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ + timeline_limit: timelineLimit, + required_state: [[MSC3575_WILDCARD, MSC3575_WILDCARD]], +}); + +// For an active unencrypted room: fetch everything, plus explicit lazy+ME members so +// the member list and display names are always available. +const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ timeline_limit: timelineLimit, required_state: [ + [MSC3575_WILDCARD, MSC3575_WILDCARD], [EventType.RoomMember, MSC3575_STATE_KEY_ME], [EventType.RoomMember, MSC3575_STATE_KEY_LAZY], - [EventType.RoomCreate, ''], - [EventType.RoomName, ''], - [EventType.RoomAvatar, ''], - [EventType.RoomCanonicalAlias, ''], - [EventType.RoomEncryption, ''], - [EventType.RoomTombstone, ''], - [EventType.RoomJoinRules, ''], - [EventType.RoomHistoryVisibility, ''], - [EventType.RoomPowerLevels, ''], - [StateEvent.PoniesRoomEmotes, '*'], - [StateEvent.RoomWidget, '*'], - [StateEvent.GroupCallPrefix, '*'], - [EventType.SpaceChild, '*'], - [EventType.SpaceParent, '*'], - [StateEvent.RoomCosmeticsColor, '*'], - [StateEvent.RoomCosmeticsFont, '*'], - [StateEvent.RoomCosmeticsPronouns, '*'], ], }); -const buildLists = ( - pageSize: number, - timelineLimit: number, - includeInviteList: boolean, - requiredState: MSC3575RoomSubscription['required_state'] -): Map => { +const buildLists = (pageSize: number, includeInviteList: boolean): Map => { const lists = new Map(); + const listRequiredState = buildListRequiredState(); + lists.set(LIST_JOINED, { ranges: [[0, Math.max(0, pageSize - 1)]], - timeline_limit: timelineLimit, - required_state: requiredState, + sort: LIST_SORT_ORDER, + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: listRequiredState, slow_get_all_rooms: true, - filters: { - is_invite: false, - }, + filters: { is_invite: false }, }); if (includeInviteList) { lists.set(LIST_INVITES, { ranges: [[0, Math.max(0, pageSize - 1)]], - timeline_limit: timelineLimit, - required_state: requiredState, + sort: LIST_SORT_ORDER, + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: listRequiredState, slow_get_all_rooms: true, - filters: { - is_invite: true, - }, + filters: { is_invite: true }, }); } + lists.set(LIST_DMS, { + ranges: [[0, Math.max(0, pageSize - 1)]], + sort: LIST_SORT_ORDER, + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: listRequiredState, + slow_get_all_rooms: true, + filters: { is_dm: true }, + }); + return lists; }; @@ -185,6 +238,48 @@ const getListEndIndex = (list: MSC3575List | null): number => { return list.ranges.reduce((max, range) => Math.max(max, range[1] ?? -1), -1); }; +// MSC4186 presence extension: requests `extensions.presence` in every sliding sync +// poll and feeds received `m.presence` events into the SDK's User objects so that +// components using `useUserPresence` see live updates (same path as regular /sync). +class ExtensionPresence implements Extension<{ enabled: boolean }, { events?: object[] }> { + public constructor(private readonly mx: MatrixClient) {} + + // eslint-disable-next-line class-methods-use-this + public name(): string { + return 'presence'; + } + + // eslint-disable-next-line class-methods-use-this + public when(): ExtensionState { + // Run after the main response body has been processed so room/member state is ready. + return ExtensionState.PostProcess; + } + + // eslint-disable-next-line class-methods-use-this + public async onRequest(): Promise<{ enabled: boolean }> { + return { enabled: true }; + } + + public async onResponse(data: { events?: object[] }): Promise { + if (!data?.events?.length) return; + const mapper = this.mx.getEventMapper(); + data.events.forEach((rawEvent) => { + const event = mapper(rawEvent as Parameters[0]); + const userId = event.getSender() ?? (event.getContent().user_id as string | undefined); + if (!userId) return; + let user = this.mx.store.getUser(userId); + if (user) { + user.setPresenceEvent(event); + } else { + user = User.createUser(userId, this.mx); + user.setPresenceEvent(event); + this.mx.store.storeUser(user); + } + this.mx.emit(ClientEvent.Event, event); + }); + } +} + export class SlidingSyncManager { private disposed = false; @@ -192,13 +287,13 @@ export class SlidingSyncManager { private readonly listKeys: string[]; - private timelineLimit: number; + private readonly activeRoomSubscriptions = new Set(); private readonly listPageSize: number; - private readonly adaptiveTimeline: boolean; + private roomTimelineLimit: number; - private deviceDiagnostics: SlidingSyncDeviceDiagnostics; + private readonly adaptiveTimeline: boolean; private readonly configuredTimelineLimit?: number; @@ -216,30 +311,36 @@ export class SlidingSyncManager { config: SlidingSyncConfig ) { const listPageSize = clampPositive(config.listPageSize, DEFAULT_LIST_PAGE_SIZE); - const adaptiveTimeline = !( - typeof config.timelineLimit === 'number' && config.timelineLimit > 0 - ); - const signals = readAdaptiveSignals(); - const timelineLimit = resolveAdaptiveTimelineLimit(config.timelineLimit, listPageSize, signals); const pollTimeoutMs = clampPositive(config.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS); this.probeTimeoutMs = clampPositive(config.probeTimeoutMs, 5000); this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS); - this.timelineLimit = timelineLimit; this.listPageSize = listPageSize; - this.adaptiveTimeline = adaptiveTimeline; - this.deviceDiagnostics = signals; - this.configuredTimelineLimit = config.timelineLimit; const includeInviteList = config.includeInviteList !== false; - const subscription = buildDefaultSubscription(timelineLimit); - const lists = buildLists( - listPageSize, - timelineLimit, - includeInviteList, - subscription.required_state + const adaptiveTimeline = !( + typeof config.timelineLimit === 'number' && config.timelineLimit > 0 ); + const signals = readAdaptiveSignals(); + const roomTimelineLimit = resolveAdaptiveRoomTimelineLimit(config.timelineLimit, signals); + this.adaptiveTimeline = adaptiveTimeline; + this.roomTimelineLimit = roomTimelineLimit; + this.configuredTimelineLimit = config.timelineLimit; + + const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); + const lists = buildLists(listPageSize, includeInviteList); this.listKeys = Array.from(lists.keys()); - this.slidingSync = new SlidingSync(proxyBaseUrl, lists, subscription, mx, pollTimeoutMs); + this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs); + + // Register the presence extension so m.presence events from the server are fed + // into the SDK's User objects, keeping useUserPresence accurate during sliding sync. + this.slidingSync.registerExtension(new ExtensionPresence(mx)); + + // Register a custom subscription for unencrypted active rooms; encrypted rooms use + // the default subscription (which already has [*,*]). + this.slidingSync.addCustomSubscription( + UNENCRYPTED_SUBSCRIPTION_KEY, + buildUnencryptedSubscription(roomTimelineLimit) + ); this.onLifecycle = (state, resp, err) => { if (this.disposed || err || !resp || state !== SlidingSyncState.Complete) return; @@ -248,18 +349,15 @@ export class SlidingSyncManager { this.onConnectionChange = () => { if (this.disposed || !this.adaptiveTimeline) return; - const currentSignals = readAdaptiveSignals(); - this.deviceDiagnostics = currentSignals; - const nextTimelineLimit = resolveAdaptiveTimelineLimit( + const nextLimit = resolveAdaptiveRoomTimelineLimit( this.configuredTimelineLimit, - this.listPageSize, - currentSignals + readAdaptiveSignals() ); - if (nextTimelineLimit === this.timelineLimit) return; - this.timelineLimit = nextTimelineLimit; - this.applyTimelineLimit(nextTimelineLimit); + if (nextLimit === this.roomTimelineLimit) return; + this.roomTimelineLimit = nextLimit; + this.applyRoomTimelineLimit(nextLimit); log.log( - `Sliding Sync adaptive timeline updated to ${nextTimelineLimit} for ${this.mx.getUserId()}` + `Sliding Sync adaptive room timeline updated to ${nextLimit} for ${this.mx.getUserId()}` ); }; } @@ -270,15 +368,13 @@ export class SlidingSyncManager { typeof navigator !== 'undefined' ? (navigator as any).connection : undefined ) as | { - addEventListener?: (event: string, cb: () => void) => void; - removeEventListener?: (event: string, cb: () => void) => void; + addEventListener?: (e: string, cb: () => void) => void; + removeEventListener?: (e: string, cb: () => void) => void; onchange?: (() => void) | null; } | undefined; connection?.addEventListener?.('change', this.onConnectionChange); - if (connection && connection.onchange === null) { - connection.onchange = this.onConnectionChange; - } + if (connection && connection.onchange === null) connection.onchange = this.onConnectionChange; if (typeof window !== 'undefined') { window.addEventListener('online', this.onConnectionChange); window.addEventListener('offline', this.onConnectionChange); @@ -293,28 +389,33 @@ export class SlidingSyncManager { typeof navigator !== 'undefined' ? (navigator as any).connection : undefined ) as | { - addEventListener?: (event: string, cb: () => void) => void; - removeEventListener?: (event: string, cb: () => void) => void; + addEventListener?: (e: string, cb: () => void) => void; + removeEventListener?: (e: string, cb: () => void) => void; onchange?: (() => void) | null; } | undefined; connection?.removeEventListener?.('change', this.onConnectionChange); - if (connection?.onchange === this.onConnectionChange) { - connection.onchange = null; - } + if (connection?.onchange === this.onConnectionChange) connection.onchange = null; if (typeof window !== 'undefined') { window.removeEventListener('online', this.onConnectionChange); window.removeEventListener('offline', this.onConnectionChange); } } + private applyRoomTimelineLimit(timelineLimit: number): void { + this.slidingSync.modifyRoomSubscriptionInfo(buildEncryptedSubscription(timelineLimit)); + this.slidingSync.addCustomSubscription( + UNENCRYPTED_SUBSCRIPTION_KEY, + buildUnencryptedSubscription(timelineLimit) + ); + } + public getDiagnostics(): SlidingSyncDiagnostics { return { proxyBaseUrl: this.proxyBaseUrl, - timelineLimit: this.timelineLimit, - listPageSize: this.listPageSize, + timelineLimit: this.roomTimelineLimit, adaptiveTimeline: this.adaptiveTimeline, - device: this.deviceDiagnostics, + listPageSize: this.listPageSize, lists: this.listKeys.map((key) => { const listData = this.slidingSync.getListData(key); const params = this.slidingSync.getListParams(key); @@ -347,18 +448,193 @@ export class SlidingSyncManager { }); } - private applyTimelineLimit(timelineLimit: number): void { - this.slidingSync.modifyRoomSubscriptionInfo(buildDefaultSubscription(timelineLimit)); - this.listKeys.forEach((key) => { - const existing = this.slidingSync.getListParams(key); - if (!existing) return; - this.slidingSync.setList(key, { - ...existing, - timeline_limit: timelineLimit, - }); + /** + * Ensure a dynamic list is registered (or updated) on the sliding sync session. + * If the list does not yet exist it is created with sensible defaults merged with + * `updateArgs`. If it already exists and the merged result differs, only the ranges + * are updated (cheaper — avoids resending sticky params) when `updateArgs` only + * contains `ranges`; otherwise the full list is replaced. + * + * This mirrors Element Web's `SlidingSyncManager.ensureListRegistered`. + */ + public ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): MSC3575List { + let list = this.slidingSync.getListParams(listKey); + if (!list) { + list = { + ranges: [[0, 20]], + sort: LIST_SORT_ORDER, + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: buildListRequiredState(), + ...updateArgs, + }; + } else { + const updated = { ...list, ...updateArgs }; + if (JSON.stringify(list) === JSON.stringify(updated)) return list; + list = updated; + } + + try { + if (updateArgs.ranges && Object.keys(updateArgs).length === 1) { + this.slidingSync.setListRanges(listKey, updateArgs.ranges); + } else { + this.slidingSync.setList(listKey, list); + } + } catch { + // ignore — the list will be re-sent on the next sync cycle + } + return this.slidingSync.getListParams(listKey) ?? list; + } + + /** + * Spider through all rooms by incrementally expanding the search list, matching + * Element Web's `startSpidering` behaviour. Called once after `attach()` and runs + * in the background; callers must not await it. + * + * The first request uses `setList` to register the list with its full config; + * subsequent page advances use the cheaper `setListRanges` (sticky params are + * not resent). A gap sleep is applied before the first request and after each + * subsequent one to avoid hammering the proxy at startup. + */ + public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise { + // Delay before the first request — startSpidering is called right after attach(), + // so give the initial sync a moment to settle first. + await new Promise((res) => { + setTimeout(res, gapBetweenRequestsMs); + }); + if (this.disposed) return; + + // Use a single expanding range [[0, endIndex]] rather than a two-range sliding + // window. Synapse's extension handler asserts len(actual_list.ops) == 1, which + // fails when the response contains multiple ops (one per range). A single range + // always produces a single SYNC op, avoiding the assertion. + let endIndex = batchSize - 1; + let hasMore = true; + let firstTime = true; + + const spideringRequiredState: MSC3575List['required_state'] = [ + [EventType.RoomJoinRules, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomTombstone, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomCreate, ''], + [EventType.RoomTopic, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomMember, MSC3575_STATE_KEY_ME], + ['m.space.child', MSC3575_WILDCARD], + ['im.ponies.room_emotes', MSC3575_WILDCARD], + ]; + + while (hasMore) { + if (this.disposed) return; + const ranges: [number, number][] = [[0, endIndex]]; + try { + if (firstTime) { + // Full setList on first call to register the list with all params. + this.slidingSync.setList(LIST_SEARCH, { + ranges, + sort: ['by_recency'], + timeline_limit: 0, + required_state: spideringRequiredState, + }); + } else { + // Cheaper range-only update for subsequent pages; sticky params are preserved. + this.slidingSync.setListRanges(LIST_SEARCH, ranges); + } + } catch { + // Swallow errors — the next iteration will retry with updated ranges. + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((res) => { + setTimeout(res, gapBetweenRequestsMs); + }); + } + + if (this.disposed) return; + const listData = this.slidingSync.getListData(LIST_SEARCH); + hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); + endIndex += batchSize; + firstTime = false; + } + log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); + } + + /** + * Enable or disable server-side room name filtering. + * When `query` is a non-empty string, registers (or updates) a dedicated + * `room_search` list that uses the MSC4186 `room_name_like` filter so the + * server returns only rooms whose name matches the query. When `query` is + * null or empty the list is reset to an unfiltered minimal range — callers + * should hide/ignore the list results in that case. + * This is a no-op after dispose(). + */ + public setRoomNameSearch(query: string | null): void { + if (this.disposed) return; + const trimmed = query?.trim() ?? ''; + const filters: MSC3575List['filters'] = trimmed ? { room_name_like: trimmed } : {}; + this.ensureListRegistered(LIST_ROOM_SEARCH, { + filters, + ranges: [[0, 19]], + sort: LIST_SORT_ORDER, + }); + } + + /** + * Activate or clear a space-scoped room list. + * When `spaceId` is provided, registers (or updates) a dedicated `space` + * list filtered to rooms that are children of that space, returning the + * first page sorted by recency. This supplements the main `joined` list + * rather than replacing it, so background sync of all rooms is unaffected. + * Pass `null` to deactivate the space list (collapses range to 0–0). + * This is a no-op after dispose(). + */ + public setSpaceScope(spaceId: string | null): void { + if (this.disposed) return; + const filters: MSC3575List['filters'] = spaceId + ? { is_invite: false, spaces: [spaceId] } + : { is_invite: false }; + this.ensureListRegistered(LIST_SPACE, { + filters, + ranges: spaceId ? [[0, Math.min(this.listPageSize - 1, 499)]] : [[0, 0]], + sort: LIST_SORT_ORDER, }); } + /** + * Subscribe to a room with the appropriate active-room subscription. + * Encrypted rooms use the default subscription ([*,*]); unencrypted rooms use a + * custom subscription that also requests lazy members. + * If the room is not yet known to the SDK (e.g. navigating directly to a room URL + * before the list has synced it), we default to the encrypted subscription — it is + * always safe to over-request state. + * Safe to call when already subscribed — the SDK deduplicates. + * This is a no-op after dispose(). + */ + public subscribeToRoom(roomId: string): void { + if (this.disposed) return; + const room = this.mx.getRoom(roomId); + if (room && !this.mx.isRoomEncrypted(roomId)) { + // Only use the unencrypted (lazy-load) subscription when we are certain + // the room is unencrypted. Unknown rooms fall through to the safer + // encrypted default. + this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY); + } + this.activeRoomSubscriptions.add(roomId); + this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + log.log(`Sliding Sync active room subscription added: ${roomId}`); + } + + /** + * Remove the explicit room subscription for a room. + * Rooms that are still in a list will continue to receive background updates. + * This is a no-op after dispose(). + */ + public unsubscribeFromRoom(roomId: string): void { + if (this.disposed) return; + this.activeRoomSubscriptions.delete(roomId); + this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + log.log(`Sliding Sync active room subscription removed: ${roomId}`); + } + public static async probe( mx: MatrixClient, proxyBaseUrl: string,