diff --git a/.changeset/fix-sliding-sync.md b/.changeset/fix-sliding-sync.md new file mode 100644 index 0000000000..bfcf936b5f --- /dev/null +++ b/.changeset/fix-sliding-sync.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Improved sliding sync (only requests specific room state data), cleaned up and fixed most flickering issues, and added buttons in developer tools to request full state. diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts index f97c0f9fca..db345edaab 100644 --- a/src/app/components/sidebar/Sidebar.css.ts +++ b/src/app/components/sidebar/Sidebar.css.ts @@ -132,7 +132,7 @@ export const SidebarItemBottom = recipe({ selectors: { '&:hover': { - transform: `translateY(${toRem(PUSH_Y)})`, + transform: `translateY(${toRem(-PUSH_Y)})`, }, '&::before': { content: '', diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 4b9972f04a..7bed396af6 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -11,7 +11,7 @@ import { Plus, X, } from '$components/icons/phosphor'; -import { EventType, NotificationCountType } from '$types/matrix-sdk'; +import { EventType, NotificationCountType, type MatrixEvent } from '$types/matrix-sdk'; import { Page, PageContent, PageHeader } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -20,6 +20,7 @@ import { settingsAtom } from '$state/settings'; import { copyToClipboard } from '$utils/dom'; import { getClientSyncDiagnostics } from '$client/initMatrix'; import { useRoom } from '$hooks/useRoom'; +import type { StateTypeToState } from '$hooks/useRoomState'; import { useRoomState } from '$hooks/useRoomState'; import { useRoomAccountData } from '$hooks/useRoomAccountData'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; @@ -35,16 +36,6 @@ import { SendRoomEvent } from './SendRoomEvent'; import type { StateEventInfo } from './StateEventEditor'; import { StateEventEditor } from './StateEventEditor'; -const formatSyncReason = (reason: string): string => { - if (reason === 'sliding_active') return 'Sliding Sync active'; - if (reason === 'sliding_disabled_server') return 'Server-side sliding sync disabled'; - if (reason === 'session_opt_out') return 'Session opt-in is off'; - if (reason === 'missing_proxy') return 'Sliding proxy URL missing'; - if (reason === 'cold_cache_bootstrap') return 'Cold-cache bootstrap (classic for this run)'; - if (reason === 'probe_failed_fallback') return 'Sliding probe failed, using fallback'; - return reason; -}; - type DeveloperToolsProps = { requestClose: () => void; }; @@ -52,8 +43,40 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const mx = useMatrixClient(); const room = useRoom(); - const roomState = useRoomState(room); + const roomStateMemory = useRoomState(room); const accountData = useRoomAccountData(room); + const [fullApiState, setFullApiState] = useState(); + const [fetchingApiState, setFetchingApiState] = useState(false); + const roomState = fullApiState ?? roomStateMemory; + + const handleFetchFullState = useCallback(async () => { + setFetchingApiState(true); + try { + const stateEvents = await mx.roomState(room.roomId); + const stateMap = new Map(); + for (const event of stateEvents) { + if (event.type === 'm.room.member') continue; + let kToE = stateMap.get(event.type); + if (!kToE) { + kToE = new Map(); + stateMap.set(event.type, kToE); + } + // Mock MatrixEvent structure enough for UI + kToE.set(event.state_key ?? '', { + event, + getType: () => event.type, + getContent: () => event.content, + getStateKey: () => event.state_key ?? '', + getSender: () => event.sender, + } as unknown as MatrixEvent); + } + setFullApiState(stateMap); + } catch (e) { + console.error('Failed to fetch full room state:', e); + } finally { + setFetchingApiState(false); + } + }, [mx, room.roomId]); const [expandState, setExpandState] = useState(false); const [expandUnreadDiagnostics, setExpandUnreadDiagnostics] = useState(false); @@ -391,29 +414,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { {expandSlidingDiagnostics && ( - - Transport: {syncDiagnostics.transport} - {syncDiagnostics.fallbackFromSliding ? ' (fallback)' : ''} - - - Sliding configured:{' '} - {syncDiagnostics.slidingConfigured ? 'yes' : 'no'} - - - Sliding server-enabled:{' '} - {syncDiagnostics.slidingEnabledOnServer ? 'yes' : 'no'} - - - Sliding session opt-in:{' '} - {syncDiagnostics.sessionOptIn ? 'yes' : 'no'} - - - Sliding requested:{' '} - {syncDiagnostics.slidingRequested ? 'yes' : 'no'} - - - Sync reason: {formatSyncReason(syncDiagnostics.reason)} - + Transport: {syncDiagnostics.transport} Client sync state: {syncDiagnostics.syncState ?? 'null'} @@ -434,9 +435,21 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { )} - - Events - Total: {roomState.size} + + Events (Total: {roomState.size}) + void; }; -export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) { +export function StateEventEditor({ + type, + stateKey, + rawEvent, + requestClose, +}: StateEventEditorProps) { const mx = useMatrixClient(); const room = useRoom(); const stateEvent = useStateEvent(room, type as keyof StateEvents, stateKey); @@ -252,9 +258,16 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi const canEdit = permissions.stateEvent(type, mx.getSafeUserId()); const eventJSONStr = useMemo(() => { - if (!stateEvent) return ''; - return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT); - }, [stateEvent]); + if (stateEvent) { + return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT); + } + if (rawEvent) { + return JSON.stringify(rawEvent, null, EDITOR_INTENT_SPACE_COUNT); + } + return ''; + }, [stateEvent, rawEvent]); + + const content = stateEvent?.getContent() ?? (rawEvent as { content?: object })?.content ?? {}; const handleCloseEdit = useCallback(() => { setEditContent(undefined); @@ -286,7 +299,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi /> ) : ( diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index 9c9a612e2e..bce39fe571 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -114,16 +114,6 @@ const formatListCoverage = (knownCount: number, rangeEnd: number): string => { return `${loadedCount}/${knownCount}`; }; -const formatSyncReason = (reason: string): string => { - if (reason === 'sliding_active') return 'Sliding Sync active'; - if (reason === 'sliding_disabled_server') return 'Server-side sliding sync disabled'; - if (reason === 'session_opt_out') return 'Session opt-in is off'; - if (reason === 'missing_proxy') return 'Sliding proxy URL missing'; - if (reason === 'cold_cache_bootstrap') return 'Cold-cache bootstrap (classic for this run)'; - if (reason === 'probe_failed_fallback') return 'Sliding probe failed, using fallback'; - return reason; -}; - export function SyncDiagnostics() { const mx = useMatrixClient(); const [, setTick] = useState(0); @@ -148,20 +138,8 @@ export function SyncDiagnostics() { gap="100" > - - Transport: {diagnostics.transport} - {diagnostics.fallbackFromSliding ? ' (fallback)' : ''} - + Transport: {diagnostics.transport} State: {diagnostics.syncState ?? 'null'} - - Sliding configured: {diagnostics.slidingConfigured ? 'yes' : 'no'} - - - Sliding server-enabled: {diagnostics.slidingEnabledOnServer ? 'yes' : 'no'} - - Sliding session opt-in: {diagnostics.sessionOptIn ? 'yes' : 'no'} - Sliding requested: {diagnostics.slidingRequested ? 'yes' : 'no'} - Sync reason: {formatSyncReason(diagnostics.reason)} Room counts: {roomDiagnostics.totalRooms} total, {roomDiagnostics.joinedRooms} joined,{' '} {roomDiagnostics.inviteRooms} invites diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index c86914d569..42481970f9 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -3,18 +3,6 @@ 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. - * - * Subscriptions are intentionally never removed on navigation — once a room - * has been opened it continues receiving background updates so that returning - * to it is instant. Explicit unsubscription (and timeline pruning) only happens - * when the user actually leaves the room via `unsubscribeFromRoom()`. - * - * 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(); @@ -25,6 +13,8 @@ export const useSlidingSyncActiveRoom = (): void => { if (!manager) return undefined; manager.subscribeToRoom(roomId); - return undefined; + return () => { + manager.unsubscribeFromRoom(roomId); + }; }, [mx, roomId]); }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index e45e32cd60..2c736d0ea9 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; +import { getPresenceSyncManager } from '$client/initMatrix'; import { MatrixEvent, MatrixEventEvent, @@ -53,7 +54,6 @@ import { import { mobileOrTablet } from '$utils/user-agent'; import { createDebugLogger } from '$utils/debugLogger'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; -import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; import { TelemetryConsentBanner } from '$components/telemetry-consent'; @@ -851,11 +851,10 @@ function PresenceFeature() { const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); useEffect(() => { - // Classic sync: set_presence query param on every /sync poll. + // Classic sync / MSC4186 presence: set_presence query param on every /sync poll. // Passing undefined restores the default (online); Offline suppresses broadcasting. mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); - // Sliding sync: enable/disable the presence extension on the next poll. - getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); + getPresenceSyncManager(mx)?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); return null; diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 99ca73d64e..98709ebed5 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -145,9 +145,9 @@ export function SidebarNav() { } sticky={ - {oldSidebar ? ( <> +
@@ -159,6 +159,7 @@ export function SidebarNav() { <> {isCollapsed && ( <> + diff --git a/src/app/pages/client/sidebar/UserQuickTools.tsx b/src/app/pages/client/sidebar/UserQuickTools.tsx index 783b0db2a7..ec5b1a0eca 100644 --- a/src/app/pages/client/sidebar/UserQuickTools.tsx +++ b/src/app/pages/client/sidebar/UserQuickTools.tsx @@ -3,6 +3,7 @@ import { AccountSwitcherTab } from './AccountSwitcherTab'; import { InboxTab } from './InboxTab'; import { SearchTab } from './SearchTab'; import { SettingsTab } from './SettingsTab'; +import { UnverifiedTab } from './UnverifiedTab'; import { useAtom } from 'jotai'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import * as css from './UserQuickTools.css'; @@ -46,6 +47,7 @@ export function UserQuickTools({ > {!isCollapsed && ( <> + diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 9e0496ee3a..26b72e8f01 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,12 +1,10 @@ -import type { CryptoCallbacks, MatrixClient, ISyncStateData } from '$types/matrix-sdk'; -import { - ClientEvent, - createClient, - Filter, - IndexedDBStore, - IndexedDBCryptoStore, - SyncState, +import type { + CryptoCallbacks, + MatrixClient, + MSC3575SlidingSyncRequest, + MSC3575SlidingSyncResponse, } from '$types/matrix-sdk'; +import { createClient, IndexedDBStore, IndexedDBCryptoStore } from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; import type { Session, Sessions, SessionStoreName } from '$state/sessions'; @@ -19,91 +17,83 @@ import { pushSessionToSW } from '../sw-session'; import { cryptoCallbacks } from './secretStorageKeys'; import type { SlidingSyncConfig, SlidingSyncDiagnostics } from './slidingSync'; import { SlidingSyncManager } from './slidingSync'; +import { PresenceSyncManager } from './presenceSync'; const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); -const classicSyncObserverByClient = new WeakMap< - MatrixClient, - (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void ->(); -const FAST_SYNC_POLL_TIMEOUT_MS = 30_000; +const presenceSyncByClient = new WeakMap(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; -type SyncTransport = 'classic' | 'sliding'; -type SyncTransportReason = - | 'sliding_active' - | 'sliding_disabled_server' - | 'session_opt_out' - | 'missing_proxy' - | 'cold_cache_bootstrap' - | 'probe_failed_fallback' - | 'unknown'; -type SyncTransportMeta = { - transport: SyncTransport; - slidingConfigured: boolean; - slidingEnabledOnServer: boolean; - sessionOptIn: boolean; - slidingRequested: boolean; - fallbackFromSliding: boolean; - reason: SyncTransportReason; -}; -const syncTransportByClient = new WeakMap(); -const fetchRoomEventStartupCleanupByClient = new WeakMap void>(); -const COLD_CACHE_BOOTSTRAP_TIMEOUT_MS = 20000; type FetchRoomEventResult = Awaited>; type MatrixClientWithWritableFetchRoomEvent = MatrixClient & { fetchRoomEvent: (roomId: string, eventId: string) => Promise; }; -type StartupFetchRoomEventPatchOptions = { - stubOnCacheMiss: boolean; +const fetchRoomEventStartupCleanupByClient = new WeakMap void>(); + +const slidingSyncConnIdCleanupByClient = new WeakMap void>(); + +type SlidingSyncMethod = ( + reqBody: MSC3575SlidingSyncRequest, + proxyBaseUrl?: string, + abortSignal?: AbortSignal +) => Promise; + +type MatrixClientWithWritableSlidingSync = MatrixClient & { + slidingSync: SlidingSyncMethod; }; +type SlidingSyncRequestWithConnId = MSC3575SlidingSyncRequest & { conn_id?: string }; + +const SLIDING_SYNC_CONN_ID = 'sable-main'; + +function installSlidingSyncConnId(mx: MatrixClient): void { + slidingSyncConnIdCleanupByClient.get(mx)?.(); + + const mxWritable = mx as MatrixClientWithWritableSlidingSync; + const original = mx.slidingSync.bind(mx) as SlidingSyncMethod; + + mxWritable.slidingSync = (reqBody, proxyBaseUrl, abortSignal) => { + const req = reqBody as SlidingSyncRequestWithConnId; + if (req.conn_id === undefined) { + req.conn_id = SLIDING_SYNC_CONN_ID; + } + return original(reqBody, proxyBaseUrl, abortSignal); + }; + + slidingSyncConnIdCleanupByClient.set(mx, () => { + slidingSyncConnIdCleanupByClient.delete(mx); + mxWritable.slidingSync = original; + }); +} + function installStartupFetchRoomEventPatch( mx: MatrixClient, - options: StartupFetchRoomEventPatchOptions + slidingSyncManager: SlidingSyncManager ): void { fetchRoomEventStartupCleanupByClient.get(mx)?.(); - const { stubOnCacheMiss } = options; const mxWritable = mx as MatrixClientWithWritableFetchRoomEvent; const origFetchRoomEvent = mx.fetchRoomEvent.bind(mx); - let restored = false; const restore = () => { - if (restored) return; - restored = true; fetchRoomEventStartupCleanupByClient.delete(mx); - // Put the real fetchRoomEvent back and detach this mxWritable.fetchRoomEvent = origFetchRoomEvent; - mx.off(ClientEvent.Sync, onSync); - }; - - const onSync = (state: SyncState) => { - // Initial sync burst is over, let normal server fetches run again - if (state === SyncState.Prepared || state === SyncState.Syncing) { - restore(); - } }; mxWritable.fetchRoomEvent = (roomId: string, eventId: string) => { - if (restored) return origFetchRoomEvent(roomId, eventId); - const cachedEvent = mx.getRoom(roomId)?.findEventById(eventId); - if (cachedEvent) { - return Promise.resolve(cachedEvent.event); - } - if (stubOnCacheMiss) { - const payload: FetchRoomEventResult = { - event_id: eventId, - room_id: roomId, - }; - return Promise.resolve(payload); + if (slidingSyncManager.isRoomActive(roomId)) { + return origFetchRoomEvent(roomId, eventId); } - return origFetchRoomEvent(roomId, eventId); + const cachedEvent = mx.getRoom(roomId)?.findEventById(eventId); + const payload: FetchRoomEventResult = cachedEvent?.event ?? { + event_id: eventId, + room_id: roomId, + }; + return Promise.resolve(payload); }; - mx.on(ClientEvent.Sync, onSync); fetchRoomEventStartupCleanupByClient.set(mx, restore); } @@ -196,18 +186,6 @@ const readStoredAccount = (dbName: string): Promise => }); }); -const databaseExists = async (dbName: string): Promise => { - try { - const dbs = await window.indexedDB.databases(); - return dbs.some((db) => db.name === dbName); - } catch { - return false; - } -}; - -const isClientReadyForUi = (syncState: string | null): boolean => - syncState === 'PREPARED' || syncState === 'SYNCING' || syncState === 'CATCHUP'; - const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); return ( @@ -218,57 +196,6 @@ const isMismatch = (err: unknown): boolean => { ); }; -const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise => - /* oxlint-disable promise/no-multiple-resolved */ - new Promise((resolve) => { - const waitStart = performance.now(); - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - mx.removeListener(ClientEvent.Sync, onSync); - clearTimeout(timer); - const waitMs = performance.now() - waitStart; - Sentry.metrics.distribution('sable.sync.client_ready_ms', waitMs, { - attributes: { timed_out: String(timedOut) }, - }); - if (timedOut) { - Sentry.addBreadcrumb({ - category: 'sync', - message: 'waitForClientReady timed out — client may be stuck', - level: 'warning', - data: { timeout_ms: timeoutMs }, - }); - } - resolve(); - }; - /* oxlint-enable promise/no-multiple-resolved */ - - if (isClientReadyForUi(mx.getSyncState())) { - Sentry.metrics.distribution('sable.sync.client_ready_ms', 0, { - attributes: { timed_out: 'false' }, - }); - finish(); - return; - } - - let timer = 0; - let timedOut = false; - const onSync = (state: string) => { - debugLog.info('sync', `Sync state changed: ${state}`, { - state, - ready: isClientReadyForUi(state), - }); - if (isClientReadyForUi(state)) finish(); - }; - - timer = window.setTimeout(() => { - timedOut = true; - finish(); - }, timeoutMs); - mx.on(ClientEvent.Sync, onSync); - }); - /** * Pre-flight check: scans every IndexedDB database and deletes any that * belong to a userId not present in the stored sessions list, or whose @@ -445,7 +372,8 @@ export type StartClientConfig = { timelineLimit?: number; }; -export type ClientSyncDiagnostics = SyncTransportMeta & { +export type ClientSyncDiagnostics = { + transport: 'sliding'; syncState: string | null; sliding?: SlidingSyncDiagnostics; }; @@ -457,268 +385,58 @@ const disposeSlidingSync = (mx: MatrixClient): void => { slidingSyncByClient.delete(mx); }; +const disposePresenceSync = (mx: MatrixClient): void => { + const manager = presenceSyncByClient.get(mx); + if (!manager) return; + manager.dispose(); + presenceSyncByClient.delete(mx); +}; + export const getSlidingSyncManager = (mx: MatrixClient): SlidingSyncManager | undefined => slidingSyncByClient.get(mx); +export const getPresenceSyncManager = (mx: MatrixClient): PresenceSyncManager | undefined => + presenceSyncByClient.get(mx); + export const startClient = async (mx: MatrixClient, config?: StartClientConfig): Promise => { debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() }); disposeSlidingSync(mx); + disposePresenceSync(mx); const slidingConfig = config?.slidingSync; - const slidingEnabledOnServer = resolveSlidingEnabled(slidingConfig?.enabled); - const slidingRequested = slidingEnabledOnServer && config?.sessionSlidingSyncOptIn === true; - const proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl; - const hasSlidingProxy = typeof proxyBaseUrl === 'string' && proxyBaseUrl.trim().length > 0; - log.log('startClient sliding config', { - userId: mx.getUserId(), - enabled: slidingConfig?.enabled, - enabledOnServer: slidingEnabledOnServer, - sessionOptIn: config?.sessionSlidingSyncOptIn === true, - requestedEnabled: slidingRequested, - proxyBaseUrl, - hasSlidingProxy, - }); - debugLog.info('sync', 'Sliding sync configuration', { - enabledOnServer: slidingEnabledOnServer, - requested: slidingRequested, - hasProxy: hasSlidingProxy, - }); - - const CLASSIC_SYNC_STARTUP_TIMEOUT_MS = 45_000; - - const startClassicSync = async ( - fallbackFromSliding: boolean, - reason: SyncTransportReason - ): Promise => { - syncTransportByClient.set(mx, { - transport: 'classic', - slidingConfigured: slidingEnabledOnServer, - slidingEnabledOnServer, - sessionOptIn: config?.sessionSlidingSyncOptIn === true, - slidingRequested, - fallbackFromSliding, - reason, - }); - Sentry.metrics.count('sable.sync.transport', 1, { - attributes: { - transport: 'classic', - reason, - fallback: String(fallbackFromSliding), - }, - }); - - const startupTimeout = new Promise((resolve) => { - window.setTimeout(() => { - debugLog.warn('sync', 'Classic sync startup timed out', { - userId: mx.getUserId(), - timeoutMs: CLASSIC_SYNC_STARTUP_TIMEOUT_MS, - }); - resolve(); - }, CLASSIC_SYNC_STARTUP_TIMEOUT_MS); - }); - - const effectivePollTimeout = config?.pollTimeoutMs ?? FAST_SYNC_POLL_TIMEOUT_MS; - const effectiveTimelineLimit = config?.timelineLimit ?? 10; - - const classicFilter = new Filter(mx.getUserId() ?? undefined); - classicFilter.setTimelineLimit(effectiveTimelineLimit); - // Ensure lazy loading stays on (carried by buildDefaultFilter but explicit here - // since we replace the filter entirely rather than merging). - const filterDefinition = classicFilter.getDefinition(); - if (filterDefinition.room) { - filterDefinition.room.timeline = filterDefinition.room.timeline ?? {}; - (filterDefinition.room.timeline as { lazy_load_members?: boolean }).lazy_load_members = true; - } + const proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl ?? mx.baseUrl; - installStartupFetchRoomEventPatch(mx, { stubOnCacheMiss: true }); - - let syncStarted: Promise; - try { - syncStarted = mx.startClient({ - lazyLoadMembers: true, - pollTimeout: effectivePollTimeout, - threadSupport: true, - filter: classicFilter, - }); - } catch (syncErr) { - fetchRoomEventStartupCleanupByClient.get(mx)?.(); - throw syncErr; - } - - await Promise.race([syncStarted, startupTimeout]); - // Attach an ongoing classic-sync observer — equivalent to SlidingSyncManager's - // onLifecycle listener. Tracks state transitions, initial-sync timing, and errors. - let classicSyncCount = 0; - const classicSyncStartMs = performance.now(); - let classicInitialSyncDone = false; - const classicSyncListener = ( - state: SyncState, - prevState: SyncState | null, - data?: ISyncStateData - ) => { - classicSyncCount += 1; - Sentry.metrics.count('sable.sync.cycle', 1, { - attributes: { transport: 'classic', state }, - }); - debugLog.info('sync', `Classic sync state: ${state}`, { - state, - prevState: prevState ?? 'null', - syncNumber: classicSyncCount, - error: data?.error?.message, - }); - if (state === SyncState.Error || state === SyncState.Reconnecting) { - debugLog.warn('sync', `Classic sync problem: ${state}`, { - state, - prevState: prevState ?? 'null', - errorMessage: data?.error?.message, - syncNumber: classicSyncCount, - }); - Sentry.metrics.count('sable.sync.error', 1, { - attributes: { transport: 'classic', state }, - }); - Sentry.addBreadcrumb({ - category: 'sync.classic', - message: `Classic sync problem: ${state}`, - level: 'warning', - data: { - state, - prevState, - error: data?.error?.message, - syncNumber: classicSyncCount, - }, - }); - } - if ( - !classicInitialSyncDone && - (state === SyncState.Syncing || state === SyncState.Prepared) - ) { - classicInitialSyncDone = true; - const elapsed = performance.now() - classicSyncStartMs; - debugLog.info('sync', 'Classic sync initial ready', { - state, - syncNumber: classicSyncCount, - elapsed: `${elapsed.toFixed(0)}ms`, - }); - Sentry.metrics.distribution('sable.sync.initial_ms', elapsed, { - attributes: { transport: 'classic' }, - }); - } - }; - classicSyncObserverByClient.set(mx, classicSyncListener); - mx.on(ClientEvent.Sync, classicSyncListener); - }; - - const shouldBootstrapClassicOnColdCache = async (): Promise => { - if (slidingConfig?.bootstrapClassicOnColdCache === false) return false; - const userId = mx.getUserId(); - if (!userId) return false; - - const [storeHasAccount, fallbackStoreHasAccount, hasStoreDb, hasFallbackStoreDb] = - await Promise.all([ - readStoredAccount(`sync${userId}`), - readStoredAccount('web-sync-store'), - databaseExists(`sync${userId}`), - databaseExists('web-sync-store'), - ]); - - const hasWarmCache = - storeHasAccount === userId || - fallbackStoreHasAccount === userId || - hasStoreDb || - hasFallbackStoreDb; - - return !hasWarmCache; - }; - - if (!slidingEnabledOnServer || !slidingRequested) { - await startClassicSync( - false, - slidingEnabledOnServer ? 'session_opt_out' : 'sliding_disabled_server' - ); - return; - } - - if (!hasSlidingProxy) { - await startClassicSync(false, 'missing_proxy'); - return; - } - - if (await shouldBootstrapClassicOnColdCache()) { - log.log('startClient cold-cache bootstrap: using classic sync for this run', mx.getUserId()); - await startClassicSync(false, 'cold_cache_bootstrap'); - waitForClientReady(mx, COLD_CACHE_BOOTSTRAP_TIMEOUT_MS).catch((err) => { - debugLog.warn('network', 'Cold cache bootstrap timed out', { - userId: mx.getUserId(), - timeout: `${COLD_CACHE_BOOTSTRAP_TIMEOUT_MS}ms`, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - - const resolvedProxyBaseUrl = proxyBaseUrl; - 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, - hasSlidingProxy, - proxyBaseUrl: resolvedProxyBaseUrl, - supported, - }); - if (!supported) { - log.warn('Sliding Sync unavailable, falling back to classic sync for', mx.getUserId()); - debugLog.warn('network', 'Sliding Sync probe failed, falling back to classic sync', { - userId: mx.getUserId(), - proxyBaseUrl: resolvedProxyBaseUrl, - probeTimeout: `${probeTimeoutMs}ms`, - }); - await startClassicSync(true, 'probe_failed_fallback'); - return; - } - - const manager = new SlidingSyncManager(mx, resolvedProxyBaseUrl, { + const manager = new SlidingSyncManager(mx, proxyBaseUrl, { ...slidingConfig, includeInviteList: true, pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, }); + + const presenceManager = new PresenceSyncManager(mx); + presenceSyncByClient.set(mx, presenceManager); + + presenceManager.start(); + + installStartupFetchRoomEventPatch(mx, manager); + installSlidingSyncConnId(mx); + manager.attach(); slidingSyncByClient.set(mx, manager); - syncTransportByClient.set(mx, { - transport: 'sliding', - slidingConfigured: true, - slidingEnabledOnServer, - sessionOptIn: config?.sessionSlidingSyncOptIn === true, - slidingRequested, - fallbackFromSliding: false, - reason: 'sliding_active', - }); - Sentry.metrics.count('sable.sync.transport', 1, { - attributes: { - transport: 'sliding', - reason: 'sliding_active', - fallback: 'false', - }, - }); try { - installStartupFetchRoomEventPatch(mx, { stubOnCacheMiss: false }); await mx.startClient({ lazyLoadMembers: true, slidingSync: manager.slidingSync, threadSupport: true, }); } catch (err) { - fetchRoomEventStartupCleanupByClient.get(mx)?.(); debugLog.error('network', 'Failed to start client with sliding sync', { error: err instanceof Error ? err.message : String(err), userId: mx.getUserId(), - proxyBaseUrl: resolvedProxyBaseUrl, + proxyBaseUrl: proxyBaseUrl, stack: err instanceof Error ? err.stack : undefined, }); disposeSlidingSync(mx); + disposePresenceSync(mx); throw err; } }; @@ -726,15 +444,10 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): export const stopClient = (mx: MatrixClient): void => { log.log('stopClient', mx.getUserId()); debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() }); - fetchRoomEventStartupCleanupByClient.get(mx)?.(); + slidingSyncConnIdCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); - const classicSyncListener = classicSyncObserverByClient.get(mx); - if (classicSyncListener) { - mx.removeListener(ClientEvent.Sync, classicSyncListener); - classicSyncObserverByClient.delete(mx); - } + disposePresenceSync(mx); mx.stopClient(); - syncTransportByClient.delete(mx); }; export const clearCacheAndReload = async (mx: MatrixClient) => { @@ -746,17 +459,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { }; export const getClientSyncDiagnostics = (mx: MatrixClient): ClientSyncDiagnostics => { - const meta = syncTransportByClient.get(mx) ?? { - transport: 'classic', - slidingConfigured: false, - slidingEnabledOnServer: false, - sessionOptIn: false, - slidingRequested: false, - fallbackFromSliding: false, - reason: 'unknown', - }; return { - ...meta, + transport: 'sliding', syncState: mx.getSyncState(), sliding: slidingSyncByClient.get(mx)?.getDiagnostics(), }; diff --git a/src/client/presenceSync.ts b/src/client/presenceSync.ts new file mode 100644 index 0000000000..70e528ee99 --- /dev/null +++ b/src/client/presenceSync.ts @@ -0,0 +1,112 @@ +import type { MatrixClient } from '$types/matrix-sdk'; +import { ClientEvent, User, Method, Filter } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('presenceSync'); + +export class PresenceSyncManager { + private disposed = false; + + private enabled = true; + + private activeRequest: Promise | null = null; + + private syncToken: string | undefined; + + public constructor( + private readonly mx: MatrixClient, + private readonly pollTimeoutMs: number = 30000, + private readonly pollingIntervalMs: number = 20000 + ) { + debugLog.info('sync', 'PresenceSyncManager initialized'); + } + + public setPresenceEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + if (enabled && !this.activeRequest && !this.disposed) { + this.poll(); + } + } + + public start(): void { + if (this.disposed || this.activeRequest) return; + this.poll(); + } + + public dispose(): void { + this.disposed = true; + this.enabled = false; + } + + private async poll(): Promise { + if (!this.enabled || this.disposed) return; + + this.activeRequest = (async () => { + try { + const filter = new Filter(this.mx.getUserId()); + filter.setDefinition({ + room: { rooms: [] }, + account_data: { types: [] }, + presence: { types: ['m.presence'] }, + }); + + const filterId = await this.mx.getOrCreateFilter('presence_only', filter); + + const response = (await this.mx.http.authedRequest(Method.Get, '/sync', { + filter: filterId, + since: this.syncToken, + timeout: this.pollTimeoutMs, + set_presence: this.enabled ? 'online' : 'offline', + })) as Record; + + debugLog.info('sync', 'PresenceSync response', { + next_batch: response.next_batch, + presence: response.presence, + }); + + this.syncToken = response.next_batch as string | undefined; + + const presence = response.presence as { events?: unknown[] } | undefined; + + if (presence?.events && Array.isArray(presence.events)) { + const mapper = this.mx.getEventMapper(); + presence.events.forEach((rawEvent: unknown) => { + if ( + typeof rawEvent !== 'object' || + !rawEvent || + (rawEvent as Record).type !== 'm.presence' + ) + return; + const presenceEvent = mapper(rawEvent); + + const userId = + presenceEvent.getSender() ?? + (presenceEvent.getContent().user_id as string | undefined); + if (!userId) return; + + let user = this.mx.store.getUser(userId); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = User.createUser(userId, this.mx); + user.setPresenceEvent(presenceEvent); + this.mx.store.storeUser(user); + } + this.mx.emit(ClientEvent.Event, presenceEvent); + }); + } + } catch (err) { + debugLog.error('sync', 'Error during background presence sync', err); + } + })(); + + await this.activeRequest; + this.activeRequest = null; + + if (this.enabled && !this.disposed) { + // Sleep between polls to ensure it's very lightweight + setTimeout(() => this.poll(), this.pollingIntervalMs); + } + } +} diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 5e5e18ce10..4f8f5c80fe 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -1,18 +1,5 @@ /** - * Unit tests for SlidingSyncManager memory management: - * - * 1. dispose() — must call slidingSync.stop() to halt the polling loop and - * abort in-flight requests. Without this the SDK's Promise loop keeps - * running after the client is "stopped", leaking network traffic and - * event listeners. - * - * 2. onMembershipLeave — when the MatrixClient emits a RoomMemberEvent.Membership - * event indicating the local user left or was banned from a room that is - * actively subscribed, unsubscribeFromRoom() should be called automatically. - * - * Note: navigation between rooms does not call unsubscribeFromRoom — - * subscriptions accumulate across the session so returning to a room is - * instant (matching Element Web's model). + * Unit tests for SlidingSyncManager memory management */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -21,8 +8,7 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync'; // ── vi.hoisted mocks ───────────────────────────────────────────────────────── -// Must be defined via vi.hoisted so they're available before vi.mock runs -// (vi.mock calls are hoisted above all imports by vitest's transformer). +// Must be defined via vi.hoisted const mocks = vi.hoisted(() => ({ slidingSyncInstance: { on: vi.fn<() => void>(), @@ -55,8 +41,7 @@ vi.mock('@sentry/react', () => ({ })); // ── SlidingSync SDK mock ───────────────────────────────────────────────────── -// vi.fn() wrappers are arrow functions internally and cannot be called with `new`. -// A plain function constructor (returning an object) is the correct pattern. +// A plain function constructor is the correct pattern vi.mock('$types/matrix-sdk', async (importOriginal) => { const actual = await importOriginal>(); function MockSlidingSync() { diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index cea5b1f787..79bd8ebabb 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -1,5 +1,4 @@ import type { - Extension, MatrixClient, MSC3575List, MSC3575RoomData, @@ -7,8 +6,6 @@ import type { MSC3575SlidingSyncResponse, } from '$types/matrix-sdk'; import { - ClientEvent, - ExtensionState, KnownMembership, MSC3575_WILDCARD, RoomMemberEvent, @@ -18,48 +15,28 @@ import { MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_ME, EventType, - User, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import { CustomStateEvent } from '$types/matrix/room'; import * as Sentry from '@sentry/react'; const log = createLogger('slidingSync'); const debugLog = createDebugLogger('slidingSync'); -interface NetworkInformation { - effectiveType?: string; - downlink?: number; - addEventListener?: (event: string, callback: () => void) => void; - removeEventListener?: (event: string, callback: () => void) => void; - onchange?: (() => void) | null; -} - 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_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'; -// Timeline limit for the active-room subscription (full history load). -// List entries always use LIST_TIMELINE_LIMIT=1 for lightweight previews. const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { @@ -98,30 +75,6 @@ const clampPositive = (value: number | undefined, fallback: number): number => { return Math.round(value); }; -// 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, ''], @@ -132,34 +85,50 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomCanonicalAlias, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], ['m.space.child', MSC3575_WILDCARD], - ['im.ponies.room_emotes', MSC3575_WILDCARD], - ['moe.sable.room.abbreviations', ''], + [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], + [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], + [CustomStateEvent.RoomAbbreviations, ''], + [CustomStateEvent.RoomBanner, ''], +]; + +const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + [EventType.RoomPowerLevels, ''], + [EventType.RoomName, ''], + [EventType.RoomTopic, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomJoinRules, ''], + [EventType.RoomHistoryVisibility, ''], + [EventType.RoomGuestAccess, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomTombstone, ''], + [EventType.RoomCreate, ''], + [EventType.RoomPinnedEvents, ''], + [EventType.RoomServerAcl, ''], + [EventType.RoomThirdPartyInvite, MSC3575_WILDCARD], + [EventType.RoomMember, MSC3575_STATE_KEY_ME], + [EventType.RoomMember, MSC3575_STATE_KEY_LAZY], + ['m.space.child', MSC3575_WILDCARD], + ['m.space.parent', MSC3575_WILDCARD], + [EventType.GroupCallPrefix, ''], + [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], + ...Object.values(CustomStateEvent).map((type) => [type, MSC3575_WILDCARD] as [string, string]), ]; -// 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], - ], + required_state: ACTIVE_ROOM_REQUIRED_STATE, }); const buildLists = (pageSize: number, includeInviteList: boolean): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); - // Start with a reasonable initial range that will quickly expand to full list - // Since timeline_limit=1, loading many rooms is very cheap - // This prevents the white page issue from progressive loading delays const initialRange = Math.min(pageSize, 100); lists.set(LIST_JOINED, { @@ -167,7 +136,6 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map { 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[] }> { - private enabled = true; - - public constructor(private readonly mx: MatrixClient) {} - - public setEnabled(value: boolean): void { - this.enabled = value; - } - - public name(): string { - return 'presence'; - } - - public when(): ExtensionState { - // Run after the main response body has been processed so room/member state is ready. - return ExtensionState.PostProcess; - } - - public async onRequest(): Promise<{ enabled: boolean }> { - return { enabled: this.enabled }; - } - - 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; @@ -257,17 +170,17 @@ export class SlidingSyncManager { private readonly roomTimelineLimit: number; - private readonly onConnectionChange: () => void; - - private readonly onLifecycle: (state: SlidingSyncState, resp: unknown, err?: Error) => void; + private readonly onLifecycle: ( + state: SlidingSyncState, + resp: MSC3575SlidingSyncResponse | null, + err?: Error + ) => void; private readonly onMembershipLeave: ( event: unknown, member: { userId: string; roomId: string; membership?: string } ) => void; - private presenceExtension!: ExtensionPresence; - private listsFullyLoaded = false; private initialSyncCompleted = false; @@ -316,13 +229,6 @@ export class SlidingSyncManager { this.listKeys = Array.from(lists.keys()); 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.presenceExtension = new ExtensionPresence(mx); - this.slidingSync.registerExtension(this.presenceExtension); - - // 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) @@ -359,30 +265,8 @@ export class SlidingSyncManager { return; } - // Before room data is processed, reset live timelines for active rooms that - // are receiving a full refresh (initial: true) or a post-gap update - // (limited: true). The SDK deliberately does not call resetLiveTimeline() for - // sliding sync, so events from previous visits accumulate in the live - // timeline alongside new events. Resetting here — before the SDK's - // onRoomData listener runs — ensures the fresh batch lands on a clean - // timeline with a correct backward pagination token. - if (state === SlidingSyncState.RequestFinished && resp && !err) { - const rooms = (resp as MSC3575SlidingSyncResponse).rooms ?? {}; - Object.entries(rooms) - .filter(([, roomData]) => roomData.initial || roomData.limited) - .filter(([roomId]) => this.activeRoomSubscriptions.has(roomId)) - .forEach(([roomId]) => { - const room = this.mx.getRoom(roomId); - if (!room) return; - const timelineSet = room.getUnfilteredTimelineSet(); - if (timelineSet.getLiveTimeline().getEvents().length === 0) return; - timelineSet.resetLiveTimeline(); - }); - } - if (err || !resp || state !== SlidingSyncState.Complete) return; - // Track what changed in this sync cycle const changes: Record = {}; let totalRoomCount = 0; let hasChanges = false; @@ -416,10 +300,8 @@ export class SlidingSyncManager { const syncDuration = performance.now() - syncStartTime; - // Mark initial sync as complete after first successful cycle if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; - // Wall-clock ms from attach() — the actual user-perceived wait for first data. const initialElapsed = this.attachTime != null ? performance.now() - this.attachTime : syncDuration; debugLog.info('sync', 'Initial sync completed', { @@ -462,32 +344,6 @@ export class SlidingSyncManager { if (!this.activeRoomSubscriptions.has(member.roomId)) return; this.unsubscribeFromRoom(member.roomId); }; - - this.onConnectionChange = () => { - const isOnline = navigator.onLine; - const connectionInfo = - typeof navigator !== 'undefined' - ? (navigator as unknown as { connection?: NetworkInformation }).connection - : undefined; - const effectiveType = connectionInfo?.effectiveType; - const downlink = connectionInfo?.downlink; - - debugLog.info('network', `Network connectivity changed: ${isOnline ? 'online' : 'offline'}`, { - online: isOnline, - effectiveType, - downlink: downlink ? `${downlink} Mbps` : undefined, - }); - - if (!isOnline) { - debugLog.warn('network', 'Device went offline - sync paused', { - syncNumber: this.syncCount, - }); - } else { - debugLog.info('network', 'Device back online - sync will resume', { - syncNumber: this.syncCount, - }); - } - }; } public attach(): void { @@ -508,29 +364,8 @@ export class SlidingSyncManager { this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); this.mx.on(RoomMemberEvent.Membership, this.onMembershipLeave); - const connection = ( - typeof navigator !== 'undefined' - ? (navigator as unknown as { connection?: NetworkInformation }).connection - : undefined - ) as - | { - addEventListener?: (e: string, cb: () => void) => void; - removeEventListener?: (e: string, cb: () => void) => void; - onchange?: (() => void) | null; - } - | undefined; - connection?.addEventListener?.('change', this.onConnectionChange); - // oxlint-disable-next-line unicorn/prefer-add-event-listener - if (connection && connection.onchange === null) connection.onchange = this.onConnectionChange; - if (typeof window !== 'undefined') { - window.addEventListener('online', this.onConnectionChange); - window.addEventListener('offline', this.onConnectionChange); - } - debugLog.info('sync', 'Sliding sync listeners attached successfully', { - hasConnectionAPI: !!connection, - hasWindowEvents: typeof window !== 'undefined', - }); + debugLog.info('sync', 'Sliding sync listeners attached successfully'); } public dispose(): void { @@ -541,43 +376,18 @@ export class SlidingSyncManager { initialSyncCompleted: this.initialSyncCompleted, }); - // Clean up pending room-data latency listeners before marking disposed. - // SlidingSync.stop() will removeAllListeners anyway, but this keeps the Map tidy. this.pendingRoomDataListeners.clear(); this.disposed = true; - // Stop the SDK's internal polling loop and abort any in-flight requests. this.slidingSync.stop(); this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); this.mx.removeListener(RoomMemberEvent.Membership, this.onMembershipLeave); - const connection = ( - typeof navigator !== 'undefined' - ? (navigator as unknown as { connection?: NetworkInformation }).connection - : undefined - ) as - | { - addEventListener?: (e: string, cb: () => void) => void; - removeEventListener?: (e: string, cb: () => void) => void; - onchange?: (() => void) | null; - } - | undefined; - connection?.removeEventListener?.('change', this.onConnectionChange); - // oxlint-disable-next-line unicorn/prefer-add-event-listener - if (connection?.onchange === this.onConnectionChange) connection.onchange = null; - if (typeof window !== 'undefined') { - window.removeEventListener('online', this.onConnectionChange); - window.removeEventListener('offline', this.onConnectionChange); - } debugLog.info('sync', 'Sliding sync disposed successfully', { totalSyncCycles: this.syncCount, }); } - public setPresenceEnabled(enabled: boolean): void { - this.presenceExtension.setEnabled(enabled); - } - public getDiagnostics(): SlidingSyncDiagnostics { return { proxyBaseUrl: this.proxyBaseUrl, @@ -596,7 +406,6 @@ export class SlidingSyncManager { } private expandListsToKnownCount(): void { - // Stop expanding once we've loaded all rooms - prevents continuous updates if (this.listsFullyLoaded) return; let allListsComplete = true; @@ -627,23 +436,16 @@ export class SlidingSyncManager { const existing = this.slidingSync.getListParams(key); const currentEnd = getListEndIndex(existing); - // Calculate how many rooms we still need to load const maxEnd = Math.min(knownCount, this.maxRooms) - 1; if (currentEnd >= maxEnd) { - // This list is fully loaded expansionDetails[key] = { status: 'complete', knownCount, currentEnd }; return; } allListsComplete = false; - // Progressive expansion: load in moderate chunks to balance speed with stability - // Chunk size reduced to 100 to prevent timeline ordering issues when opening rooms - // while lists are still expanding. Rooms should get at least one clean sync from - // their list before the active subscription requests a high timeline limit. - const chunkSize = 100; - const desiredEnd = Math.min(currentEnd + chunkSize, maxEnd); + const desiredEnd = maxEnd; if (desiredEnd === currentEnd) { expansionDetails[key] = { @@ -690,7 +492,6 @@ export class SlidingSyncManager { const expansionDuration = performance.now() - expansionStartTime; const hasExpansions = Object.values(expansionDetails).some((d) => d.status === 'expanding'); - // Mark as fully loaded once all lists are complete if (allListsComplete) { this.listsFullyLoaded = true; log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); @@ -728,15 +529,6 @@ export class SlidingSyncManager { } } - /** - * 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) { @@ -760,7 +552,6 @@ export class SlidingSyncManager { this.slidingSync.setList(listKey, list); } } catch (error) { - // ignore — the list will be re-sent on the next sync cycle debugLog.warn('sync', `Failed to update list "${listKey}"`, { list: listKey, error: error instanceof Error ? error.message : String(error), @@ -770,104 +561,6 @@ export class SlidingSyncManager { 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; - let batchCount = 0; - - await Sentry.startSpan( - { - name: 'sync.spidering', - op: 'matrix.sync', - attributes: { 'sync.transport': 'sliding' }, - }, - async (span) => { - 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; - batchCount += 1; - 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 { - // oxlint-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; - } - const finalCount = this.slidingSync.getListData(LIST_SEARCH)?.joinedCount ?? 0; - span.setAttributes({ - 'spidering.batches': batchCount, - 'spidering.total_rooms': finalCount, - }); - 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() ?? ''; @@ -879,15 +572,6 @@ export class SlidingSyncManager { }); } - /** - * 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 @@ -900,24 +584,15 @@ export class SlidingSyncManager { }); } - /** - * 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 isRoomActive(roomId: string): boolean { + return this.activeRoomSubscriptions.has(roomId); + } + public subscribeToRoom(roomId: string): void { if (this.disposed) return; const room = this.mx.getRoom(roomId); const isEncrypted = this.mx.isRoomEncrypted(roomId); if (room && !isEncrypted) { - // 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); @@ -941,8 +616,6 @@ export class SlidingSyncManager { activeSubscriptions: this.activeRoomSubscriptions.size, }, }); - // One-shot listener: measure latency from subscription request to first room data. - // Clean up any stale listener for the same roomId first. const existingListener = this.pendingRoomDataListeners.get(roomId); if (existingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, existingListener); @@ -951,8 +624,6 @@ export class SlidingSyncManager { const onFirstRoomData = (dataRoomId: string) => { if (dataRoomId !== roomId) return; const latencyMs = Math.round(performance.now() - subscribeMs); - // Measure how many events landed on the live timeline as part of this - // subscription activation — this is the "page" the timeline has to absorb. const subscribedRoom = this.mx.getRoom(roomId); const eventCount = subscribedRoom?.getLiveTimeline().getEvents().length ?? 0; debugLog.info('sync', 'Room subscription: first data received (sliding)', { @@ -979,14 +650,8 @@ export class SlidingSyncManager { this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); } - /** - * 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; - // Clean up any pending first-data latency listener for this room. const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); @@ -1003,39 +668,4 @@ export class SlidingSyncManager { syncCycle: this.syncCount, }); } - - public static async probe( - mx: MatrixClient, - proxyBaseUrl: string, - probeTimeoutMs: number - ): Promise { - return Sentry.startSpan( - { name: 'sync.probe', op: 'matrix.sync', attributes: { 'sync.proxy': proxyBaseUrl } }, - async (span) => { - try { - const response = await mx.slidingSync( - { - lists: { - probe: { - ranges: [[0, 0]], - timeline_limit: 1, - required_state: [], - }, - }, - timeout: 0, - clientTimeout: probeTimeoutMs, - }, - proxyBaseUrl - ); - - const supported = typeof response.pos === 'string' && response.pos.length > 0; - span.setAttribute('probe.supported', supported); - return supported; - } catch { - span.setAttribute('probe.supported', false); - return false; - } - } - ); - } }