diff --git a/config.json b/config.json index 77be1ad3ca..0002880c9b 100644 --- a/config.json +++ b/config.json @@ -16,10 +16,6 @@ "themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/", "themeCatalogApprovedHostPrefixes": ["https://raw.githubusercontent.com/SableClient/themes/"], - "slidingSync": { - "enabled": true - }, - "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/index.html b/index.html index 5cca4f0388..4e73dec7cd 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,24 @@ + + diff --git a/src/app/components/ClientConfigLoader.test.tsx b/src/app/components/ClientConfigLoader.test.tsx deleted file mode 100644 index b80e408847..0000000000 --- a/src/app/components/ClientConfigLoader.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Integration tests: exercises the full config-load → setMatrixToBase → URL - * generation pipeline that App.tsx runs on startup. - * - * The pattern under test mirrors App.tsx: - * - * {(config) => { setMatrixToBase(config.matrixToBaseUrl); ... }} - * - * - * We mock fetch so we don't need a real config.json or a live matrix.to instance. - */ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import { setMatrixToBase, getMatrixToRoom, getMatrixToUser } from '$plugins/matrix-to'; -import { ClientConfigLoader } from './ClientConfigLoader'; - -afterEach(() => { - setMatrixToBase(); // reset module state to 'https://matrix.to' - vi.unstubAllGlobals(); -}); - -const mockFetch = (config: object) => - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve(config) })); - -describe('ClientConfigLoader + matrix-to wiring', () => { - it('generates a standard matrix.to URL when no custom base is configured', async () => { - mockFetch({}); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent('https://matrix.to/#/!room:example.com') - ); - }); - - it('generates a custom-base URL for rooms when matrixToBaseUrl is set', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent( - 'https://custom.example.org/#/!room:example.com' - ) - ); - }); - - it('generates a custom-base URL for users when matrixToBaseUrl is set', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToUser('@alice:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('user')).toHaveTextContent( - 'https://custom.example.org/#/@alice:example.com' - ) - ); - }); - - it('strips a trailing slash from matrixToBaseUrl', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org/' }); - - render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} - - ); - - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent( - 'https://custom.example.org/#/!room:example.com' - ) - ); - }); -}); diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx deleted file mode 100644 index f03b8398e0..0000000000 --- a/src/app/components/ClientConfigLoader.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { ReactNode } from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; -import type { ClientConfig } from '$hooks/useClientConfig'; -import { trimTrailingSlash } from '$utils/common'; - -const getClientConfig = async (): Promise => { - const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; - const config = await fetch(url, { method: 'GET' }); - return config.json(); -}; - -type ClientConfigLoaderProps = { - fallback?: () => ReactNode; - error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode; - children: (config: ClientConfig) => ReactNode; -}; -export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) { - const [state, load] = useAsyncCallback(getClientConfig); - const [ignoreError, setIgnoreError] = useState(false); - - const ignoreCallback = useCallback(() => setIgnoreError(true), []); - - useEffect(() => { - load(); - }, [load]); - - if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) { - return fallback?.(); - } - - if (!ignoreError && state.status === AsyncStatus.Error) { - return error?.(state.error, load, ignoreCallback); - } - - const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {}; - - return children(config); -} diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx index 0a92aa2a33..fe01d4b0b7 100644 --- a/src/app/components/SpecVersionsLoader.tsx +++ b/src/app/components/SpecVersionsLoader.tsx @@ -32,7 +32,7 @@ export function SpecVersionsLoader({ } if (!ignoreError && state.status === AsyncStatus.Error) { - return error?.(state.error, load, ignoreCallback); + if (error) return error(state.error, load, ignoreCallback); } return children( diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 422e538540..f710eacebc 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -422,7 +422,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { {syncDiagnostics.sliding ? ( <> - Proxy: {syncDiagnostics.sliding.proxyBaseUrl} + Base URL: {syncDiagnostics.sliding.baseUrl} Room timeline: {syncDiagnostics.sliding.timelineLimit} | page diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index bce39fe571..4739dc2691 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -179,7 +179,7 @@ export function SyncDiagnostics() { {expandSliding && ( - Sliding proxy: {diagnostics.sliding.proxyBaseUrl} + Sliding sync base URL: {diagnostics.sliding.baseUrl} Room timeline limit: {diagnostics.sliding.timelineLimit} | page size:{' '} {diagnostics.sliding.listPageSize} diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 085355f40f..7cd673bc4f 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -7,7 +7,6 @@ import { useSetting } from '$state/hooks/settings'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import { SettingTile } from '$components/setting-tile'; import { SequenceCard } from '$components/sequence-card'; -import { Sync } from '../general'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; @@ -60,7 +59,6 @@ export function Experimental({ requestBack, requestClose }: Readonly
- diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index a3fef67ac7..dc6d1f4a65 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -49,8 +49,6 @@ import { useMessageSpacingItems } from '$hooks/useMessageSpacing'; import { useDateFormatItems } from '$hooks/useDateFormat'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { sessionsAtom, activeSessionIdAtom } from '$state/sessions'; -import { useClientConfig } from '$hooks/useClientConfig'; -import { resolveSlidingEnabled } from '$client/initMatrix'; import { isKeyHotkey } from 'is-hotkey'; import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; @@ -1380,14 +1378,17 @@ function Embeds() { ); } -export function Sync() { - const clientConfig = useClientConfig(); +type GeneralProps = { + requestBack?: () => void; + requestClose: () => void; +}; + +function Sync() { const sessions = useAtomValue(sessionsAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); const setSessions = useSetAtom(sessionsAtom); const activeSession = sessions.find((s) => s.userId === activeSessionId) ?? sessions[0]; - const serverSlidingEnabled = resolveSlidingEnabled(clientConfig.slidingSync?.enabled); const useSlidingSync = activeSession?.slidingSyncOptIn === true; const handleSetSlidingSync = (value: boolean) => { @@ -1402,76 +1403,6 @@ export function Sync() { window.location.reload(); }; - return ( - - - Sync - - - - Enable Sliding Sync for this current login/session. Requires server support and - admin configuration.{' '} - - More info/Documentation - - .{' '} - - Known issues (Sable GitHub) - - . - - ) : ( - <> - Unavailable: the server has disabled Sliding Sync in its config.{' '} - - More info - - . - - ) - } - after={ - - } - /> - - - ); -} - -type GeneralProps = { - requestBack?: () => void; - requestClose: () => void; -}; - -function SettingsSyncSection() { const [syncEnabled, setSyncEnabled] = useSetting(settingsAtom, 'settingsSyncEnabled'); const lastSynced = useAtomValue(settingsSyncLastSyncedAtom); const syncStatus = useAtomValue(settingsSyncStatusAtom); @@ -1500,7 +1431,34 @@ function SettingsSyncSection() { return ( - Settings Sync & Backup + Data Syncing + + + Enable Sliding Sync for this current login/session. Requires server support.{' '} + + Known issues (Sable GitHub) + + . + + } + after={ + + } + /> + } @@ -1669,7 +1627,7 @@ export function General({ requestBack, requestClose }: Readonly) { - + diff --git a/src/app/hooks/router/useResolvedRoomId.ts b/src/app/hooks/router/useResolvedRoomId.ts new file mode 100644 index 0000000000..43bffe3823 --- /dev/null +++ b/src/app/hooks/router/useResolvedRoomId.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { getCanonicalAliasRoomId, isRoomAlias } from '$utils/matrix'; +import { useMatrixClient } from '$hooks/useMatrixClient'; + +type AliasResolutionState = { + alias: string; + roomId?: string; +}; + +export type ResolvedRoomId = { + roomId?: string; + resolving: boolean; +}; + +/** Resolve aliases even when the room is not present in the local sync store yet. */ +export const useResolvedRoomIdOrAlias = (roomIdOrAlias?: string): ResolvedRoomId => { + const mx = useMatrixClient(); + const localRoomId = + roomIdOrAlias && isRoomAlias(roomIdOrAlias) + ? getCanonicalAliasRoomId(mx, roomIdOrAlias) + : roomIdOrAlias; + const [aliasResolution, setAliasResolution] = useState(); + + useEffect(() => { + if (!roomIdOrAlias || !isRoomAlias(roomIdOrAlias) || localRoomId) return undefined; + if (aliasResolution?.alias === roomIdOrAlias) return undefined; + + let cancelled = false; + void mx + .getRoomIdForAlias(roomIdOrAlias) + .then(({ room_id: resolvedRoomId }) => { + if (!cancelled) setAliasResolution({ alias: roomIdOrAlias, roomId: resolvedRoomId }); + }) + .catch(() => { + if (!cancelled) setAliasResolution({ alias: roomIdOrAlias }); + }); + + return () => { + cancelled = true; + }; + }, [aliasResolution?.alias, localRoomId, mx, roomIdOrAlias]); + + if (localRoomId) return { roomId: localRoomId, resolving: false }; + if (!roomIdOrAlias || !isRoomAlias(roomIdOrAlias)) { + return { roomId: roomIdOrAlias, resolving: false }; + } + if (aliasResolution?.alias === roomIdOrAlias) { + return { roomId: aliasResolution.roomId, resolving: false }; + } + return { resolving: true }; +}; + +export const useResolvedSelectedRoom = (): ResolvedRoomId => { + const { roomIdOrAlias: encodedRoomIdOrAlias } = useParams(); + const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); + return useResolvedRoomIdOrAlias(roomIdOrAlias); +}; + +export const useResolvedSelectedSpace = (): ResolvedRoomId => { + const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams(); + const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); + return useResolvedRoomIdOrAlias(spaceIdOrAlias); +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6850a7e774..9efc4bac00 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -27,18 +27,6 @@ export type ClientConfig = { webPushAppID?: string; }; - slidingSync?: { - enabled?: boolean; - proxyBaseUrl?: string; - bootstrapClassicOnColdCache?: boolean; - listPageSize?: number; - timelineLimit?: number; - pollTimeoutMs?: number; - maxRooms?: number; - includeInviteList?: boolean; - probeTimeoutMs?: number; - }; - featuredCommunities?: { openAsDefault?: boolean; spaces?: string[]; @@ -59,20 +47,28 @@ export type ClientConfig = { settingsDefaults?: Partial; }; -const ClientConfigContext = createContext(null); +const EMPTY_CONFIG: ClientConfig = {}; + +const ClientConfigContext = createContext(EMPTY_CONFIG); export const ClientConfigProvider = ClientConfigContext.Provider; export function useClientConfig(): ClientConfig { - const config = useContext(ClientConfigContext); - if (!config) throw new Error('Client config are not provided!'); - return config; + return useContext(ClientConfigContext); } -export function useOptionalClientConfig(): ClientConfig | null { +export function useOptionalClientConfig(): ClientConfig { return useContext(ClientConfigContext); } +const ClientConfigLoadedContext = createContext(false); + +export const ClientConfigLoadedProvider = ClientConfigLoadedContext.Provider; + +export function useClientConfigLoaded(): boolean { + return useContext(ClientConfigLoadedContext); +} + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index 63cfb0d73a..5cf32b6786 100644 --- a/src/app/hooks/useImagePacks.ts +++ b/src/app/hooks/useImagePacks.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ImagePack, ImageUsage } from '$plugins/custom-emoji'; import { getGlobalImagePacks, + getGlobalImagePackRoomIds, getRoomImagePack, getRoomImagePacks, getUserImagePack, @@ -15,6 +16,7 @@ import { writeCachedPack, writeCachedPacks, } from '$plugins/custom-emoji'; +import { getSlidingSyncManager } from '$client/initMatrix'; import { useMatrixClient } from './useMatrixClient'; import { useAccountDataCallback } from './useAccountDataCallback'; import { useStateEventCallback } from './useStateEventCallback'; @@ -101,6 +103,25 @@ export const useGlobalImagePacks = (): ImagePack[] => { return cachedPacks.length > 0 ? cachedPacks : livePacks; }); + useEffect(() => { + const manager = getSlidingSyncManager(mx); + if (!manager) return undefined; + + const refresh = () => { + setGlobalPacks((prev) => { + const next = getGlobalImagePacks(mx); + return imagePackListEqual(prev, next) ? prev : next; + }); + }; + + const listeners = getGlobalImagePackRoomIds(mx).map((roomId) => { + manager.subscribeToImagePackRoom(roomId); + return manager.onRoomData(roomId, refresh); + }); + + return () => listeners.forEach((unsubscribe) => unsubscribe()); + }, [mx]); + useEffect(() => { const userId = mx.getUserId(); if (userId) writeCachedPacks(userId, globalPacksScope(), globalPacks); @@ -114,6 +135,12 @@ export const useGlobalImagePacks = (): ImagePack[] => { mEvent.getType() === (CustomAccountDataEvent.ImagePackRooms as string) || mEvent.getType() === (CustomAccountDataEvent.PoniesEmoteRooms as string) ) { + const manager = getSlidingSyncManager(mx); + if (manager) { + getGlobalImagePackRoomIds(mx).forEach((roomId) => + manager.subscribeToImagePackRoom(roomId) + ); + } setGlobalPacks((prev) => { const next = getGlobalImagePacks(mx); return imagePackListEqual(prev, next) ? prev : next; diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index 42481970f9..321a3c2af9 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -1,20 +1,62 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getSlidingSyncManager } from '$client/initMatrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useResolvedSelectedRoom, useResolvedSelectedSpace } from '$hooks/router/useResolvedRoomId'; +import { useSpaces } from '$state/hooks/roomList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { ClientEvent } from '$types/matrix-sdk'; -export const useSlidingSyncActiveRoom = (): void => { +const useAvailableSlidingSyncManager = () => { const mx = useMatrixClient(); - const roomId = useSelectedRoom(); + const [manager, setManager] = useState(() => getSlidingSyncManager(mx)); useEffect(() => { - if (!roomId) return undefined; - const manager = getSlidingSyncManager(mx); - if (!manager) return undefined; + if (manager) return undefined; - manager.subscribeToRoom(roomId); + const checkForManager = () => { + const nextManager = getSlidingSyncManager(mx); + if (nextManager) setManager(nextManager); + }; + + const retryId = globalThis.setTimeout(checkForManager, 0); + mx.on(ClientEvent.Sync, checkForManager); return () => { - manager.unsubscribeFromRoom(roomId); + globalThis.clearTimeout(retryId); + mx.removeListener(ClientEvent.Sync, checkForManager); }; - }, [mx, roomId]); + }, [manager, mx]); + + return manager; +}; + +export const useSlidingSyncRouteRooms = (): void => { + const manager = useAvailableSlidingSyncManager(); + const { roomId, resolving: resolvingRoom } = useResolvedSelectedRoom(); + const { roomId: spaceId, resolving: resolvingSpace } = useResolvedSelectedSpace(); + + useEffect(() => { + if (!manager || resolvingRoom || resolvingSpace) return undefined; + + const activeRoomIds = [...new Set([spaceId, roomId].filter(Boolean))] as string[]; + activeRoomIds.forEach((activeRoomId) => manager.subscribeToRoom(activeRoomId)); + + return () => activeRoomIds.forEach((activeRoomId) => manager.unsubscribeFromRoom(activeRoomId)); + }, [manager, resolvingRoom, resolvingSpace, roomId, spaceId]); +}; + +export const useSlidingSyncSpaceSubscriptions = (): void => { + const manager = useAvailableSlidingSyncManager(); + const mx = useMatrixClient(); + const spaces = useSpaces(mx, allRoomsAtom); + + useEffect(() => { + if (!manager) return undefined; + manager.setSpaceSubscriptions(spaces); + return undefined; + }, [manager, spaces]); +}; + +export const useSlidingSyncActiveRoom = (): void => { + useSlidingSyncRouteRooms(); + useSlidingSyncSpaceSubscriptions(); }; diff --git a/src/app/hooks/useSpecVersions.ts b/src/app/hooks/useSpecVersions.ts index c971b7e49f..28c932a6f5 100644 --- a/src/app/hooks/useSpecVersions.ts +++ b/src/app/hooks/useSpecVersions.ts @@ -1,12 +1,12 @@ import { createContext, useContext } from 'react'; import type { SpecVersions } from '../cs-api'; -const SpecVersionsContext = createContext(null); +const EMPTY_VERSIONS: SpecVersions = { versions: [] }; + +const SpecVersionsContext = createContext(EMPTY_VERSIONS); export const SpecVersionsProvider = SpecVersionsContext.Provider; export function useSpecVersions(): SpecVersions { - const versions = useContext(SpecVersionsContext); - if (!versions) throw new Error('Server versions are not provided!'); - return versions; + return useContext(SpecVersionsContext); } diff --git a/src/app/hooks/useSyncState.ts b/src/app/hooks/useSyncState.ts index 61d48ed40c..b7cc987830 100644 --- a/src/app/hooks/useSyncState.ts +++ b/src/app/hooks/useSyncState.ts @@ -7,7 +7,13 @@ export const useSyncState = ( onChange: ClientEventHandlerMap[ClientEvent.Sync] ): void => { useEffect(() => { - mx?.on(ClientEvent.Sync, onChange); + if (!mx) return undefined; + + mx.on(ClientEvent.Sync, onChange); + + const currentState = mx.getSyncState(); + if (currentState) onChange(currentState, null); + return () => { mx?.removeListener(ClientEvent.Sync, onChange); }; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 95cb850010..e9275fd8b7 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useRef } from 'react'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { createStore } from 'jotai/vanilla'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; @@ -6,19 +6,21 @@ import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; -import { ClientConfigLoader } from '$components/ClientConfigLoader'; import type { ClientConfig } from '$hooks/useClientConfig'; -import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { ClientConfigLoadedProvider, ClientConfigProvider } from '$hooks/useClientConfig'; import { setMatrixToBase } from '$plugins/matrix-to'; import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize'; import { useCompositionEndTracking } from '$hooks/useComposingCheck'; import { ErrorPage } from '$components/DefaultErrorPage'; -import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { isReactQueryDevtoolsEnabled } from './reactQueryDevtoolsGate'; import { bootstrapSettingsStore } from '$state/settings'; +import { trimTrailingSlash } from '$utils/common'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('App'); const queryClient = new QueryClient(); const ReactQueryDevtools = lazy(async () => { @@ -41,18 +43,16 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); return ( - - - - - - {reactQueryDevtoolsEnabled && ( - - - - )} - - + + + + + {reactQueryDevtoolsEnabled && ( + + + + )} + ); } @@ -65,17 +65,36 @@ function renderSentryErrorFallback({ error, eventId }: { error: unknown; eventId ); } -function appConfigFallback() { - return ; -} +function useClientConfigLoader(): { config: ClientConfig; loaded: boolean } { + const [config, setConfig] = useState({}); + const [loaded, setLoaded] = useState(false); -function appConfigError(err: unknown, retry: () => void, ignore: () => void) { - return ; -} + useEffect(() => { + let cancelled = false; -function AppConfigLoaded({ clientConfig, screenSize }: BootstrappedAppShellProps) { - setMatrixToBase(clientConfig.matrixToBaseUrl); - return ; + const loadConfig = async () => { + try { + const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; + const res = await fetch(url, { method: 'GET' }); + const data = (await res.json()) as ClientConfig; + if (cancelled) return; + setConfig(data); + setMatrixToBase(data.matrixToBaseUrl); + setLoaded(true); + } catch (err) { + log.error('Failed to load config.json, continuing with empty config:', err); + if (cancelled) return; + setLoaded(true); + } + }; + + void loadConfig(); + return () => { + cancelled = true; + }; + }, []); + + return { config, loaded }; } function App() { @@ -83,12 +102,7 @@ function App() { useCompositionEndTracking(); const portalContainer = document.getElementById('portalContainer') ?? undefined; - const renderAppConfig = useCallback( - (clientConfig: ClientConfig) => ( - - ), - [screenSize] - ); + const { config: clientConfig, loaded: configLoaded } = useClientConfigLoader(); return ( @@ -97,9 +111,11 @@ function App() { - - {renderAppConfig} - + + + + + diff --git a/src/app/pages/ConfigConfig.tsx b/src/app/pages/ConfigConfig.tsx deleted file mode 100644 index 2c2df830cd..0000000000 --- a/src/app/pages/ConfigConfig.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Box, Button, Dialog, Spinner, Text, color, config } from 'folds'; -import { SplashScreen } from '$components/splash-screen'; - -export function ConfigConfigLoading() { - return ( - - - - Petting cats - - - ); -} - -type ConfigConfigErrorProps = { - error: unknown; - retry: () => void; - ignore: () => void; -}; -export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) { - return ( - - - - - - Failed to load client configuration file. - {typeof error === 'object' && - error && - 'message' in error && - typeof error.message === 'string' && ( - - {error.message} - - )} - - - - - - - - ); -} diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index f681e31f75..5c18a386d2 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -13,6 +13,7 @@ import { OverlayCenter, PopOut, Spinner, + Switch, Text, config, } from 'folds'; @@ -120,7 +121,12 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Parameters >(useCallback(login, [])); - useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); + const [useSlidingSync, setUseSlidingSync] = useState(false); + + useLoginComplete( + loginState.status === AsyncStatus.Success ? loginState.data : undefined, + useSlidingSync + ); const handleUsernameLogin = (username: string, password: string) => { startLogin(baseUrl, { @@ -265,6 +271,18 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
+ + + + Use sliding sync (faster, but buggier) + + + - - - - - )} - {loading || !mx ? ( - - ) : ( - - - {(serverConfigs) => ( - - - - {children} - - - - )} - - - )} - + {mx && } + {(!mx || isError) && } + {isError && ( + + + + + {loadState.status === AsyncStatus.Error && ( + {`Failed to load. ${loadState.error.message}`} + )} + {startState.status === AsyncStatus.Error && ( + {`Failed to start. ${startState.error.message}`} + )} + + + + + + )} + {!mx ? ( + + ) : isError ? null : ( + + {!syncReadyClient ? ( + + ) : ( + + + {(serverConfigs) => ( + + + + {children} + + + + )} + + + )} + + )} ); } diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx index b25951f91e..dbda208c32 100644 --- a/src/app/pages/client/SpecVersions.tsx +++ b/src/app/pages/client/SpecVersions.tsx @@ -1,39 +1,33 @@ import type { ReactNode } from 'react'; import { useCallback } from 'react'; -import { Box, Dialog, config, Text, Button, Spinner } from 'folds'; +import { Box, Button, Dialog, config, Text } from 'folds'; import { SpecVersionsLoader } from '$components/SpecVersionsLoader'; import { SpecVersionsProvider } from '$hooks/useSpecVersions'; -import type { SpecVersions } from '../../cs-api'; import { SplashScreen } from '$components/splash-screen'; +import type { SpecVersions } from '../../cs-api'; -function specVersionsFallback() { - return ( - - - - Connecting to server - - - ); -} +const EMPTY_VERSIONS: SpecVersions = { versions: [] }; -function specVersionsError(_err: unknown, retry: () => void, ignore: () => void) { +type HomeserverOfflineErrorProps = { + baseUrl: string; + onRetry: () => void; +}; +function HomeserverOfflineError({ baseUrl, onRetry }: HomeserverOfflineErrorProps) { return ( - - Failed to connect to homeserver. Either homeserver is down or your internet. - - - @@ -51,8 +45,20 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: [children] ); + const renderFallback = useCallback( + () => {children}, + [children] + ); + + const renderError = useCallback( + (_err: unknown, retry: () => void) => ( + + ), + [baseUrl] + ); + return ( - + {renderChildren} ); diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 666477dd0b..14c1622203 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; +import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; @@ -13,9 +14,11 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { const { roomIdOrAlias: encodedRoomIdOrAlias, eventId: encodedEventId } = useParams(); const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); const eventId = encodedEventId && decodeURIComponent(encodedEventId); - const roomId = useSelectedRoom(); + const { roomId, resolving } = useResolvedSelectedRoom(); const room = mx.getRoom(roomId); + if (resolving) return ; + if (!room || !rooms.includes(room.roomId)) { return ; } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 2cd2c0acb7..baba83a7f0 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; +import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useResolvedSelectedRoom } from '$hooks/router/useResolvedRoomId'; import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; @@ -18,9 +19,11 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const roomIdOrAlias = encodedRoomIdOrAlias && decodeURIComponent(encodedRoomIdOrAlias); const eventId = encodedEventId && decodeURIComponent(encodedEventId); const viaServers = useSearchParamsViaServers(); - const roomId = useSelectedRoom(); + const { roomId, resolving } = useResolvedSelectedRoom(); const room = mx.getRoom(roomId); + if (resolving) return ; + if (!room || (!isShowingAllRoomsInHome && !rooms.includes(room.roomId))) { return ( ; + if (!room || !allRooms.includes(room.roomId)) { // room is not joined return ( diff --git a/src/app/pages/client/space/SpaceProvider.tsx b/src/app/pages/client/space/SpaceProvider.tsx index 09c9e468d2..ddef162cdc 100644 --- a/src/app/pages/client/space/SpaceProvider.tsx +++ b/src/app/pages/client/space/SpaceProvider.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react'; +import { Spinner } from 'folds'; import { useParams } from 'react-router-dom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSpaces } from '$state/hooks/roomList'; import { allRoomsAtom } from '$state/room-list/roomList'; -import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; +import { useResolvedSelectedSpace } from '$hooks/router/useResolvedRoomId'; import { SpaceProvider } from '$hooks/useSpace'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; @@ -19,9 +20,11 @@ export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) { const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); const viaServers = useSearchParamsViaServers(); - const selectedSpaceId = useSelectedSpace(); + const { roomId: selectedSpaceId, resolving } = useResolvedSelectedSpace(); const space = mx.getRoom(selectedSpaceId); + if (resolving) return ; + if (!space || !joinedSpaces.includes(space.roomId)) { return ; } diff --git a/src/app/plugins/custom-emoji/utils.ts b/src/app/plugins/custom-emoji/utils.ts index 4597b64baf..318a2a4e6d 100644 --- a/src/app/plugins/custom-emoji/utils.ts +++ b/src/app/plugins/custom-emoji/utils.ts @@ -105,6 +105,18 @@ export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] { return packs; } +export function getGlobalImagePackRoomIds(mx: MatrixClient): string[] { + const emoteRoomsContent = + getAccountData(mx, CustomAccountDataEvent.ImagePackRooms)?.getContent() || + getAccountData(mx, CustomAccountDataEvent.PoniesEmoteRooms)?.getContent(); + + if (typeof emoteRoomsContent !== 'object' || !emoteRoomsContent) return []; + const { rooms: roomIdToPackInfo } = emoteRoomsContent as { rooms?: unknown }; + if (typeof roomIdToPackInfo !== 'object' || !roomIdToPackInfo) return []; + + return Object.keys(roomIdToPackInfo); +} + export function getUserImagePack(mx: MatrixClient): ImagePack | undefined { const packEvent = getAccountData(mx, CustomAccountDataEvent.PoniesUserEmotes); const userId = mx.getUserId(); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index f7f9edd9e6..57b1608c30 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -4,7 +4,13 @@ import type { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, } from '$types/matrix-sdk'; -import { createClient, IndexedDBStore, IndexedDBCryptoStore } from '$types/matrix-sdk'; +import { + ClientEvent, + createClient, + IndexedDBStore, + IndexedDBCryptoStore, + SyncState, +} from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; import type { Session, Sessions, SessionStoreName } from '$state/sessions'; @@ -15,7 +21,7 @@ import { createDebugLogger } from '$utils/debugLogger'; import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; import { cryptoCallbacks } from './secretStorageKeys'; -import type { SlidingSyncConfig, SlidingSyncDiagnostics } from './slidingSync'; +import type { SlidingSyncDiagnostics } from './slidingSync'; import { SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; @@ -23,8 +29,73 @@ const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); const presenceSyncByClient = new WeakMap(); +const presenceStartCleanupByClient = new WeakMap void>(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; +const isInitialSyncReady = (state: string | null): boolean => + state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; + +const startPresenceAfterInitialSync = ( + mx: MatrixClient, + manager: PresenceSyncManager +): (() => void) => { + let started = false; + let startTimer: ReturnType | undefined; + + const start = () => { + if (started) return; + started = true; + presenceStartCleanupByClient.delete(mx); + mx.removeListener(ClientEvent.Sync, onSync); + manager.start(); + }; + + const scheduleStart = () => { + if (startTimer !== undefined) return; + startTimer = globalThis.setTimeout(() => { + startTimer = undefined; + start(); + }, 0); + }; + + const onSync = (state: SyncState) => { + if (isInitialSyncReady(state)) scheduleStart(); + }; + + if (isInitialSyncReady(mx.getSyncState())) scheduleStart(); + else mx.on(ClientEvent.Sync, onSync); + + const cleanup = () => { + if (startTimer !== undefined) globalThis.clearTimeout(startTimer); + mx.removeListener(ClientEvent.Sync, onSync); + presenceStartCleanupByClient.delete(mx); + }; + presenceStartCleanupByClient.set(mx, cleanup); + return cleanup; +}; + +type StartupPhase = 'sync_store' | 'rust_crypto' | 'client_init' | 'client_start'; + +const measureStartupPhase = async ( + phase: StartupPhase, + task: () => Promise, + attributes?: Record +): Promise => { + const startTime = performance.now(); + try { + const result = await task(); + Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - startTime, { + attributes: { phase, outcome: 'success', ...attributes }, + }); + return result; + } catch (error) { + Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - startTime, { + attributes: { phase, outcome: 'error', ...attributes }, + }); + throw error; + } +}; + type FetchRoomEventResult = Awaited>; type MatrixClientWithWritableFetchRoomEvent = MatrixClient & { fetchRoomEvent: (roomId: string, eventId: string) => Promise; @@ -36,7 +107,7 @@ const slidingSyncConnIdCleanupByClient = new WeakMap void>() type SlidingSyncMethod = ( reqBody: MSC3575SlidingSyncRequest, - proxyBaseUrl?: string, + baseUrl?: string, abortSignal?: AbortSignal ) => Promise; @@ -53,13 +124,13 @@ function installSlidingSyncConnId(mx: MatrixClient): void { const mxWritable = mx as MatrixClientWithWritableSlidingSync; const original = mx.slidingSync.bind(mx) as SlidingSyncMethod; - - mxWritable.slidingSync = (reqBody, proxyBaseUrl, abortSignal) => { + mxWritable.slidingSync = (reqBody, baseUrl, abortSignal) => { const req = reqBody as SlidingSyncRequestWithConnId; if (req.conn_id === undefined) { req.conn_id = SLIDING_SYNC_CONN_ID; } - return original(reqBody, proxyBaseUrl, abortSignal); + + return original(reqBody, baseUrl, abortSignal); }; slidingSyncConnIdCleanupByClient.set(mx, () => { @@ -97,17 +168,6 @@ function installStartupFetchRoomEventPatch( fetchRoomEventStartupCleanupByClient.set(mx, restore); } -export const resolveSlidingEnabled = (enabled: SlidingSyncConfig['enabled']): boolean => { - if (enabled === undefined) return false; - if (typeof enabled === 'boolean') return enabled; - const normalized = String(enabled).trim().toLowerCase(); - if (normalized === 'false' || normalized === '0' || normalized === 'off' || normalized === 'no') - return false; - if (normalized === 'true' || normalized === '1' || normalized === 'on' || normalized === 'yes') - return true; - return false; -}; - const deleteDatabase = (name: string): Promise => new Promise((resolve) => { const req = window.indexedDB.deleteDatabase(name); @@ -264,7 +324,12 @@ export const clearMismatchedStores = async (): Promise => { ); }; -const buildClient = async (session: Session): Promise => { +type BuiltClient = { + mx: MatrixClient; + indexedDBStore: IndexedDBStore; +}; + +const buildClient = (session: Session): BuiltClient => { const storeName = getSessionStoreName(session); const indexedDBStore = new IndexedDBStore({ @@ -287,8 +352,46 @@ const buildClient = async (session: Session): Promise => { verificationMethods: ['m.sas.v1'], }); - await indexedDBStore.startup(); - return mx; + return { mx, indexedDBStore }; +}; + +type ClientInitializationResult = + | { ok: true; mx: MatrixClient } + | { ok: false; error: unknown; phase: 'sync_store' | 'rust_crypto' }; + +const initializeClient = async ( + session: Session, + cryptoDatabasePrefix: string +): Promise => { + let builtClient: BuiltClient; + try { + builtClient = buildClient(session); + } catch (error) { + return { ok: false, error, phase: 'sync_store' }; + } + const { mx, indexedDBStore } = builtClient; + + void mx.getVersions().catch(() => undefined); + + const syncStorePromise = measureStartupPhase('sync_store', () => indexedDBStore.startup()); + const cryptoPromise = measureStartupPhase('rust_crypto', () => + mx.initRustCrypto({ cryptoDatabasePrefix }) + ); + const [syncStoreResult, cryptoResult] = await Promise.allSettled([ + syncStorePromise, + cryptoPromise, + ]); + + if (syncStoreResult.status === 'rejected') { + mx.stopClient(); + return { ok: false, error: syncStoreResult.reason, phase: 'sync_store' }; + } + if (cryptoResult.status === 'rejected') { + mx.stopClient(); + return { ok: false, error: cryptoResult.reason, phase: 'rust_crypto' }; + } + + return { ok: true, mx }; }; export const initClient = async (session: Session): Promise => { @@ -325,48 +428,49 @@ export const initClient = async (session: Session): Promise => { } }; - let mx: MatrixClient; + const initStartTime = performance.now(); + let initOutcome = 'success'; try { - mx = await buildClient(session); - } catch (err) { - if (!isMismatch(err)) { - debugLog.error('sync', 'Failed to build client', { error: err }); - throw err; - } - log.warn('initClient: mismatch on buildClient — wiping and retrying:', err); - debugLog.warn('sync', 'Client build mismatch - wiping stores and retrying', { error: err }); - await wipeAllStores(); - mx = await buildClient(session); - } + let result = await initializeClient(session, storeName.rustCryptoPrefix); + if (!result.ok) { + if (!isMismatch(result.error)) { + debugLog.error('sync', 'Failed to initialize client', { + phase: result.phase, + error: result.error, + }); + throw result.error; + } - try { - await mx.initRustCrypto({ - cryptoDatabasePrefix: storeName.rustCryptoPrefix, - }); - } catch (err) { - if (!isMismatch(err)) { - debugLog.error('sync', 'Failed to initialize crypto', { error: err }); - throw err; + log.warn(`initClient: mismatch during ${result.phase} — wiping and retrying:`, result.error); + debugLog.warn('sync', 'Client initialization mismatch - wiping stores and retrying', { + phase: result.phase, + error: result.error, + }); + await wipeAllStores(); + result = await initializeClient(session, storeName.rustCryptoPrefix); + if (!result.ok) { + debugLog.error('sync', 'Failed to initialize client after store reset', { + phase: result.phase, + error: result.error, + }); + throw result.error; + } } - log.warn('initClient: mismatch on initRustCrypto — wiping and retrying:', err); - debugLog.warn('sync', 'Crypto init mismatch - wiping stores and retrying', { - error: err, - }); - mx.stopClient(); - await wipeAllStores(); - mx = await buildClient(session); - await mx.initRustCrypto({ - cryptoDatabasePrefix: storeName.rustCryptoPrefix, + + result.mx.setMaxListeners(50); + return result.mx; + } catch (error) { + initOutcome = 'error'; + throw error; + } finally { + Sentry.metrics.distribution('sable.startup.phase_ms', performance.now() - initStartTime, { + attributes: { phase: 'client_init', outcome: initOutcome }, }); } - - mx.setMaxListeners(50); - return mx; }; export type StartClientConfig = { baseUrl?: string; - slidingSync?: SlidingSyncConfig; sessionSlidingSyncOptIn?: boolean; pollTimeoutMs?: number; timelineLimit?: number; @@ -386,6 +490,7 @@ const disposeSlidingSync = (mx: MatrixClient): void => { }; const disposePresenceSync = (mx: MatrixClient): void => { + presenceStartCleanupByClient.get(mx)?.(); const manager = presenceSyncByClient.get(mx); if (!manager) return; manager.dispose(); @@ -399,29 +504,24 @@ export const getPresenceSyncManager = (mx: MatrixClient): PresenceSyncManager | 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 proxyBaseUrl = slidingConfig?.proxyBaseUrl ?? config?.baseUrl ?? mx.baseUrl; - const useSliding = - config?.sessionSlidingSyncOptIn === true && - !!slidingConfig && - resolveSlidingEnabled(slidingConfig?.enabled); + const baseUrl = config?.baseUrl ?? mx.baseUrl; + const useSliding = config?.sessionSlidingSyncOptIn === true; + + debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() }); const presenceManager = new PresenceSyncManager(mx); presenceSyncByClient.set(mx, presenceManager); - - presenceManager.start(); + startPresenceAfterInitialSync(mx, presenceManager); let manager: SlidingSyncManager | undefined; if (useSliding) { - manager = new SlidingSyncManager(mx, proxyBaseUrl, { - ...slidingConfig, - includeInviteList: true, - pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, + manager = new SlidingSyncManager(mx, baseUrl, { + pollTimeoutMs: config?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, + timelineLimit: config?.timelineLimit, }); installStartupFetchRoomEventPatch(mx, manager); @@ -432,16 +532,21 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): } try { - await mx.startClient({ - lazyLoadMembers: true, - slidingSync: manager?.slidingSync, - threadSupport: true, - }); + await measureStartupPhase( + 'client_start', + () => + mx.startClient({ + lazyLoadMembers: true, + slidingSync: manager?.slidingSync, + threadSupport: true, + }), + { transport: useSliding ? 'sliding' : 'classic' } + ); } catch (err) { debugLog.error('network', 'Failed to start client with sliding sync', { error: err instanceof Error ? err.message : String(err), userId: mx.getUserId(), - proxyBaseUrl: useSliding ? proxyBaseUrl : undefined, + baseUrl: useSliding ? baseUrl : undefined, stack: err instanceof Error ? err.stack : undefined, }); disposeSlidingSync(mx); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 4f8f5c80fe..6cdd6974f6 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -3,15 +3,17 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { MatrixClient } from '$types/matrix-sdk'; +import type { MatrixClient, MSC3575List } from '$types/matrix-sdk'; +import { EventType, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; -import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync'; +import { SlidingSyncManager } from './slidingSync'; // ── vi.hoisted mocks ───────────────────────────────────────────────────────── // Must be defined via vi.hoisted const mocks = vi.hoisted(() => ({ + slidingSyncConstructorArgs: undefined as unknown[] | undefined, slidingSyncInstance: { - on: vi.fn<() => void>(), + on: vi.fn<(event: unknown, handler: unknown) => void>(), off: vi.fn<() => void>(), removeListener: vi.fn<() => void>(), stop: vi.fn<() => void>(), @@ -20,10 +22,10 @@ const mocks = vi.hoisted(() => ({ addCustomSubscription: vi.fn<() => void>(), useCustomSubscription: vi.fn<() => void>(), registerExtension: vi.fn<() => void>(), - getListData: vi.fn<() => null>(), - getListParams: vi.fn<() => null>(), + getListData: vi.fn<(key: string) => null>(), + getListParams: vi.fn<(key: string) => null>(), setList: vi.fn<() => void>(), - setListRanges: vi.fn<() => void>(), + setListRanges: vi.fn<(key: string, ranges: number[][]) => void>(), }, })); @@ -44,7 +46,8 @@ vi.mock('@sentry/react', () => ({ // A plain function constructor is the correct pattern vi.mock('$types/matrix-sdk', async (importOriginal) => { const actual = await importOriginal>(); - function MockSlidingSync() { + function MockSlidingSync(...args: unknown[]) { + mocks.slidingSyncConstructorArgs = args; return mocks.slidingSyncInstance; } return { ...actual, SlidingSync: MockSlidingSync }; @@ -66,12 +69,113 @@ function makeMockMx(overrides: Record = {}) { } function makeManager(mx: ReturnType): SlidingSyncManager { - const config: SlidingSyncConfig = {}; - return new SlidingSyncManager(mx, 'https://sliding.example.com', config); + return new SlidingSyncManager(mx, 'https://sliding.example.com'); } beforeEach(() => { vi.clearAllMocks(); + mocks.slidingSyncInstance.getListData.mockReset().mockReturnValue(null); + mocks.slidingSyncInstance.getListParams.mockReset().mockReturnValue(null); + mocks.slidingSyncInstance.setListRanges.mockReset(); + mocks.slidingSyncConstructorArgs = undefined; +}); + +function fireLifecycle(state: SlidingSyncState, response: unknown = {}) { + const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find( + ([event]) => event === SlidingSyncEvent.Lifecycle + ); + const lifecycle = lifecycleCall?.[1] as + | ((nextState: SlidingSyncState, nextResponse: unknown, error?: Error) => void) + | undefined; + lifecycle?.(state, response); +} + +describe('SlidingSyncManager initial request', () => { + it('starts with a small room-list range and only row-level state', () => { + makeManager(makeMockMx()); + + const lists = mocks.slidingSyncConstructorArgs?.[1] as Map; + const joined = lists.get('joined'); + + expect(joined?.ranges).toEqual([[0, 29]]); + expect(joined?.required_state).toHaveLength(8); + expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']); + expect(joined?.required_state).not.toContainEqual(['m.space.child', '*']); + }); + + it('expands by one page after the first response instead of jumping to all rooms', () => { + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 1000 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockReturnValue({ + ranges: [[0, 29]], + } as never); + manager.attach(); + + const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find( + ([event]) => event === SlidingSyncEvent.Lifecycle + ); + const lifecycle = lifecycleCall?.[1] as + | ((state: SlidingSyncState, response: unknown, error?: Error) => void) + | undefined; + lifecycle?.(SlidingSyncState.Complete, {}); + + expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [ + [0, 29], + [30, 59], + ]); + }); + + it('continues expanding lists when an active-room subscription has not returned data yet', () => { + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 1000 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockReturnValue({ + ranges: [[0, 29]], + } as never); + manager.attach(); + manager.subscribeToRoom('!active:example.com'); + + const lifecycleCall = mocks.slidingSyncInstance.on.mock.calls.find( + ([event]) => event === SlidingSyncEvent.Lifecycle + ); + const lifecycle = lifecycleCall?.[1] as + | ((state: SlidingSyncState, response: unknown, error?: Error) => void) + | undefined; + lifecycle?.(SlidingSyncState.Complete, {}); + + expect(mocks.slidingSyncInstance.setListRanges).toHaveBeenCalledWith('joined', [ + [0, 29], + [30, 59], + ]); + }); + + it('defers active subscriptions while an expanded range is awaiting its response', () => { + let joinedRange: [number, number][] = [[0, 29]]; + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 193 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockImplementation((key: string) => + key === 'joined' ? ({ ranges: joinedRange } as never) : ({ ranges: [[0, 29]] } as never) + ); + mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => { + if (key === 'joined') joinedRange = ranges as [number, number][]; + }); + manager.attach(); + + fireLifecycle(SlidingSyncState.Complete); + manager.subscribeToRoom('!active:example.com'); + + const diagnostics = manager.getDiagnostics(); + expect(manager.isRoomActive('!active:example.com')).toBe(true); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).not.toHaveBeenCalled(); + expect(diagnostics.lists.find((list) => list.key === 'joined')).toMatchObject({ + rangeEnd: 59, + }); + }); }); // ── dispose() ──────────────────────────────────────────────────────────────── @@ -109,6 +213,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'leave'); @@ -121,6 +226,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'ban'); @@ -132,6 +238,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'leave', '!room:example.com', '@other:example.com'); @@ -144,6 +251,7 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => { const mx = makeMockMx(); const manager = makeManager(mx); manager.attach(); + fireLifecycle(SlidingSyncState.Complete); manager.subscribeToRoom('!room:example.com'); fireMembershipEvent(mx, 'join'); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 862b122d9f..0733229b7d 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -29,14 +29,17 @@ export const LIST_INVITES = 'invites'; export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; -const LIST_TIMELINE_LIMIT = 1; +const LIST_TIMELINE_LIMIT = 0; const DEFAULT_LIST_PAGE_SIZE = 250; +const INITIAL_LIST_SIZE = 30; +const LIST_EXPANSION_PAGE_SIZE = INITIAL_LIST_SIZE; const DEFAULT_POLL_TIMEOUT_MS = 20000; -const DEFAULT_MAX_ROOMS = 5000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; +const SPACE_SUBSCRIPTION_KEY = 'space'; +const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { @@ -45,16 +48,10 @@ export type PartialSlidingSyncRequest = { ranges?: [number, number][]; }; -export type SlidingSyncConfig = { - enabled?: boolean; - proxyBaseUrl?: string; - bootstrapClassicOnColdCache?: boolean; +type SlidingSyncOptions = { listPageSize?: number; timelineLimit?: number; pollTimeoutMs?: number; - maxRooms?: number; - includeInviteList?: boolean; - probeTimeoutMs?: number; }; export type SlidingSyncListDiagnostics = { @@ -64,7 +61,7 @@ export type SlidingSyncListDiagnostics = { }; export type SlidingSyncDiagnostics = { - proxyBaseUrl: string; + baseUrl: string; timelineLimit: number; listPageSize: number; lists: SlidingSyncListDiagnostics[]; @@ -76,23 +73,31 @@ const clampPositive = (value: number | undefined, fallback: number): number => { }; const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [ - [EventType.RoomJoinRules, ''], + // first sync limited solely to what's needed to render rooms [EventType.RoomAvatar, ''], [EventType.RoomTombstone, ''], [EventType.RoomEncryption, ''], [EventType.RoomCreate, ''], - [EventType.RoomTopic, ''], + [EventType.RoomName, ''], [EventType.RoomCanonicalAlias, ''], + [EventType.RoomJoinRules, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], +]; + +const SPACE_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + [EventType.RoomCreate, ''], + [EventType.RoomName, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomJoinRules, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomTombstone, ''], ['m.space.child', MSC3575_WILDCARD], - [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], - [CustomStateEvent.ImagePack, MSC3575_WILDCARD], - [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], - [CustomStateEvent.RoomAbbreviations, ''], - [CustomStateEvent.RoomBanner, ''], + ['m.space.parent', MSC3575_WILDCARD], ]; const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + // active room subscription includes all room data [EventType.RoomPowerLevels, ''], [EventType.RoomName, ''], [EventType.RoomTopic, ''], @@ -118,7 +123,7 @@ const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ const buildEncryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ timeline_limit: timelineLimit, - required_state: [[MSC3575_WILDCARD, MSC3575_WILDCARD]], + required_state: ACTIVE_ROOM_REQUIRED_STATE, }); const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ @@ -126,11 +131,24 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri required_state: ACTIVE_ROOM_REQUIRED_STATE, }); -const buildLists = (pageSize: number, includeInviteList: boolean): Map => { +const buildImagePackSubscription = (): MSC3575RoomSubscription => ({ + timeline_limit: 0, + required_state: [ + [CustomStateEvent.ImagePack, MSC3575_WILDCARD], + [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], + ], +}); + +const buildSpaceSubscription = (): MSC3575RoomSubscription => ({ + timeline_limit: 0, + required_state: SPACE_REQUIRED_STATE, +}); + +const buildLists = (pageSize: number): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); - const initialRange = Math.min(pageSize, 100); + const initialRange = Math.min(pageSize, INITIAL_LIST_SIZE); lists.set(LIST_JOINED, { ranges: [[0, Math.max(0, initialRange - 1)]], @@ -140,15 +158,13 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map { export class SlidingSyncManager { private disposed = false; - private readonly maxRooms: number; - private readonly listKeys: string[]; private readonly activeRoomSubscriptions = new Set(); + private readonly deferredActiveRoomSubscriptions = new Set(); + + private readonly spaceSubscriptions = new Set(); + + private deferredSpaceSubscriptions = new Set(); + + private readonly imagePackRoomSubscriptions = new Set(); + private readonly listPageSize: number; private readonly roomTimelineLimit: number; @@ -190,6 +212,10 @@ export class SlidingSyncManager { private previousListCounts: Map = new Map(); + private readonly requestedListRangeEnds = new Map(); + + private readonly confirmedListRangeEnds = new Map(); + /** * One-shot RoomData listeners keyed by roomId, used to measure the latency * between subscribeToRoom() and the first data arriving for that room. @@ -208,32 +234,35 @@ export class SlidingSyncManager { public readonly slidingSync: SlidingSync; - public readonly probeTimeoutMs: number; - public constructor( private readonly mx: MatrixClient, - private readonly proxyBaseUrl: string, - config: SlidingSyncConfig + private readonly baseUrl: string, + options: SlidingSyncOptions = {} ) { - const listPageSize = clampPositive(config.listPageSize, DEFAULT_LIST_PAGE_SIZE); - const pollTimeoutMs = clampPositive(config.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS); - this.probeTimeoutMs = clampPositive(config.probeTimeoutMs, 5000); - this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS); + const listPageSize = clampPositive(options.listPageSize, DEFAULT_LIST_PAGE_SIZE); + const pollTimeoutMs = clampPositive(options.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS); this.listPageSize = listPageSize; - const includeInviteList = config.includeInviteList !== false; - const roomTimelineLimit = clampPositive(config.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); + const roomTimelineLimit = clampPositive(options.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); - const lists = buildLists(listPageSize, includeInviteList); + const lists = buildLists(listPageSize); this.listKeys = Array.from(lists.keys()); - this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs); + lists.forEach((list, key) => { + this.requestedListRangeEnds.set(key, getListEndIndex(list)); + }); + this.slidingSync = new SlidingSync(baseUrl, lists, defaultSubscription, mx, pollTimeoutMs); this.slidingSync.addCustomSubscription( UNENCRYPTED_SUBSCRIPTION_KEY, buildUnencryptedSubscription(roomTimelineLimit) ); + this.slidingSync.addCustomSubscription( + IMAGE_PACK_SUBSCRIPTION_KEY, + buildImagePackSubscription() + ); + this.slidingSync.addCustomSubscription(SPACE_SUBSCRIPTION_KEY, buildSpaceSubscription()); this.onLifecycle = (state, resp, err) => { const syncStartTime = performance.now(); @@ -268,6 +297,10 @@ export class SlidingSyncManager { if (err || !resp || state !== SlidingSyncState.Complete) return; + this.requestedListRangeEnds.forEach((rangeEnd, key) => { + this.confirmedListRangeEnds.set(key, rangeEnd); + }); + const changes: Record = {}; let totalRoomCount = 0; let hasChanges = false; @@ -324,7 +357,7 @@ export class SlidingSyncManager { this.initialSyncSpan = null; } - this.expandListsToKnownCount(); + this.expandListsByPage(); Sentry.metrics.distribution('sable.sync.processing_ms', syncDuration, { attributes: { transport: 'sliding' }, @@ -349,10 +382,9 @@ export class SlidingSyncManager { public attach(): void { debugLog.info('sync', 'Attaching sliding sync listeners', { - proxyBaseUrl: this.proxyBaseUrl, + baseUrl: this.baseUrl, listPageSize: this.listPageSize, roomTimelineLimit: this.roomTimelineLimit, - maxRooms: this.maxRooms, lists: this.listKeys, }); @@ -360,7 +392,7 @@ export class SlidingSyncManager { this.initialSyncSpan = Sentry.startInactiveSpan({ name: 'sync.initial', op: 'matrix.sync', - attributes: { 'sync.transport': 'sliding', 'sync.proxy': this.proxyBaseUrl }, + attributes: { 'sync.transport': 'sliding', 'sync.base_url': this.baseUrl }, }); this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); @@ -391,7 +423,7 @@ export class SlidingSyncManager { public getDiagnostics(): SlidingSyncDiagnostics { return { - proxyBaseUrl: this.proxyBaseUrl, + baseUrl: this.baseUrl, timelineLimit: this.roomTimelineLimit, listPageSize: this.listPageSize, lists: this.listKeys.map((key) => { @@ -406,8 +438,8 @@ export class SlidingSyncManager { }; } - private expandListsToKnownCount(): void { - if (this.listsFullyLoaded) return; + private expandListsByPage(): void { + if (!this.initialSyncCompleted) return; let allListsComplete = true; let expandedAny = false; @@ -435,9 +467,21 @@ export class SlidingSyncManager { } const existing = this.slidingSync.getListParams(key); - const currentEnd = getListEndIndex(existing); + const requestedEnd = getListEndIndex(existing); + const currentEnd = this.confirmedListRangeEnds.get(key) ?? -1; - const maxEnd = Math.min(knownCount, this.maxRooms) - 1; + const maxEnd = knownCount - 1; + + if (requestedEnd > currentEnd) { + allListsComplete = false; + expansionDetails[key] = { + status: 'awaiting-response', + knownCount, + currentEnd, + desiredEnd: requestedEnd, + }; + return; + } if (currentEnd >= maxEnd) { expansionDetails[key] = { status: 'complete', knownCount, currentEnd }; @@ -446,7 +490,7 @@ export class SlidingSyncManager { allListsComplete = false; - const desiredEnd = maxEnd; + const desiredEnd = Math.min(maxEnd, currentEnd + LIST_EXPANSION_PAGE_SIZE); if (desiredEnd === currentEnd) { expansionDetails[key] = { @@ -458,7 +502,10 @@ export class SlidingSyncManager { return; } - this.slidingSync.setListRanges(key, [[0, desiredEnd]]); + const existingRanges = existing?.ranges ?? []; + const nextRangeStart = currentEnd + 1; + this.slidingSync.setListRanges(key, [...existingRanges, [nextRangeStart, desiredEnd]]); + this.requestedListRangeEnds.set(key, desiredEnd); expandedAny = true; expansionDetails[key] = { @@ -469,46 +516,37 @@ export class SlidingSyncManager { roomsToLoad: desiredEnd - currentEnd, }; - debugLog.info('sync', `Expanding list "${key}" to full range`, { + debugLog.info('sync', `Expanding list "${key}" by page`, { list: key, knownCount, previousEnd: currentEnd, newEnd: desiredEnd, roomsToLoad: desiredEnd - currentEnd, }); - - if (knownCount > this.maxRooms) { - log.warn( - `Sliding Sync list "${key}" capped at ${this.maxRooms}/${knownCount} rooms for ${this.mx.getUserId()}` - ); - debugLog.warn('sync', `List "${key}" exceeds maxRooms limit`, { - list: key, - knownCount, - maxRooms: this.maxRooms, - cappedCount: this.maxRooms, - }); - } }); const expansionDuration = performance.now() - expansionStartTime; const hasExpansions = Object.values(expansionDetails).some((d) => d.status === 'expanding'); - if (allListsComplete) { - this.listsFullyLoaded = true; - log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); - const totalRooms = this.listKeys.reduce( - (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), - 0 - ); - const listsLoadedMs = - this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; - Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { - attributes: { transport: 'sliding' }, - }); - Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, { - attributes: { transport: 'sliding' }, - }); + if (!this.listsFullyLoaded) { + this.listsFullyLoaded = true; + globalThis.setTimeout(() => this.flushDeferredSubscriptions(), 0); + log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); + const totalRooms = this.listKeys.reduce( + (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), + 0 + ); + const listsLoadedMs = + this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; + Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { + attributes: { transport: 'sliding' }, + }); + Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, { + attributes: { transport: 'sliding' }, + }); + } } else if (expandedAny) { + this.listsFullyLoaded = false; log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`); } @@ -586,18 +624,72 @@ export class SlidingSyncManager { } public isRoomActive(roomId: string): boolean { - return this.activeRoomSubscriptions.has(roomId); + return ( + this.activeRoomSubscriptions.has(roomId) || this.deferredActiveRoomSubscriptions.has(roomId) + ); + } + + private flushDeferredSubscriptions(): void { + if (this.disposed || !this.listsFullyLoaded) return; + + const spaces = this.deferredSpaceSubscriptions; + this.deferredSpaceSubscriptions = new Set(); + if (spaces.size > 0) this.setSpaceSubscriptions(spaces); + + const activeRooms = [...this.deferredActiveRoomSubscriptions]; + this.deferredActiveRoomSubscriptions.clear(); + activeRooms.forEach((roomId) => this.subscribeToRoom(roomId)); + } + + public subscribeToImagePackRoom(roomId: string): void { + if (this.disposed || this.imagePackRoomSubscriptions.has(roomId)) return; + this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY); + this.imagePackRoomSubscriptions.add(roomId); + this.slidingSync.modifyRoomSubscriptions( + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) + ); + } + + /* Listen for raw sliding-sync data for one room */ + public onRoomData(roomId: string, listener: () => void): () => void { + const handler = (dataRoomId: string) => { + // Let matrix-js-sdk finish applying the room data before reading state events + if (dataRoomId === roomId) globalThis.setTimeout(listener, 0); + }; + this.slidingSync.on(SlidingSyncEvent.RoomData, handler); + return () => this.slidingSync.removeListener(SlidingSyncEvent.RoomData, handler); } public subscribeToRoom(roomId: string): void { if (this.disposed) return; + if (!this.listsFullyLoaded) { + this.deferredActiveRoomSubscriptions.add(roomId); + debugLog.info('sync', 'Room subscription deferred until lists are hydrated', { + deferredSubscriptions: this.deferredActiveRoomSubscriptions.size, + syncCycle: this.syncCount, + }); + return; + } + if (this.activeRoomSubscriptions.has(roomId)) return; const room = this.mx.getRoom(roomId); const isEncrypted = this.mx.isRoomEncrypted(roomId); if (room && !isEncrypted) { this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY); } this.activeRoomSubscriptions.add(roomId); - this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + this.slidingSync.modifyRoomSubscriptions( + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) + ); + + this.expandListsByPage(); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, }); @@ -653,13 +745,24 @@ export class SlidingSyncManager { public unsubscribeFromRoom(roomId: string): void { if (this.disposed) return; + this.deferredActiveRoomSubscriptions.delete(roomId); + if (!this.activeRoomSubscriptions.has(roomId)) return; const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); this.pendingRoomDataListeners.delete(roomId); } this.activeRoomSubscriptions.delete(roomId); - this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + if (this.spaceSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); + } + this.slidingSync.modifyRoomSubscriptions( + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) + ); Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { attributes: { transport: 'sliding' }, }); @@ -669,4 +772,33 @@ export class SlidingSyncManager { syncCycle: this.syncCount, }); } + + public setSpaceSubscriptions(roomIds: Iterable): void { + if (this.disposed) return; + const next = new Set(roomIds); + if (!this.listsFullyLoaded) { + this.deferredSpaceSubscriptions = next; + return; + } + const unchanged = + next.size === this.spaceSubscriptions.size && + [...next].every((roomId) => this.spaceSubscriptions.has(roomId)); + if (unchanged) return; + + this.spaceSubscriptions.clear(); + next.forEach((roomId) => { + this.spaceSubscriptions.add(roomId); + if (!this.activeRoomSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); + } + }); + this.slidingSync.modifyRoomSubscriptions( + new Set([ + ...this.activeRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]) + ); + this.expandListsByPage(); + } }