diff --git a/.changeset/improve-sliding-sync.md b/.changeset/improve-sliding-sync.md new file mode 100644 index 0000000000..e4569ce8fb --- /dev/null +++ b/.changeset/improve-sliding-sync.md @@ -0,0 +1,13 @@ +--- +default: minor +--- + +Improve sliding sync. + +- Make sliding sync easier to enable during login or loading and from settings. +- Move the sliding sync toggle from Experimental to the bottom of General settings. +- Start faster and restore rooms and spaces sooner. +- Reduce unnecessary background room data. +- Improve room details, memberships, presence, and unread indicators. +- Make startup loading smoother for both sliding and classic sync. +- It's fast now :3 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 index b80e408847..413aeb0831 100644 --- a/src/app/components/ClientConfigLoader.test.tsx +++ b/src/app/components/ClientConfigLoader.test.tsx @@ -1,99 +1,132 @@ -/** - * 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'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { getMatrixToRoom, setMatrixToBase } from '$plugins/matrix-to'; +import { ClientConfigLoader, FALLBACK_CLIENT_CONFIG } from './ClientConfigLoader'; + +const successfulResponse = (config: unknown) => ({ + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn<() => Promise>().mockResolvedValue(config), +}); afterEach(() => { - setMatrixToBase(); // reset module state to 'https://matrix.to' + setMatrixToBase(); 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({}); +describe('ClientConfigLoader', () => { + it('does not render config-dependent children while loading', () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)) + ); render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} + Loading}> + {() => Configured app} ); - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent('https://matrix.to/#/!room:example.com') - ); + expect(screen.getByText('Loading')).toBeInTheDocument(); + expect(screen.queryByText('Configured app')).not.toBeInTheDocument(); }); - it('generates a custom-base URL for rooms when matrixToBaseUrl is set', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); + it('loads the config before rendering children', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(successfulResponse({ allowCustomHomeservers: true })) + ); render( - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; - }} + {(config) => {String(config.allowCustomHomeservers)}} ); - await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent( - 'https://custom.example.org/#/!room:example.com' - ) + expect(await screen.findByText('true')).toBeInTheDocument(); + }); + + it('reports non-success responses and retries', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: vi.fn<() => Promise>(), + } as unknown as Response) + .mockResolvedValueOnce( + successfulResponse({ homeserverList: ['example.org'] }) as unknown as Response + ); + vi.stubGlobal('fetch', fetchMock); + + render( + ( + + )} + > + {(config) => {config.homeserverList?.[0]}} + ); + + fireEvent.click(await screen.findByRole('button', { name: /503 Service Unavailable/ })); + expect(await screen.findByText('example.org')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - it('generates a custom-base URL for users when matrixToBaseUrl is set', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); + it('rejects non-object JSON', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(successfulResponse([]))); render( - - {(config) => { - setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToUser('@alice:example.com')}; - }} + {String(error)}}> + {() => Configured app} ); - await waitFor(() => - expect(screen.getByTestId('user')).toHaveTextContent( - 'https://custom.example.org/#/@alice:example.com' - ) + expect(await screen.findByText(/must contain a JSON object/)).toBeInTheDocument(); + expect(screen.queryByText('Configured app')).not.toBeInTheDocument(); + }); + + it('uses deliberate defaults only after continuing', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + + render( + ( + + )} + > + {(config) => {config.homeserverList?.join(',')}} + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })); + expect(screen.getByText(FALLBACK_CLIENT_CONFIG.homeserverList?.[0] ?? '')).toBeInTheDocument(); }); - it('strips a trailing slash from matrixToBaseUrl', async () => { - mockFetch({ matrixToBaseUrl: 'https://custom.example.org/' }); + it('applies matrix.to configuration before rendering links', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(successfulResponse({ matrixToBaseUrl: 'https://custom.example/' })) + ); render( {(config) => { setMatrixToBase(config.matrixToBaseUrl); - return {getMatrixToRoom('!room:example.com')}; + return {getMatrixToRoom('!room:example.com')}; }} ); await waitFor(() => - expect(screen.getByTestId('link')).toHaveTextContent( - 'https://custom.example.org/#/!room:example.com' - ) + expect(screen.getByText('https://custom.example/#/!room:example.com')).toBeInTheDocument() ); }); }); diff --git a/src/app/components/ClientConfigLoader.tsx b/src/app/components/ClientConfigLoader.tsx index f03b8398e0..6097be06ef 100644 --- a/src/app/components/ClientConfigLoader.tsx +++ b/src/app/components/ClientConfigLoader.tsx @@ -1,39 +1,110 @@ import type { ReactNode } from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Button, Dialog, Text, color, config } from 'folds'; import type { ClientConfig } from '$hooks/useClientConfig'; import { trimTrailingSlash } from '$utils/common'; +import { SplashScreen } from '$components/splash-screen'; -const getClientConfig = async (): Promise => { +export const FALLBACK_CLIENT_CONFIG: ClientConfig = { + defaultHomeserver: 0, + homeserverList: ['matrix.org'], + allowCustomHomeservers: false, + hashRouter: { enabled: false, basename: '/' }, +}; + +export const getClientConfig = async (): Promise => { const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`; - const config = await fetch(url, { method: 'GET' }); - return config.json(); + const response = await fetch(url, { method: 'GET' }); + if (!response.ok) { + throw new Error(`Failed to load config.json (${response.status} ${response.statusText})`); + } + + const data: unknown = await response.json(); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('config.json must contain a JSON object.'); + } + return data as ClientConfig; }; type ClientConfigLoaderProps = { fallback?: () => ReactNode; - error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode; + error?: (error: unknown, retry: () => void, ignore: () => void) => ReactNode; children: (config: ClientConfig) => ReactNode; }; + +type ConfigLoadState = + | { status: 'loading' } + | { status: 'ready'; config: ClientConfig } + | { status: 'error'; error: unknown }; + +const renderConfigError = (error: unknown, retry: () => void, ignore: () => void) => ( + + + + + + Failed to load client configuration. + {error instanceof Error && ( + + {error.message} + + )} + + + + + + + +); + export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) { - const [state, load] = useAsyncCallback(getClientConfig); - const [ignoreError, setIgnoreError] = useState(false); + const [state, setState] = useState({ status: 'loading' }); + const [useFallback, setUseFallback] = useState(false); + const requestIdRef = useRef(0); + + const load = useCallback(() => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setUseFallback(false); + setState({ status: 'loading' }); - const ignoreCallback = useCallback(() => setIgnoreError(true), []); + void getClientConfig().then( + (loadedConfig) => { + if (requestId === requestIdRef.current) { + setState({ status: 'ready', config: loadedConfig }); + } + }, + (loadError: unknown) => { + if (requestId === requestIdRef.current) setState({ status: 'error', error: loadError }); + } + ); + }, []); + + const ignoreError = useCallback(() => setUseFallback(true), []); useEffect(() => { load(); + return () => { + requestIdRef.current += 1; + }; }, [load]); - if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) { + if (state.status === 'loading') { return fallback?.(); } - - if (!ignoreError && state.status === AsyncStatus.Error) { - return error?.(state.error, load, ignoreCallback); + if (state.status === 'error' && !useFallback) { + return (error ?? renderConfigError)(state.error, load, ignoreError); } - const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {}; - - return children(config); + const clientConfig = state.status === 'ready' ? state.config : FALLBACK_CLIENT_CONFIG; + return children(clientConfig); } diff --git a/src/app/components/SpecVersionsLoader.tsx b/src/app/components/SpecVersionsLoader.tsx index 0a92aa2a33..8dbfe1305c 100644 --- a/src/app/components/SpecVersionsLoader.tsx +++ b/src/app/components/SpecVersionsLoader.tsx @@ -6,18 +6,20 @@ import { specVersions } from '../cs-api'; type SpecVersionsLoaderProps = { baseUrl: string; + loadVersions?: () => Promise; fallback?: () => ReactNode; error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode; children: (versions: SpecVersions) => ReactNode; }; export function SpecVersionsLoader({ baseUrl, + loadVersions, fallback, error, children, }: SpecVersionsLoaderProps) { const [state, load] = useAsyncCallback( - useCallback(() => specVersions(fetch, baseUrl), [baseUrl]) + useCallback(() => loadVersions?.() ?? specVersions(fetch, baseUrl), [baseUrl, loadVersions]) ); const [ignoreError, setIgnoreError] = useState(false); @@ -32,7 +34,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/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index ac43b4c563..48f11d0e8a 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -34,7 +34,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro const [leaveState, leaveRoom] = useAsyncCallback( useCallback(async () => { debugLog.info('ui', 'Leave room button clicked', { roomId }); - mx.leave(roomId); + await mx.leave(roomId); }, [mx, roomId]) ); diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 82abef0a17..1c00610a47 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -39,6 +39,7 @@ import * as css from './style.css'; import type { RoomBannerContent } from '$types/matrix-sdk-events'; import { CustomStateEvent } from '$types/matrix/room'; import colorMXID from '$utils/colorMXID'; +import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; type GridColumnCount = '1' | '2' | '3'; const getGridColumnCount = (gridWidth: number): GridColumnCount => { @@ -191,7 +192,7 @@ export const RoomCard = as<'div', RoomCardProps>( ? getStateEvent(joinedRoom, CustomStateEvent.RoomBanner) : undefined; const bannerMXC = bannerState?.getContent()?.url; - const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); const roomName = joinedRoom?.name || name || fallbackName; const roomTopic = (topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic; @@ -238,6 +239,7 @@ export const RoomCard = as<'div', RoomCardProps>( src={bannerURI || avatar || undefined} alt={`${name} cover`} draggable="false" + onError={() => reportMediaLoadFailure('room_card_banner')} /> )} diff --git a/src/app/features/call/callRingtoneStorage.ts b/src/app/features/call/callRingtoneStorage.ts index f4c0bafe68..92f193dbbe 100644 --- a/src/app/features/call/callRingtoneStorage.ts +++ b/src/app/features/call/callRingtoneStorage.ts @@ -41,36 +41,48 @@ async function putCustomCallAudio( blob: file, }; - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).put(entry); - tx.addEventListener('complete', () => resolve()); - tx.addEventListener('error', () => reject(tx.error)); - }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(entry); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + }); + } finally { + db.close(); + } return entry; } async function getCustomCallAudio(key: string): Promise { const db = await openDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readonly'); - const req = tx.objectStore(STORE).get(key); - req.addEventListener('error', () => reject(req.error)); - req.addEventListener('success', () => { - resolve(req.result as StoredCallRingtone | undefined); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(key); + req.addEventListener('error', () => reject(req.error)); + req.addEventListener('success', () => { + resolve(req.result as StoredCallRingtone | undefined); + }); }); - }); + } finally { + db.close(); + } } async function clearCustomCallAudio(key: string): Promise { const db = await openDb(); - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).delete(key); - tx.addEventListener('complete', () => resolve()); - tx.addEventListener('error', () => reject(tx.error)); - }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(key); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + }); + } finally { + db.close(); + } } export const putCustomCallRingtone = ( diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 34fab623c9..6af3ab3764 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -421,11 +421,10 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { {syncDiagnostics.sliding ? ( <> - Proxy: {syncDiagnostics.sliding.proxyBaseUrl} + Base URL: {syncDiagnostics.sliding.baseUrl} - Room timeline: {syncDiagnostics.sliding.timelineLimit} | page - size: {syncDiagnostics.sliding.listPageSize} + Room timeline: {syncDiagnostics.sliding.timelineLimit} ) : ( diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 6d2b764efe..ccfbb0e6a1 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -59,6 +59,7 @@ import { CustomStateEvent } from '$types/matrix/room'; import { SettingTile } from '$components/setting-tile'; import { stopPropagation } from '$utils/keyboard'; import FocusTrap from 'focus-trap-react'; +import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; type RoomProfileEditProps = { canEditAvatar: boolean; @@ -404,6 +405,7 @@ function RoomBannerEdit({ bannerURI, permissions }: Readonly) { key={previewUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="Banner Preview" + onError={() => reportMediaLoadFailure('room_banner_preview')} /> ) : ( @@ -522,7 +524,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) { const bannerState = useStateEvent(room, CustomStateEvent.RoomBanner); const bannerMXC = bannerState?.getContent()?.url; - const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); return ( diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index e8ba663823..fa7d171cb6 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -62,7 +62,7 @@ export function Room() { }); }, [isWidgetDrawerOpen, room.roomId]); const powerLevels = usePowerLevels(room); - const members = useRoomMembers(mx, room.roomId); + const members = useRoomMembers(mx, room.roomId, screenSize === ScreenSize.Desktop && isDrawer); const chat = useAtomValue(callChatAtom); const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d44751aae8..7ffc2f7e43 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -12,7 +12,7 @@ import { import type { Editor } from 'slate'; import { useAtomValue, useSetAtom } from 'jotai'; import type { Room } from '$types/matrix-sdk'; -import { PushProcessor, Direction } from '$types/matrix-sdk'; +import { PushProcessor, Direction, EventType } from '$types/matrix-sdk'; import classNames from 'classnames'; import type { VListHandle } from 'virtua'; import { VList } from 'virtua'; @@ -41,6 +41,7 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useSlidingSyncRoomLoading } from '$hooks/useSlidingSyncActiveRoom'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; @@ -282,6 +283,7 @@ export function RoomTimeline({ }: Readonly) { const mx = useMatrixClient(); const alive = useAlive(); + const roomSyncLoading = useSlidingSyncRoomLoading(room.roomId); const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); const { navigateRoom } = useRoomNavigate(); @@ -939,6 +941,8 @@ export function RoomTimeline({ timelineSync.backwardStatus === 'loading' && timelineSync.eventsLength > 0; const showFrontPaginationSpinner = timelineSync.forwardStatus === 'loading' && timelineSync.eventsLength > 0; + const hasPowerLevelState = !!room.currentState.getStateEvents(EventType.RoomPowerLevels, ''); + const hideTimelineForRoomState = roomSyncLoading && hideMemberInReadOnly && !hasPowerLevelState; const timelineBottomFloatLift = !atBottomState && isReady ? { bottom: `calc(${config.space.S400} + ${toRem(52)})` } : undefined; const timelineTopFloatLift = @@ -1049,6 +1053,15 @@ export function RoomTimeline({ return ( + {(hideTimelineForRoomState || (roomSyncLoading && timelineSync.eventsLength === 0)) && ( + + + + )} {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && isReady && ( diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index bce39fe571..e0b897d93f 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Box, Button, Text } from 'folds'; import { CaretDown, CaretUp, menuIcon } from '$components/icons/phosphor'; import { SequenceCard } from '$components/sequence-card'; @@ -9,6 +10,9 @@ import { Direction, EventType, NotificationCountType, KnownMembership } from '$t import { SequenceCardStyle } from '$features/settings/styles.css'; import { getUnreadInfo, isNotificationEvent } from '$utils/room'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { allInvitesAtom } from '$state/room-list/inviteList'; +import { getMediaLoadFailureCounts } from '$utils/mediaLoadDiagnostics'; type RoomRenderingDiagnostics = { totalRooms: number; @@ -116,6 +120,8 @@ const formatListCoverage = (knownCount: number, rangeEnd: number): string => { export function SyncDiagnostics() { const mx = useMatrixClient(); + const listedJoinedRoomIds = useAtomValue(allRoomsAtom); + const listedInviteRoomIds = useAtomValue(allInvitesAtom); const [, setTick] = useState(0); const [expandSliding, setExpandSliding] = useState(false); @@ -127,6 +133,15 @@ export function SyncDiagnostics() { const diagnostics = getClientSyncDiagnostics(mx); const roomDiagnostics = getRoomRenderingDiagnostics(mx.getRooms()); const unreadDriftRooms = getUnreadDriftRooms(mx); + const listedJoinedRoomIdSet = new Set(listedJoinedRoomIds); + const sdkRoomsMissingFromList = mx + .getRooms() + .filter( + (room) => + room.getMyMembership() === (KnownMembership.Join as string) && + !listedJoinedRoomIdSet.has(room.roomId) + ); + const mediaLoadFailures = [...getMediaLoadFailureCounts()]; return ( @@ -144,6 +159,24 @@ export function SyncDiagnostics() { Room counts: {roomDiagnostics.totalRooms} total, {roomDiagnostics.joinedRooms} joined,{' '} {roomDiagnostics.inviteRooms} invites + + Room-list atoms: {listedJoinedRoomIds.length} joined, {listedInviteRoomIds.length}{' '} + invites + + + SDK joined rooms missing from room list: {sdkRoomsMissingFromList.length} + + {sdkRoomsMissingFromList.slice(0, 10).map((room) => ( + + {room.name || room.roomId} ({room.roomId}) + + ))} + + Media load failures this session:{' '} + {mediaLoadFailures.length === 0 + ? 'none' + : mediaLoadFailures.map(([kind, count]) => `${kind}: ${count}`).join(', ')} + Rooms missing name: {roomDiagnostics.roomsMissingName} Rooms missing avatar: {roomDiagnostics.roomsMissingAvatar} @@ -179,15 +212,15 @@ export function SyncDiagnostics() { {expandSliding && ( - Sliding proxy: {diagnostics.sliding.proxyBaseUrl} - - Room timeline limit: {diagnostics.sliding.timelineLimit} | page size:{' '} - {diagnostics.sliding.listPageSize} - + Sliding sync base URL: {diagnostics.sliding.baseUrl} + Room timeline limit: {diagnostics.sliding.timelineLimit} {diagnostics.sliding.lists.map((list) => ( List `{list.key}` coverage:{' '} {formatListCoverage(list.knownCount, list.rangeEnd)} + {list.requestedRangeEnd > list.rangeEnd + ? ` (requested through ${list.requestedRangeEnd + 1})` + : ''} ))} 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 36a7c6d771..91a511dc50 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) => { @@ -1400,76 +1401,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); @@ -1498,7 +1429,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={ + + } + /> + } @@ -1667,7 +1625,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..76ca7eae6b 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,17 +47,17 @@ 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); } diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index 63cfb0d73a..516aba467b 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'; @@ -55,6 +57,9 @@ const imagePackListEqual = (a: ImagePack[], b: ImagePack[]): boolean => { return a.every((pack, index) => imagePackEqual(pack, b[index])); }; +const roomIdListEqual = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((roomId, index) => roomId === b[index]); + export const useUserImagePack = (): ImagePack | undefined => { const mx = useMatrixClient(); // Seed from cache during initial state when live data is not available yet. @@ -100,6 +105,24 @@ export const useGlobalImagePacks = (): ImagePack[] => { const cachedPacks = readCachedPacks(userId, globalPacksScope()); return cachedPacks.length > 0 ? cachedPacks : livePacks; }); + const [packRoomIds, setPackRoomIds] = useState(() => getGlobalImagePackRoomIds(mx)); + + useEffect(() => { + const manager = getSlidingSyncManager(mx); + if (!manager) return undefined; + + const refresh = () => { + setGlobalPacks((prev) => { + const next = getGlobalImagePacks(mx); + return imagePackListEqual(prev, next) ? prev : next; + }); + }; + + manager.setImagePackSubscriptions(packRoomIds); + const listeners = packRoomIds.map((roomId) => manager.onRoomData(roomId, refresh)); + + return () => listeners.forEach((unsubscribe) => unsubscribe()); + }, [mx, packRoomIds]); useEffect(() => { const userId = mx.getUserId(); @@ -114,6 +137,12 @@ export const useGlobalImagePacks = (): ImagePack[] => { mEvent.getType() === (CustomAccountDataEvent.ImagePackRooms as string) || mEvent.getType() === (CustomAccountDataEvent.PoniesEmoteRooms as string) ) { + const manager = getSlidingSyncManager(mx); + const nextPackRoomIds = getGlobalImagePackRoomIds(mx); + manager?.setImagePackSubscriptions(nextPackRoomIds); + setPackRoomIds((current) => + roomIdListEqual(current, nextPackRoomIds) ? current : nextPackRoomIds + ); setGlobalPacks((prev) => { const next = getGlobalImagePacks(mx); return imagePackListEqual(prev, next) ? prev : next; diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 46640040ec..31e04867f5 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -2,10 +2,15 @@ import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import { useEffect, useState } from 'react'; -export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { +export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!enabled) { + setMembers([]); + return undefined; + } + const room = mx.getRoom(roomId); let loadingMembers = true; let disposed = false; @@ -40,7 +45,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] = mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); mx.removeListener(RoomStateEvent.Events, handleStateEvent); }; - }, [mx, roomId]); + }, [enabled, mx, roomId]); return members; }; diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index 42481970f9..a46e56b45c 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -1,20 +1,129 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { matchPath, useLocation } from 'react-router-dom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getSlidingSyncManager } from '$client/initMatrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; +import { useSpaces } from '$state/hooks/roomList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { ClientEvent } from '$types/matrix-sdk'; +import { + DIRECT_ROOM_PATH, + HOME_ROOM_PATH, + SPACE_LOBBY_PATH, + SPACE_PATH, + SPACE_ROOM_PATH, + SPACE_SEARCH_PATH, +} from '$pages/paths'; +import { isRoomAlias, isRoomId } from '$utils/matrix'; -export const useSlidingSyncActiveRoom = (): void => { +type RouteRoomRefs = { + roomIdOrAlias?: string; + spaceIdOrAlias?: string; +}; + +const decodeRoomRef = (value: string | undefined): string | undefined => { + if (!value) return undefined; + const decoded = decodeURIComponent(value); + return isRoomId(decoded) || isRoomAlias(decoded) ? decoded : undefined; +}; + +export const getRouteRoomRefs = (pathname: string): RouteRoomRefs => { + const homeRoomMatch = matchPath(HOME_ROOM_PATH, pathname); + const directRoomMatch = matchPath(DIRECT_ROOM_PATH, pathname); + const nonSpaceRoomMatch = homeRoomMatch ?? directRoomMatch; + if (nonSpaceRoomMatch) { + return { roomIdOrAlias: decodeRoomRef(nonSpaceRoomMatch.params.roomIdOrAlias) }; + } + + const spaceRoomMatch = matchPath(SPACE_ROOM_PATH, pathname); + if (spaceRoomMatch) { + return { + roomIdOrAlias: decodeRoomRef(spaceRoomMatch.params.roomIdOrAlias), + spaceIdOrAlias: decodeRoomRef(spaceRoomMatch.params.spaceIdOrAlias), + }; + } + + const spaceMatch = + matchPath(SPACE_LOBBY_PATH, pathname) ?? + matchPath(SPACE_SEARCH_PATH, pathname) ?? + matchPath(SPACE_PATH, pathname); + return { spaceIdOrAlias: decodeRoomRef(spaceMatch?.params.spaceIdOrAlias) }; +}; + +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; + + const checkForManager = () => { + const nextManager = getSlidingSyncManager(mx); + if (nextManager) setManager(nextManager); + }; - manager.subscribeToRoom(roomId); + 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 { pathname } = useLocation(); + const { roomIdOrAlias, spaceIdOrAlias } = getRouteRoomRefs(pathname); + const { roomId, resolving: resolvingRoom } = useResolvedRoomIdOrAlias(roomIdOrAlias); + const { roomId: spaceId, resolving: resolvingSpace } = useResolvedRoomIdOrAlias(spaceIdOrAlias); + + useEffect(() => { + if (!manager || resolvingRoom || resolvingSpace) return undefined; + + const activeRoomIds = [...new Set([spaceId, roomId].filter(Boolean))] as string[]; + manager.setActiveRoomSubscriptions(activeRoomIds); + + return undefined; + }, [manager, resolvingRoom, resolvingSpace, roomId, spaceId]); + + useEffect(() => { + if (!manager) return undefined; + return () => manager.setActiveRoomSubscriptions([]); + }, [manager]); +}; + +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 useSlidingSyncRoomLoading = (roomId: string): boolean => { + const manager = useAvailableSlidingSyncManager(); + const [loading, setLoading] = useState(() => manager?.isRoomSubscriptionLoading(roomId) ?? false); + + useEffect(() => { + if (!manager) { + setLoading(false); + return undefined; + } + + return manager.onRoomSubscriptionStatus(roomId, setLoading); + }, [manager, roomId]); + + return loading; +}; + +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..141f503765 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, useCallback, useMemo, useRef } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { createStore } from 'jotai/vanilla'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; @@ -14,7 +14,6 @@ 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'; @@ -36,23 +35,22 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell const jotaiStoreRef = useRef>(); if (!jotaiStoreRef.current) { jotaiStoreRef.current = createStore(); + bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); } - bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); + const router = useMemo(() => createRouter(clientConfig, screenSize), [clientConfig, screenSize]); const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); return ( - - - - - - {reactQueryDevtoolsEnabled && ( - - - - )} - - + + + + + {reactQueryDevtoolsEnabled && ( + + + + )} + ); } @@ -65,28 +63,20 @@ function renderSentryErrorFallback({ error, eventId }: { error: unknown; eventId ); } -function appConfigFallback() { - return ; -} - -function appConfigError(err: unknown, retry: () => void, ignore: () => void) { - return ; -} - -function AppConfigLoaded({ clientConfig, screenSize }: BootstrappedAppShellProps) { - setMatrixToBase(clientConfig.matrixToBaseUrl); - return ; -} - function App() { const screenSize = useScreenSize(); useCompositionEndTracking(); const portalContainer = document.getElementById('portalContainer') ?? undefined; - const renderAppConfig = useCallback( - (clientConfig: ClientConfig) => ( - - ), + const renderConfiguredApp = useCallback( + (clientConfig: ClientConfig) => { + setMatrixToBase(clientConfig.matrixToBaseUrl); + return ( + + + + ); + }, [screenSize] ); @@ -97,9 +87,7 @@ function App() { - - {renderAppConfig} - + {renderConfiguredApp} 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/Login.tsx b/src/app/pages/auth/login/Login.tsx index c5b5b58422..4b399be619 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { Box, Text, color } from 'folds'; +import { useMemo, useState } from 'react'; +import { Box, Switch, Text, Tooltip, TooltipProvider, color, config } from 'folds'; import { Link, useSearchParams } from 'react-router-dom'; import { SSOAction } from '$types/matrix-sdk'; import { RegisterFlowStatus, useAuthFlows } from '$hooks/useAuthFlows'; @@ -15,6 +15,8 @@ import { OrDivider } from '$pages/auth/OrDivider'; import { PasswordLoginForm } from './PasswordLoginForm'; import { TokenLogin } from './TokenLogin'; import { OidcLoginButton, OidcCallback } from './OidcLogin'; +import { sizedIcon, Info } from '$components/icons/phosphor'; +import { getPendingSlidingSyncLogin, setPendingSlidingSyncLogin } from './slidingSyncLogin'; // react-router ignores the real query string in hashRouter mode, so read callback params direct. const getExternalSearchParams = () => { @@ -38,6 +40,46 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar [searchParams] ); +type SlidingSyncLoginOptionProps = { + value: boolean; + onChange: (value: boolean) => void; +}; + +function SlidingSyncLoginOption({ value, onChange }: SlidingSyncLoginOptionProps) { + return ( + + + + Use sliding sync + + + + Sliding sync is faster and uses less bandwidth, but it can be buggier. You can + toggle it later in Settings at any time. + + + } + > + {(triggerRef) => ( + + {sizedIcon(Info, '100')} + + )} + + + + + ); +} + export function Login() { const server = useAuthServer(); const { hashRouter } = useClientConfig(); @@ -51,6 +93,12 @@ export function Login() { const oidcRedirectUri = usePathWithOrigin(getLoginPath(server), { ignoreHashRouter: true }); const external = getExternalSearchParams(); const absoluteLoginPath = usePathWithOrigin(getLoginPath(server)); + const [useSlidingSync, setUseSlidingSync] = useState(getPendingSlidingSyncLogin); + + const handleSlidingSyncChange = (enabled: boolean) => { + setUseSlidingSync(enabled); + setPendingSlidingSyncLogin(enabled); + }; if (hashRouter?.enabled && (external.loginToken || (external.code && external.state))) { window.location.replace( @@ -85,7 +133,7 @@ export function Login() { const showLegacySso = !showOidc && parsedFlows.sso !== undefined; if (showOidc && oidcCode && oidcState) { - return ; + return ; } return ( @@ -93,14 +141,21 @@ export function Login() { Login + {!showPassword && ( + + )} {parsedFlows.token && loginSearchParams.loginToken && ( - + )} {showPassword && ( <> + } /> {(showLegacySso || showOidc) && } diff --git a/src/app/pages/auth/login/OidcLogin.tsx b/src/app/pages/auth/login/OidcLogin.tsx index 06ab23bad5..58de6f9d55 100644 --- a/src/app/pages/auth/login/OidcLogin.tsx +++ b/src/app/pages/auth/login/OidcLogin.tsx @@ -83,8 +83,9 @@ export function OidcLoginButton({ type OidcCallbackProps = { code: string; state: string; + slidingSyncOptIn: boolean; }; -export function OidcCallback({ code, state }: OidcCallbackProps) { +export function OidcCallback({ code, state, slidingSyncOptIn }: OidcCallbackProps) { const commitSession = useCommitLoginSession(); const server = useAuthServer(); const navigate = useNavigate(); @@ -99,9 +100,9 @@ export function OidcCallback({ code, state }: OidcCallbackProps) { useEffect(() => { if (loginState.status === AsyncStatus.Success) { - commitSession(loginState.data.session); + commitSession(loginState.data.session, slidingSyncOptIn); } - }, [loginState, commitSession]); + }, [loginState, slidingSyncOptIn, commitSession]); return ( <> diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index f681e31f75..943daf94e1 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -1,4 +1,4 @@ -import type { FormEventHandler, MouseEventHandler } from 'react'; +import type { FormEventHandler, MouseEventHandler, ReactNode } from 'react'; import { useCallback, useState } from 'react'; import type { RectCords } from 'folds'; import { @@ -106,8 +106,15 @@ function UsernameHint({ server }: { server: string }) { type PasswordLoginFormProps = { defaultUsername?: string; defaultEmail?: string; + slidingSyncOptIn: boolean; + slidingSyncOption?: ReactNode; }; -export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLoginFormProps) { +export function PasswordLoginForm({ + defaultUsername, + defaultEmail, + slidingSyncOptIn, + slidingSyncOption, +}: PasswordLoginFormProps) { const server = useAuthServer(); const clientConfig = useClientConfig(); @@ -120,7 +127,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Parameters >(useCallback(login, [])); - useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); + useLoginComplete( + loginState.status === AsyncStatus.Success ? loginState.data : undefined, + slidingSyncOptIn + ); const handleUsernameLogin = (username: string, password: string) => { startLogin(baseUrl, { @@ -265,6 +275,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
+ {slidingSyncOption} -
- - - - )} - {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..39b44a6dd8 100644 --- a/src/app/pages/client/SpecVersions.tsx +++ b/src/app/pages/client/SpecVersions.tsx @@ -1,39 +1,34 @@ 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 { useMatrixClient } from '$hooks/useMatrixClient'; +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. - - - @@ -44,6 +39,8 @@ function specVersionsError(_err: unknown, retry: () => void, ignore: () => void) } export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) { + const mx = useMatrixClient(); + const loadVersions = useCallback(() => mx.getVersions(), [mx]); const renderChildren = useCallback( (versions: SpecVersions) => ( {children} @@ -51,8 +48,25 @@ 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/SyncStatus.test.ts b/src/app/pages/client/SyncStatus.test.ts new file mode 100644 index 0000000000..6a2f545cd0 --- /dev/null +++ b/src/app/pages/client/SyncStatus.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { SyncState } from '$types/matrix-sdk'; +import { shouldShowConnecting } from './SyncStatus'; + +describe('shouldShowConnecting', () => { + it('hides ordinary initial connection states', () => { + expect(shouldShowConnecting(false, SyncState.Prepared, null)).toBe(false); + expect(shouldShowConnecting(false, SyncState.Syncing, SyncState.Prepared)).toBe(false); + expect(shouldShowConnecting(false, SyncState.Catchup, null)).toBe(false); + }); + + it('shows recovery progress after a client has previously connected', () => { + expect(shouldShowConnecting(true, SyncState.Catchup, SyncState.Reconnecting)).toBe(true); + expect(shouldShowConnecting(true, SyncState.Syncing, SyncState.Catchup)).toBe(true); + }); + + it('hides the banner during steady syncing', () => { + expect(shouldShowConnecting(true, SyncState.Syncing, SyncState.Syncing)).toBe(false); + }); +}); diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index ae222b4f7e..65b9b2cd96 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,6 +1,6 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { SyncState } from '$types/matrix-sdk'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Box, config, Line, Text } from 'folds'; import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; @@ -9,25 +9,46 @@ import { ContainerColor } from '$styles/ContainerColor.css'; type StateData = { current: SyncState | null; previous: SyncState | null | undefined; + showConnecting: boolean; }; +export const shouldShowConnecting = ( + hasConnected: boolean, + current: SyncState | null, + previous: SyncState | null | undefined +): boolean => + hasConnected && + (current === SyncState.Prepared || + current === SyncState.Syncing || + current === SyncState.Catchup) && + previous !== SyncState.Syncing; + type SyncStatusProps = { mx: MatrixClient; }; export function SyncStatus({ mx }: SyncStatusProps) { + const hasConnectedRef = useRef(false); const [stateData, setStateData] = useState({ current: null, previous: undefined, + showConnecting: false, }); useSyncState( mx, useCallback((current, previous) => { + const showConnecting = shouldShowConnecting(hasConnectedRef.current, current, previous); + if (current === SyncState.Syncing) hasConnectedRef.current = true; + setStateData((s) => { - if (s.current === current && s.previous === previous) { + if ( + s.current === current && + s.previous === previous && + s.showConnecting === showConnecting + ) { return s; } - return { current, previous }; + return { current, previous, showConnecting }; }); if (current === SyncState.Reconnecting || current === SyncState.Error) { @@ -44,12 +65,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { }, []) ); - if ( - (stateData.current === SyncState.Prepared || - stateData.current === SyncState.Syncing || - stateData.current === SyncState.Catchup) && - stateData.previous !== SyncState.Syncing - ) { + if (stateData.showConnecting) { 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/Space.tsx b/src/app/pages/client/space/Space.tsx index 91188ccdef..a7f0f63234 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -107,6 +107,7 @@ import { CustomStateEvent } from '$types/matrix/room'; import type { RoomBannerContent } from '$types/matrix-sdk-events'; import { ModalWide } from '$styles/Modal.css'; import { ImageViewer } from '$components/image-viewer'; +import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; import * as css from './styles.css'; import { isResizingSidebarAtom } from '$state/isResizingSidebar'; import { UserQuickTools } from '../sidebar/UserQuickTools'; @@ -285,7 +286,7 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) const bannerState = useStateEvent(space, CustomStateEvent.RoomBanner); const bannerMXC = bannerState?.getContent()?.url; - const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', useAuthentication); const hasBanner = !!(bannerURI && !hideText && showBanners); const [bannerViewerOpen, setBannerViewerOpen] = useState(false); @@ -388,7 +389,13 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) } }} > - + reportMediaLoadFailure('room_banner')} + /> ; + if (!space || !allRooms.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/app/state/room-list/inviteList.ts b/src/app/state/room-list/inviteList.ts index efd4d6f808..5222f19ac2 100644 --- a/src/app/state/room-list/inviteList.ts +++ b/src/app/state/room-list/inviteList.ts @@ -4,22 +4,15 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { useMemo } from 'react'; import type { RoomsAction } from './utils'; -import { useBindRoomsWithMembershipsAtom } from './utils'; +import { applyRoomsAction, useBindRoomsWithMembershipsAtom } from './utils'; import { KnownMembership } from '$types/matrix-sdk'; const baseRoomsAtom = atom([]); export const allInvitesAtom = atom( (get) => get(baseRoomsAtom), - (get, set, action) => { - if (action.type === 'INITIALIZE') { - set(baseRoomsAtom, action.rooms); - return; - } - set(baseRoomsAtom, (ids) => { - const newIds = ids.filter((id) => id !== action.roomId); - if (action.type === 'PUT') newIds.push(action.roomId); - return newIds; - }); + (_get, set, action) => { + set(baseRoomsAtom, (ids) => applyRoomsAction(ids, action)); + return undefined; } ); diff --git a/src/app/state/room-list/roomList.ts b/src/app/state/room-list/roomList.ts index 240efe828c..96efbf40bf 100644 --- a/src/app/state/room-list/roomList.ts +++ b/src/app/state/room-list/roomList.ts @@ -3,22 +3,15 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { useMemo } from 'react'; import type { RoomsAction } from './utils'; -import { useBindRoomsWithMembershipsAtom } from './utils'; +import { applyRoomsAction, useBindRoomsWithMembershipsAtom } from './utils'; import { KnownMembership } from '$types/matrix-sdk'; const baseRoomsAtom = atom([]); export const allRoomsAtom = atom( (get) => get(baseRoomsAtom), - (get, set, action) => { - if (action.type === 'INITIALIZE') { - set(baseRoomsAtom, action.rooms); - return; - } - set(baseRoomsAtom, (ids) => { - const newIds = ids.filter((id) => id !== action.roomId); - if (action.type === 'PUT') newIds.push(action.roomId); - return newIds; - }); + (_get, set, action) => { + set(baseRoomsAtom, (ids) => applyRoomsAction(ids, action)); + return undefined; } ); export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => { diff --git a/src/app/state/room-list/utils.test.ts b/src/app/state/room-list/utils.test.ts new file mode 100644 index 0000000000..a6fdb5932e --- /dev/null +++ b/src/app/state/room-list/utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { applyRoomsAction } from './utils'; + +describe('applyRoomsAction', () => { + it('applies a page of room changes in one stable update', () => { + const current = ['!one:example.com', '!remove:example.com']; + + expect( + applyRoomsAction(current, { + type: 'BATCH', + put: ['!one:example.com', '!two:example.com', '!three:example.com'], + delete: ['!remove:example.com'], + }) + ).toEqual(['!one:example.com', '!two:example.com', '!three:example.com']); + }); + + it('preserves the existing array when a batch makes no changes', () => { + const current = ['!one:example.com']; + const next = applyRoomsAction(current, { + type: 'BATCH', + put: ['!one:example.com'], + delete: [], + }); + + expect(next).toBe(current); + }); +}); diff --git a/src/app/state/room-list/utils.ts b/src/app/state/room-list/utils.ts index b1184a2641..f0f5074279 100644 --- a/src/app/state/room-list/utils.ts +++ b/src/app/state/room-list/utils.ts @@ -10,10 +10,46 @@ export type RoomsAction = rooms: string[]; } | { - type: 'PUT' | 'DELETE'; + type: 'PUT'; roomId: string; + } + | { + type: 'DELETE'; + roomId: string; + } + | { + type: 'BATCH'; + put: string[]; + delete: string[]; }; +export const applyRoomsAction = (ids: string[], action: RoomsAction): string[] => { + if (action.type === 'INITIALIZE') return [...new Set(action.rooms)]; + + if (action.type === 'PUT') { + if (ids.includes(action.roomId)) return ids; + return [...ids, action.roomId]; + } + + if (action.type === 'DELETE') { + if (!ids.includes(action.roomId)) return ids; + return ids.filter((id) => id !== action.roomId); + } + + const deleted = new Set(action.delete); + const next = deleted.size === 0 ? [...ids] : ids.filter((id) => !deleted.has(id)); + const known = new Set(next); + action.put.forEach((roomId) => { + if (known.has(roomId)) return; + known.add(roomId); + next.push(roomId); + }); + + const unchanged = + next.length === ids.length && next.every((roomId, index) => roomId === ids[index]); + return unchanged ? ids : next; +}; + export const useBindRoomsWithMembershipsAtom = ( mx: MatrixClient, roomsAtom: WritableAtom, @@ -22,6 +58,30 @@ export const useBindRoomsWithMembershipsAtom = ( const setRoomsAtom = useSetAtom(roomsAtom); useEffect(() => { + let flushTimer: ReturnType | undefined; + const pendingRoomActions = new Map(); + + const flushPendingRoomActions = () => { + flushTimer = undefined; + if (pendingRoomActions.size === 0) return; + + const put: string[] = []; + const deleted: string[] = []; + pendingRoomActions.forEach((action, roomId) => { + if (action === 'PUT') put.push(roomId); + else deleted.push(roomId); + }); + pendingRoomActions.clear(); + setRoomsAtom({ type: 'BATCH', put, delete: deleted }); + }; + + const queueRoomAction = (action: 'PUT' | 'DELETE', roomId: string) => { + pendingRoomActions.set(roomId, action); + if (flushTimer === undefined) { + flushTimer = globalThis.setTimeout(flushPendingRoomActions, 0); + } + }; + const satisfyMembership = (room: Room): boolean => !!memberships.find((membership) => membership === room.getMyMembership()); setRoomsAtom({ @@ -34,26 +94,28 @@ export const useBindRoomsWithMembershipsAtom = ( const handleAddRoom = (room: Room) => { if (satisfyMembership(room)) { - setRoomsAtom({ type: 'PUT', roomId: room.roomId }); + queueRoomAction('PUT', room.roomId); } }; const handleMembershipChange = (room: Room) => { if (satisfyMembership(room)) { - setRoomsAtom({ type: 'PUT', roomId: room.roomId }); + queueRoomAction('PUT', room.roomId); } else { - setRoomsAtom({ type: 'DELETE', roomId: room.roomId }); + queueRoomAction('DELETE', room.roomId); } }; const handleDeleteRoom = (roomId: string) => { - setRoomsAtom({ type: 'DELETE', roomId }); + queueRoomAction('DELETE', roomId); }; mx.on(ClientEvent.Room, handleAddRoom); mx.on(RoomEvent.MyMembership, handleMembershipChange); mx.on(ClientEvent.DeleteRoom, handleDeleteRoom); return () => { + if (flushTimer !== undefined) globalThis.clearTimeout(flushTimer); + pendingRoomActions.clear(); mx.removeListener(ClientEvent.Room, handleAddRoom); mx.removeListener(RoomEvent.MyMembership, handleMembershipChange); mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom); diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 24ded9f800..c3fb1cd432 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -9,7 +9,7 @@ import type { ReceiptType, } from '$types/matrix-sdk'; import { RoomEvent, SyncState, EventType, ClientEvent, KnownMembership } from '$types/matrix-sdk'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import type { RoomToUnread, UnreadInfo, Unread } from '$types/matrix/room'; import { NotificationType } from '$types/matrix/room'; import { @@ -19,10 +19,9 @@ import { getUnreadInfos, isNotificationEvent, } from '$utils/room'; -import { useStateEventCallback } from '$hooks/useStateEventCallback'; import { useSyncState } from '$hooks/useSyncState'; import { useRoomsNotificationPreferencesContext } from '$hooks/useRoomsNotificationPreferences'; -import { getClientSyncDiagnostics } from '$client/initMatrix'; +import { getClientSyncDiagnostics, getSlidingSyncManager } from '$client/initMatrix'; import { mDirectAtom } from '$state/mDirectList'; import { roomToParentsAtom } from './roomToParents'; @@ -180,21 +179,45 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo const setUnreadAtom = useSetAtom(unreadAtom); const roomsNotificationPreferences = useRoomsNotificationPreferencesContext(); const mDirects = useAtomValue(mDirectAtom); - const spaceChildResetTimerRef = useRef(null); + const roomToParents = useAtomValue(roomToParentsAtom); const shouldApplyUnreadFixup = useCallback( () => getClientSyncDiagnostics(mx).transport === 'sliding', [mx] ); + const publishUnreadAction = useCallback( + (action: RoomToUnreadAction) => { + if (getClientSyncDiagnostics(mx).transport === 'sliding') { + const manager = getSlidingSyncManager(mx); + if (manager?.isResponseProcessing()) return; + } + setUnreadAtom(action); + }, + [mx, setUnreadAtom] + ); + + useEffect(() => { + const manager = getSlidingSyncManager(mx); + if (!manager) return undefined; + return manager.subscribeToResponseSettled(() => { + setUnreadAtom({ + type: 'RESET', + unreadInfos: getUnreadInfos(mx, { + applyFixup: true, + mDirects, + }), + }); + }); + }, [mx, setUnreadAtom, mDirects]); useEffect(() => { - setUnreadAtom({ + publishUnreadAction({ type: 'RESET', unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup(), mDirects, }), }); - }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); + }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects, roomToParents]); useSyncState( mx, @@ -204,7 +227,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo (state === SyncState.Prepared && prevState === null) || (state === SyncState.Syncing && prevState !== SyncState.Syncing) ) { - setUnreadAtom({ + publishUnreadAction({ type: 'RESET', unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup(), @@ -213,7 +236,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo }); } }, - [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects] + [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects] ) ); @@ -227,7 +250,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo ) => { if (!room || room.isSpaceRoom()) return; if (getNotificationType(mx, room.roomId) === NotificationType.Mute) { - setUnreadAtom({ + publishUnreadAction({ type: 'DELETE', roomId: room.roomId, }); @@ -241,7 +264,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo applyFixup: shouldApplyUnreadFixup(), mDirects, }); - setUnreadAtom({ + publishUnreadAction({ type: 'PUT', unreadInfo, }); @@ -258,7 +281,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo mDirects, }); if (unreadInfo.total > 0 || unreadInfo.highlight > 0) { - setUnreadAtom({ + publishUnreadAction({ type: 'PUT', unreadInfo, }); @@ -269,7 +292,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo return () => { mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); + }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const handleReceipt = (mEvent: MatrixEvent, room: Room) => { @@ -289,17 +312,17 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo mDirects, }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { - setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); + publishUnreadAction({ type: 'DELETE', roomId: room.roomId }); return; } - setUnreadAtom({ type: 'PUT', unreadInfo }); + publishUnreadAction({ type: 'PUT', unreadInfo }); } }; mx.on(RoomEvent.Receipt, handleReceipt); return () => { mx.removeListener(RoomEvent.Receipt, handleReceipt); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); + }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const roomListeners = new Map(); @@ -317,10 +340,10 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo mDirects, }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { - setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); + publishUnreadAction({ type: 'DELETE', roomId: room.roomId }); return; } - setUnreadAtom({ type: 'PUT', unreadInfo }); + publishUnreadAction({ type: 'PUT', unreadInfo }); }; room.on(RoomEvent.UnreadNotifications, handleUnreadNotifications); @@ -336,7 +359,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo room.removeListener(RoomEvent.UnreadNotifications, listener); }); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); + }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const handleRoomAccountData = (mEvent: MatrixEvent, room: Room) => { @@ -348,31 +371,31 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo mDirects, }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { - setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); + publishUnreadAction({ type: 'DELETE', roomId: room.roomId }); return; } - setUnreadAtom({ type: 'PUT', unreadInfo }); + publishUnreadAction({ type: 'PUT', unreadInfo }); }; mx.on(RoomEvent.AccountData, handleRoomAccountData); return () => { mx.removeListener(RoomEvent.AccountData, handleRoomAccountData); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); + }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]); useEffect(() => { - setUnreadAtom({ + publishUnreadAction({ type: 'RESET', unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup(), mDirects, }), }); - }, [mx, setUnreadAtom, roomsNotificationPreferences, shouldApplyUnreadFixup, mDirects]); + }, [mx, publishUnreadAction, roomsNotificationPreferences, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const handleMembershipChange = (room: Room, membership: string) => { if (membership !== (KnownMembership.Join as string)) { - setUnreadAtom({ + publishUnreadAction({ type: 'DELETE', roomId: room.roomId, }); @@ -382,7 +405,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo return () => { mx.removeListener(RoomEvent.MyMembership, handleMembershipChange); }; - }, [mx, setUnreadAtom]); + }, [mx, publishUnreadAction]); // Seed badge state immediately when a room is first registered with the client // (e.g. after joining or receiving an invite that gets auto-accepted). @@ -397,52 +420,12 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo mDirects, }); if (unreadInfo.total > 0 || unreadInfo.highlight > 0) { - setUnreadAtom({ type: 'PUT', unreadInfo }); + publishUnreadAction({ type: 'PUT', unreadInfo }); } }; mx.on(ClientEvent.Room, handleRoomAdded as (room: Room) => void); return () => { mx.removeListener(ClientEvent.Room, handleRoomAdded as (room: Room) => void); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); - - useEffect( - () => () => { - if (spaceChildResetTimerRef.current !== null) { - window.clearTimeout(spaceChildResetTimerRef.current); - spaceChildResetTimerRef.current = null; - } - }, - [] - ); - - useStateEventCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === (EventType.SpaceChild as string)) { - const roomId = mEvent.getRoomId(); - if (!roomId) return; - const parentRoom = mx.getRoom(roomId); - if (!parentRoom || parentRoom.getMyMembership() !== (KnownMembership.Join as string)) - return; - - if (spaceChildResetTimerRef.current !== null) { - window.clearTimeout(spaceChildResetTimerRef.current); - } - spaceChildResetTimerRef.current = window.setTimeout(() => { - setUnreadAtom({ - type: 'RESET', - unreadInfos: getUnreadInfos(mx, { - applyFixup: shouldApplyUnreadFixup(), - mDirects, - }), - }); - spaceChildResetTimerRef.current = null; - }, 150); - } - }, - [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects] - ) - ); + }, [mx, publishUnreadAction, shouldApplyUnreadFixup, mDirects]); }; diff --git a/src/app/theme/cache.ts b/src/app/theme/cache.ts index b10b68c1e4..59b4519bfa 100644 --- a/src/app/theme/cache.ts +++ b/src/app/theme/cache.ts @@ -82,6 +82,30 @@ function openDb(): Promise { }); } +let dbPromise: Promise | undefined; + +function getDb(): Promise { + if (dbPromise) return dbPromise; + const pending = openDb() + .then((db) => { + const forget = () => { + if (dbPromise === pending) dbPromise = undefined; + }; + db.addEventListener('close', forget); + db.addEventListener('versionchange', () => { + forget(); + db.close(); + }); + return db; + }) + .catch((error) => { + if (dbPromise === pending) dbPromise = undefined; + throw error; + }); + dbPromise = pending; + return pending; +} + export async function hashThemeCss(cssText: string): Promise { try { const bytes = new TextEncoder().encode(cssText); @@ -99,7 +123,7 @@ export async function hashThemeCss(cssText: string): Promise { } export async function getCachedThemeEntry(url: string): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).get(url); @@ -109,7 +133,7 @@ export async function getCachedThemeEntry(url: string): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(entry); @@ -196,7 +220,7 @@ export async function revalidateCachedThemeCss( } export async function clearThemeCache(): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).clear(); @@ -206,7 +230,7 @@ export async function clearThemeCache(): Promise { } export async function getThemeCacheStats(): Promise { - const db = await openDb(); + const db = await getDb(); const entries = await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).getAll(); @@ -240,7 +264,7 @@ export async function getThemeCacheStats(): Promise { /** Clears only refetchable network CSS. Uploaded local theme packages are preserved. */ export async function clearRemoteThemeCache(): Promise { - const db = await openDb(); + const db = await getDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); const store = tx.objectStore(STORE); diff --git a/src/app/utils/featureCheck.ts b/src/app/utils/featureCheck.ts index d5564e749d..16a82829ad 100644 --- a/src/app/utils/featureCheck.ts +++ b/src/app/utils/featureCheck.ts @@ -11,6 +11,7 @@ export const checkIndexedDBSupport = async (): Promise => { } db.addEventListener('success', () => { resolve(true); + db.result.close(); indexedDB.deleteDatabase(dbName); }); db.addEventListener('error', () => { diff --git a/src/app/utils/mediaLoadDiagnostics.ts b/src/app/utils/mediaLoadDiagnostics.ts new file mode 100644 index 0000000000..79bf773f89 --- /dev/null +++ b/src/app/utils/mediaLoadDiagnostics.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/react'; +import { createDebugLogger } from './debugLogger'; + +export type MediaLoadKind = 'room_banner' | 'room_card_banner' | 'room_banner_preview'; + +const debugLog = createDebugLogger('media'); +const failureCounts = new Map(); + +export const reportMediaLoadFailure = (kind: MediaLoadKind): void => { + failureCounts.set(kind, (failureCounts.get(kind) ?? 0) + 1); + debugLog.warn('network', 'Media failed to load', { kind }); + Sentry.metrics.count('sable.media.load_error', 1, { attributes: { kind } }); +}; + +export const getMediaLoadFailureCounts = (): ReadonlyMap => + new Map(failureCounts); diff --git a/src/app/utils/presence.ts b/src/app/utils/presence.ts index cb50bab9d0..cbe835f90f 100644 --- a/src/app/utils/presence.ts +++ b/src/app/utils/presence.ts @@ -1,5 +1,6 @@ import type { MatrixClient } from 'matrix-js-sdk'; import { SetPresence } from 'matrix-js-sdk'; +import { getPresenceSyncManager } from '$client/initMatrix'; import { Presence } from '../hooks/useUserPresence'; const PRESENCE_TO_SET_PRESENCE: Record = { @@ -16,8 +17,10 @@ export const setUserPresence = async ( presence: Presence, statusMsg?: string ): Promise => { - Promise.all([ - mx.setSyncPresence(presenceToSetPresence(presence)), + const syncPresence = presenceToSetPresence(presence); + getPresenceSyncManager(mx)?.setPresence(syncPresence); + await Promise.all([ + mx.setSyncPresence(syncPresence), mx.setPresence({ presence, status_msg: statusMsg, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 381739cd16..d2c615df4f 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -457,10 +457,18 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn // If we have no read receipt, SDK counts may be unreliable. Always check timeline. if (!readUpToId) { const liveEvents = room.getLiveTimeline().getEvents(); - - const hasActivity = liveEvents.some( - (event) => event.getSender() !== userId && isNotificationEvent(event, room, userId) - ); + const fullyReadEventId = room + .getAccountData(EventType.FullyRead) + ?.getContent<{ event_id?: string }>()?.event_id; + let hasActivity = false; + for (let i = liveEvents.length - 1; i >= 0; i -= 1) { + const event = liveEvents[i]; + if (!event || event.getId() === fullyReadEventId) break; + if (event.getSender() !== userId && isNotificationEvent(event, room, userId)) { + hasActivity = true; + break; + } + } if (hasActivity) { // If SDK already has counts, use those. Otherwise show dot badge (count=1). diff --git a/src/app/utils/sort.test.ts b/src/app/utils/sort.test.ts index 8f9a413884..a232ea8e58 100644 --- a/src/app/utils/sort.test.ts +++ b/src/app/utils/sort.test.ts @@ -14,14 +14,18 @@ import { } from './sort'; // Minimal stub that satisfies the MatrixClient shape needed by these sort functions. -function makeClient(rooms: Record): MatrixClient { +function makeClient( + rooms: Record +): MatrixClient { return { getRoom: (id: string) => { const r = rooms[id]; if (!r) return null; - return { name: r.name, getLastActiveTimestamp: () => r.ts } as unknown as ReturnType< - MatrixClient['getRoom'] - >; + return { + name: r.name, + getLastActiveTimestamp: () => r.ts, + getBumpStamp: () => r.bumpStamp, + } as unknown as ReturnType; }, } as unknown as MatrixClient; } @@ -63,6 +67,15 @@ describe('factoryRoomIdByActivity', () => { const sort = factoryRoomIdByActivity(mx); expect(['!unknown:h', '!known:h'].toSorted(sort)).toEqual(['!known:h', '!unknown:h']); }); + + it('uses sliding-sync bump stamps when no timeline event is loaded', () => { + const mx = makeClient({ + '!old:h': { name: 'Old', ts: Number.MIN_SAFE_INTEGER, bumpStamp: 1000 }, + '!new:h': { name: 'New', ts: Number.MIN_SAFE_INTEGER, bumpStamp: 9000 }, + }); + const sort = factoryRoomIdByActivity(mx); + expect(['!old:h', '!new:h'].toSorted(sort)).toEqual(['!new:h', '!old:h']); + }); }); describe('factoryRoomIdByAtoZ', () => { diff --git a/src/app/utils/sort.ts b/src/app/utils/sort.ts index 454521df60..0de221b28b 100644 --- a/src/app/utils/sort.ts +++ b/src/app/utils/sort.ts @@ -2,16 +2,19 @@ import type { MatrixClient } from '$types/matrix-sdk'; export type SortFunc = (a: T, b: T) => number; +const getRoomActivity = (mx: MatrixClient, roomId: string): number => { + const room = mx.getRoom(roomId); + if (!room) return Number.MIN_SAFE_INTEGER; + const timelineTimestamp = room.getLastActiveTimestamp(); + return timelineTimestamp === Number.MIN_SAFE_INTEGER + ? (room.getBumpStamp() ?? Number.MIN_SAFE_INTEGER) + : timelineTimestamp; +}; + export const factoryRoomIdByActivity = (mx: MatrixClient): SortFunc => (a, b) => { - const room1 = mx.getRoom(a); - const room2 = mx.getRoom(b); - - return ( - (room2?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) - - (room1?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) - ); + return getRoomActivity(mx, b) - getRoomActivity(mx, a); }; export const factoryRoomIdByAtoZ = diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 1c2d0a4866..fc29e68260 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -4,27 +4,100 @@ import type { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, } from '$types/matrix-sdk'; -import { createClient, IndexedDBStore, IndexedDBCryptoStore } from '$types/matrix-sdk'; +import { + ClientEvent, + createClient, + IndexedDBStore, + IndexedDBCryptoStore, + KnownMembership, + SyncState, +} from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; -import type { Session, Sessions, SessionStoreName } from '$state/sessions'; -import { getSessionStoreName, MATRIX_SESSIONS_KEY } from '$state/sessions'; -import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; +import type { Session, SessionStoreName } from '$state/sessions'; +import { getSessionStoreName } from '$state/sessions'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; import { SessionOidcTokenRefresher } from './oidcTokenRefresher'; import { cryptoCallbacks } from './secretStorageKeys'; -import type { SlidingSyncConfig, SlidingSyncDiagnostics } from './slidingSync'; -import { SlidingSyncManager } from './slidingSync'; +import type { SlidingSyncDiagnostics } from './slidingSync'; +import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; +import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); +const membershipActionCleanupByClient = new WeakMap void>(); const presenceSyncByClient = new WeakMap(); -const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; +const presenceStartCleanupByClient = new WeakMap void>(); +const SLIDING_SYNC_POLL_TIMEOUT_MS = 45000; + +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 & { @@ -33,11 +106,11 @@ type MatrixClientWithWritableFetchRoomEvent = MatrixClient & { const fetchRoomEventStartupCleanupByClient = new WeakMap void>(); -const slidingSyncConnIdCleanupByClient = new WeakMap void>(); +const slidingSyncRequestCleanupByClient = new WeakMap void>(); type SlidingSyncMethod = ( reqBody: MSC3575SlidingSyncRequest, - proxyBaseUrl?: string, + baseUrl?: string, abortSignal?: AbortSignal ) => Promise; @@ -51,22 +124,25 @@ type SlidingSyncRequestWithConnId = MSC3575SlidingSyncRequest & { const SLIDING_SYNC_CONN_ID = 'sable-main'; -function installSlidingSyncConnId(mx: MatrixClient): void { - slidingSyncConnIdCleanupByClient.get(mx)?.(); +function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncManager): void { + slidingSyncRequestCleanupByClient.get(mx)?.(); 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); + + const roomIds = manager.getActiveRoomSubscriptionIds(); + scopeEphemeralExtensions(req.extensions, roomIds); + + return original(reqBody, baseUrl, abortSignal); }; - slidingSyncConnIdCleanupByClient.set(mx, () => { - slidingSyncConnIdCleanupByClient.delete(mx); + slidingSyncRequestCleanupByClient.set(mx, () => { + slidingSyncRequestCleanupByClient.delete(mx); mxWritable.slidingSync = original; }); } @@ -80,11 +156,19 @@ function installStartupFetchRoomEventPatch( const mxWritable = mx as MatrixClientWithWritableFetchRoomEvent; const origFetchRoomEvent = mx.fetchRoomEvent.bind(mx); + let restored = false; const restore = () => { + if (restored) return; + restored = true; fetchRoomEventStartupCleanupByClient.delete(mx); + mx.removeListener(ClientEvent.Sync, onSync); mxWritable.fetchRoomEvent = origFetchRoomEvent; }; + const onSync = (state: SyncState) => { + if (isInitialSyncReady(state)) restore(); + }; + mxWritable.fetchRoomEvent = (roomId: string, eventId: string) => { if (slidingSyncManager.isRoomActive(roomId)) { return origFetchRoomEvent(roomId, eventId); @@ -98,19 +182,9 @@ function installStartupFetchRoomEventPatch( }; fetchRoomEventStartupCleanupByClient.set(mx, restore); + mx.on(ClientEvent.Sync, onSync); } -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); @@ -119,14 +193,6 @@ const deleteDatabase = (name: string): Promise => req.addEventListener('blocked', () => resolve()); }); -const deleteSyncStoreGroup = async (syncStoreName: string): Promise => { - await Promise.all([ - deleteDatabase(syncStoreName), - deleteDatabase(syncStoreName.replace(/^sync/, 'crypto')), - deleteDatabase(`${syncStoreName}::matrix-sdk-crypto`), - ]); -}; - const deleteSessionStores = async (storeName: SessionStoreName): Promise => { await Promise.all([ deleteDatabase(storeName.sync), @@ -135,60 +201,6 @@ const deleteSessionStores = async (storeName: SessionStoreName): Promise = ]); }; -/** - * Reads the account stored in an IndexedDB sync store without opening a full MatrixClient. - * Returns undefined if the database doesn't exist or has no account record. - */ -const readStoredAccount = (dbName: string): Promise => - new Promise((resolve) => { - let settled = false; - const finish = (value: string | undefined) => { - if (settled) return; - settled = true; - resolve(value); - }; - const req = window.indexedDB.open(dbName); - req.addEventListener('error', () => finish(undefined)); - req.addEventListener('success', () => { - const db = req.result; - try { - if (!db.objectStoreNames.contains('account')) { - db.close(); - finish(undefined); - } else { - const tx = db.transaction('account', 'readonly'); - const store = tx.objectStore('account'); - const getReq = store.get('account'); - getReq.addEventListener('success', () => { - db.close(); - const record = getReq.result; - if (!record?.account_data) { - finish(undefined); - } else { - try { - const data = JSON.parse(record.account_data); - finish(data?.user_id ?? undefined); - } catch { - finish(undefined); - } - } - }); - getReq.addEventListener('error', () => { - db.close(); - finish(undefined); - }); - } - } catch { - try { - db.close(); - } catch { - /* ignore */ - } - finish(undefined); - } - }); - }); - const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); return ( @@ -199,75 +211,12 @@ const isMismatch = (err: unknown): boolean => { ); }; -/** - * Pre-flight check: scans every IndexedDB database and deletes any that - * belong to a userId not present in the stored sessions list, or whose - * sync-store data contradicts the expected session userId. - * Call this once on startup before initClient. - */ -export const clearMismatchedStores = async (): Promise => { - const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); - const knownUserIds = new Set(sessions.map((s) => s.userId)); - const knownStoreNames = new Set( - sessions.flatMap((s) => { - const sn = getSessionStoreName(s); - return [sn.sync, sn.crypto, `${sn.rustCryptoPrefix}::matrix-sdk-crypto`]; - }) - ); - - let allDbs: IDBDatabaseInfo[] = []; - try { - allDbs = await window.indexedDB.databases(); - } catch { - // databases() not supported in all browsers - } - - await Promise.all( - allDbs.map(async ({ name }) => { - if (!name) return; - - const containsKnownUser = Array.from(knownUserIds).some((uid) => name.includes(uid)); - const looksLikeUserDb = name.includes('@'); - if (looksLikeUserDb && !containsKnownUser && !knownStoreNames.has(name)) { - log.warn(`clearMismatchedStores: "${name}" has unknown user — deleting`); - await deleteDatabase(name); - return; - } - - if (!name.startsWith('sync')) return; - - const storedUserId = await readStoredAccount(name); - if (!storedUserId) return; - - if (!knownUserIds.has(storedUserId)) { - log.warn(`clearMismatchedStores: "${name}" has unknown user ${storedUserId} — deleting`); - await deleteSyncStoreGroup(name); - return; - } - - const expectedStore = `sync${storedUserId}`; - if (name !== expectedStore && !knownStoreNames.has(name)) { - log.warn(`clearMismatchedStores: "${name}" is misplaced for ${storedUserId} — deleting`); - await deleteSyncStoreGroup(name); - } - }) - ); - - await Promise.all( - sessions.map(async (session) => { - const sn = getSessionStoreName(session); - const storedUserId = await readStoredAccount(sn.sync); - if (storedUserId && storedUserId !== session.userId) { - log.warn( - `clearMismatchedStores: "${sn.sync}" has ${storedUserId} but session is ${session.userId} — deleting` - ); - await deleteSessionStores(sn); - } - }) - ); +type BuiltClient = { + mx: MatrixClient; + indexedDBStore: IndexedDBStore; }; -const buildClient = async (session: Session): Promise => { +const buildClient = (session: Session): BuiltClient => { const storeName = getSessionStoreName(session); const indexedDBStore = new IndexedDBStore({ @@ -297,8 +246,46 @@ const buildClient = async (session: Session): Promise => { : undefined, }); - 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 => { @@ -335,51 +322,54 @@ 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; + initialRoomIds?: Iterable; + onCachedRoomsLoaded?: () => void; }; export type ClientSyncDiagnostics = { @@ -389,13 +379,51 @@ export type ClientSyncDiagnostics = { }; const disposeSlidingSync = (mx: MatrixClient): void => { + membershipActionCleanupByClient.get(mx)?.(); const manager = slidingSyncByClient.get(mx); if (!manager) return; manager.dispose(); slidingSyncByClient.delete(mx); }; +type MatrixClientWithWritableMembershipActions = MatrixClient & { + joinRoom: MatrixClient['joinRoom']; + leave: MatrixClient['leave']; +}; + +const installMembershipActionReconciliation = ( + mx: MatrixClient, + manager: SlidingSyncManager +): void => { + membershipActionCleanupByClient.get(mx)?.(); + + const writableMx = mx as MatrixClientWithWritableMembershipActions; + // Keep the exact methods so cleanup restores the client without leaving bound replacements behind. + // oxlint-disable-next-line typescript/unbound-method + const originalJoinRoom = mx.joinRoom; + // oxlint-disable-next-line typescript/unbound-method + const originalLeave = mx.leave; + + writableMx.joinRoom = async (...args) => { + const room = await originalJoinRoom.apply(mx, args); + manager.reconcileRoomMembership(room.roomId, KnownMembership.Join); + return room; + }; + writableMx.leave = async (...args) => { + const result = await originalLeave.apply(mx, args); + manager.reconcileRoomMembership(args[0], KnownMembership.Leave); + return result; + }; + + membershipActionCleanupByClient.set(mx, () => { + membershipActionCleanupByClient.delete(mx); + writableMx.joinRoom = originalJoinRoom; + writableMx.leave = originalLeave; + }); +}; + const disposePresenceSync = (mx: MatrixClient): void => { + presenceStartCleanupByClient.get(mx)?.(); const manager = presenceSyncByClient.get(mx); if (!manager) return; manager.dispose(); @@ -409,51 +437,60 @@ 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() }); let manager: SlidingSyncManager | undefined; - let presenceManager: PresenceSyncManager | undefined; if (useSliding) { - 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); + startPresenceAfterInitialSync(mx, presenceManager); + + manager = new SlidingSyncManager(mx, baseUrl, { + pollTimeoutMs: config?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, + timelineLimit: config?.timelineLimit, + initialRoomIds: config?.initialRoomIds, }); installStartupFetchRoomEventPatch(mx, manager); - installSlidingSyncConnId(mx); + installSlidingSyncRequestPatch(mx, manager); manager.attach(); + manager.prepareSidebarCacheHydration(); + installMembershipActionReconciliation(mx, manager); slidingSyncByClient.set(mx, manager); - - presenceManager = new PresenceSyncManager(mx); - presenceSyncByClient.set(mx, presenceManager); } try { - await mx.startClient({ - lazyLoadMembers: true, - slidingSync: manager?.slidingSync, - threadSupport: true, - }); - presenceManager?.start(); + await measureStartupPhase( + 'client_start', + () => + mx.startClient({ + lazyLoadMembers: true, + slidingSync: manager?.slidingSync, + threadSupport: true, + }), + { transport: useSliding ? 'sliding' : 'classic' } + ); + if (manager && (await manager.waitForSidebarCacheHydration())) { + config?.onCachedRoomsLoaded?.(); + } } 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, }); + fetchRoomEventStartupCleanupByClient.get(mx)?.(); + slidingSyncRequestCleanupByClient.get(mx)?.(); + membershipActionCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); disposePresenceSync(mx); throw err; @@ -463,7 +500,8 @@ 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() }); - slidingSyncConnIdCleanupByClient.get(mx)?.(); + fetchRoomEventStartupCleanupByClient.get(mx)?.(); + slidingSyncRequestCleanupByClient.get(mx)?.(); disposeSlidingSync(mx); disposePresenceSync(mx); mx.stopClient(); @@ -473,6 +511,7 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { log.log('clearCacheAndReload', mx.getUserId()); stopClient(mx); clearNavToActivePathStore(mx.getSafeUserId()); + SlidingSyncSidebarCache.clear(mx.getSafeUserId()); await mx.store.deleteAllData(); window.location.reload(); }; @@ -548,6 +587,7 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { } if (session) { + SlidingSyncSidebarCache.clear(session.userId); const storeName: SessionStoreName = getSessionStoreName(session); await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); await deleteDatabase(storeName.sync); diff --git a/src/client/presenceSync.ts b/src/client/presenceSync.ts index 3148fd1651..556a314db2 100644 --- a/src/client/presenceSync.ts +++ b/src/client/presenceSync.ts @@ -1,5 +1,12 @@ import type { CryptoBackend, IDeviceLists, IToDeviceEvent, MatrixClient } from '$types/matrix-sdk'; -import { ClientEvent, Filter, Method, processToDeviceMessages, User } from '$types/matrix-sdk'; +import { + ClientEvent, + Filter, + Method, + processToDeviceMessages, + SetPresence, + User, +} from '$types/matrix-sdk'; import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('presenceSync'); @@ -19,6 +26,12 @@ export class PresenceSyncManager { private enabled = true; + private started = false; + + private presence = SetPresence.Online; + + private restartAfterRequest = false; + private activeRequest: Promise | null = null; private abortController: AbortController | undefined; @@ -40,23 +53,40 @@ export class PresenceSyncManager { this.enabled = enabled; if (!enabled) { + this.restartAfterRequest = false; if (this.nextPollTimer !== undefined) clearTimeout(this.nextPollTimer); this.nextPollTimer = undefined; this.abortController?.abort(); return; } - if (!this.activeRequest && !this.disposed) this.poll(); + if (this.started && !this.activeRequest && !this.disposed) this.poll(); + } + + public setPresence(presence: SetPresence = SetPresence.Online): void { + if (this.presence === presence) return; + this.presence = presence; + + if (!this.started || !this.enabled || this.disposed) return; + if (this.activeRequest) { + this.restartAfterRequest = true; + this.abortController?.abort(); + } else { + this.poll(); + } } public start(): void { - if (this.disposed || this.activeRequest || !this.enabled) return; + if (this.disposed || this.started) return; + this.started = true; + if (!this.enabled) return; this.poll(); } public dispose(): void { this.disposed = true; this.enabled = false; + this.started = false; if (this.nextPollTimer !== undefined) clearTimeout(this.nextPollTimer); this.nextPollTimer = undefined; this.abortController?.abort(); @@ -135,7 +165,7 @@ export class PresenceSyncManager { filter: filterId, since: this.syncToken, timeout: this.pollTimeoutMs, - set_presence: 'online', + set_presence: this.presence, }, undefined, { abortSignal: signal } @@ -161,7 +191,10 @@ export class PresenceSyncManager { this.activeRequest = null; this.abortController = undefined; - if (this.enabled && !this.disposed) { + if (this.restartAfterRequest && this.started && this.enabled && !this.disposed) { + this.restartAfterRequest = false; + this.poll(); + } else if (this.started && this.enabled && !this.disposed) { this.nextPollTimer = setTimeout(() => { this.nextPollTimer = undefined; this.poll(); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 4f8f5c80fe..fc7d846d2f 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, KnownMembership, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; -import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync'; +import { scopeEphemeralExtensions, 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 }; @@ -58,6 +61,9 @@ function makeMockMx(overrides: Record = {}) { getSafeUserId: vi.fn<() => string>().mockReturnValue('@user:example.com'), isRoomEncrypted: vi.fn<() => boolean>().mockReturnValue(false), getRoom: vi.fn<() => null>().mockReturnValue(null), + getJoinedRooms: vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockResolvedValue({ + joined_rooms: [], + }), on: vi.fn<() => void>(), off: vi.fn<() => void>(), removeListener: vi.fn<() => void>(), @@ -66,12 +72,460 @@ 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(); + localStorage.clear(); + 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); +} + +function fireRoomData(roomId: string, data: Record = {}) { + const roomDataHandler = mocks.slidingSyncInstance.on.mock.calls + .toReversed() + .find(([event]) => event === SlidingSyncEvent.RoomData)?.[1] as + | ((dataRoomId: string, data: unknown) => void) + | undefined; + roomDataHandler?.(roomId, data); +} + +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'); + const updates = lists.get('updates'); + const defaultSubscription = mocks.slidingSyncConstructorArgs?.[2] as { + timeline_limit: number; + }; + + expect(joined?.ranges).toEqual([[0, 29]]); + expect(joined?.timeline_limit).toBe(1); + expect(joined?.required_state).toHaveLength(8); + expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']); + expect(joined?.required_state).not.toContainEqual(['m.space.child', '*']); + expect(updates).toMatchObject({ + ranges: [[0, 29]], + timeline_limit: 1, + required_state: [[EventType.RoomMember, '$ME']], + filters: { is_invite: false }, + }); + expect(defaultSubscription.timeline_limit).toBe(30); + expect(mocks.slidingSyncConstructorArgs?.[4]).toBe(45000); + }); + + it('settles response processing after post-response work can finish', async () => { + const manager = makeManager(makeMockMx()); + const settled = vi.fn<() => void>(); + manager.subscribeToResponseSettled(settled); + manager.attach(); + + fireLifecycle(SlidingSyncState.RequestFinished, {}); + expect(manager.isResponseProcessing()).toBe(true); + + fireLifecycle(SlidingSyncState.Complete, {}); + expect(manager.isResponseProcessing()).toBe(true); + expect(settled).not.toHaveBeenCalled(); + + await Promise.resolve(); + expect(manager.isResponseProcessing()).toBe(false); + expect(settled).toHaveBeenCalledOnce(); + }); + + it('includes the selected room subscription before the first request', () => { + const manager = new SlidingSyncManager(makeMockMx(), 'https://sliding.example.com', { + initialRoomIds: ['!selected:example.com'], + }); + + expect(manager.isRoomActive('!selected:example.com')).toBe(true); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenCalledWith( + '!selected:example.com', + 'active_room' + ); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith( + new Set(['!selected:example.com']) + ); + }); + + 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, 59]]); + }); + + it('does not expand lists synchronously while adding an active-room subscription', () => { + 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'); + + expect(mocks.slidingSyncInstance.setListRanges).not.toHaveBeenCalledWith('joined', [[0, 59]]); + + 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, 59]]); + }); + + it('prioritizes 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).toHaveBeenCalledWith( + new Set(['!active:example.com']) + ); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenCalledWith( + '!active:example.com', + 'active_room' + ); + expect(diagnostics.lists.find((list) => list.key === 'joined')).toMatchObject({ + rangeEnd: 29, + }); + }); + + it('keeps full lightweight event coverage after narrowing the detailed joined list', () => { + const listRanges = new Map([ + ['joined', [[0, 29]]], + ['updates', [[0, 29]]], + ]); + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' || key === 'updates' ? ({ joinedCount: 45 } as never) : null + ); + mocks.slidingSyncInstance.getListParams.mockImplementation( + (key: string) => ({ ranges: listRanges.get(key) ?? [[0, 29]] }) as never + ); + mocks.slidingSyncInstance.setListRanges.mockImplementation((key, ranges) => { + if (key === 'joined' || key === 'updates') { + listRanges.set(key, ranges as [number, number][]); + } + }); + manager.attach(); + + fireLifecycle(SlidingSyncState.Complete); + expect(listRanges.get('joined')).toEqual([[0, 44]]); + expect(listRanges.get('updates')).toEqual([[0, 44]]); + + fireLifecycle(SlidingSyncState.Complete); + fireLifecycle(SlidingSyncState.Complete); + expect(listRanges.get('joined')).toEqual([[0, 2]]); + expect(listRanges.get('updates')).toEqual([[0, 44]]); + }); + + it('keeps detailed coverage when the homeserver does not provide the updates list', () => { + let joinedRange: [number, number][] = [[0, 29]]; + const manager = makeManager(makeMockMx()); + mocks.slidingSyncInstance.getListData.mockImplementation((key: string) => + key === 'joined' ? ({ joinedCount: 45 } 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); + fireLifecycle(SlidingSyncState.Complete); + + expect(joinedRange).toEqual([[0, 44]]); + }); +}); + +describe('SlidingSyncManager room subscription coordination', () => { + it('replaces route subscriptions atomically without cycling the retained space', () => { + const manager = makeManager(makeMockMx()); + const spaceId = '!space:example.com'; + const oldRoomId = '!old:example.com'; + const newRoomId = '!new:example.com'; + + manager.setActiveRoomSubscriptions([spaceId, oldRoomId]); + mocks.slidingSyncInstance.modifyRoomSubscriptions.mockClear(); + + manager.setActiveRoomSubscriptions([spaceId, newRoomId]); + + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledOnce(); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledWith( + new Set([spaceId, newRoomId]) + ); + expect(manager.isRoomActive(spaceId)).toBe(true); + expect(manager.isRoomActive(oldRoomId)).toBe(false); + expect(manager.isRoomActive(newRoomId)).toBe(true); + }); + + it('reports loading until the subscribed room returns data', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!slow:example.com'; + const loadingStates: boolean[] = []; + + manager.onRoomSubscriptionStatus(roomId, (loading) => loadingStates.push(loading)); + manager.subscribeToRoom(roomId); + + fireRoomData(roomId); + expect(loadingStates).toEqual([false, true]); + expect(manager.isRoomSubscriptionLoading(roomId)).toBe(true); + + manager.attach(); + fireLifecycle(SlidingSyncState.Complete); + + expect(loadingStates).toEqual([false, true, false]); + }); + + it('uses the active subscription while a room is also an image-pack room', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!pack:example.com'; + + manager.setImagePackSubscriptions([roomId]); + manager.subscribeToRoom(roomId); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'active_room' + ); + }); + + it('restores the image-pack subscription when the room is no longer active', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!pack:example.com'; + + manager.setImagePackSubscriptions([roomId]); + manager.subscribeToRoom(roomId); + manager.unsubscribeFromRoom(roomId); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'image_packs' + ); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + }); + + it('keeps space subscriptions active for future hierarchy changes', async () => { + const manager = makeManager(makeMockMx()); + const roomId = '!space:example.com'; + (manager as unknown as { listsFullyLoaded: boolean }).listsFullyLoaded = true; + manager.attach(); + + manager.setSpaceSubscriptions([roomId]); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + + fireRoomData(roomId); + await Promise.resolve(); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + }); + + it('hydrates sidebar state once for rooms first seen after startup', async () => { + const manager = makeManager(makeMockMx()); + const roomId = '!new:example.com'; + ( + manager as unknown as { initialListHydrationCompleted: boolean } + ).initialListHydrationCompleted = true; + manager.attach(); + + fireRoomData(roomId, { initial: true, notification_count: 0, highlight_count: 0 }); + await Promise.resolve(); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'sidebar_room' + ); + + fireRoomData(roomId, { initial: true }); + await Promise.resolve(); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set()); + }); + + it('removes image-pack subscriptions which are no longer configured', () => { + const manager = makeManager(makeMockMx()); + + manager.setImagePackSubscriptions(['!old:example.com', '!current:example.com']); + manager.setImagePackSubscriptions(['!current:example.com']); + + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set(['!current:example.com']) + ); + }); +}); + +describe('scopeEphemeralExtensions', () => { + it('limits typing and receipts to active rooms without changing other extensions', () => { + const extensions = { + typing: { enabled: true }, + receipts: { enabled: true }, + account_data: { enabled: true }, + }; + + scopeEphemeralExtensions(extensions, ['!space:example.com', '!room:example.com']); + + expect(extensions).toEqual({ + typing: { + enabled: true, + lists: [], + rooms: ['!space:example.com', '!room:example.com'], + }, + receipts: { + enabled: true, + lists: [], + rooms: ['!space:example.com', '!room:example.com'], + }, + account_data: { enabled: true }, + }); + }); + + it('uses an empty room scope when no timeline is open', () => { + const extensions = { + typing: { enabled: true, lists: ['joined'], rooms: ['!old:example.com'] }, + receipts: { enabled: true, lists: ['joined'], rooms: ['!old:example.com'] }, + }; + + scopeEphemeralExtensions(extensions, []); + + expect(extensions.typing).toMatchObject({ lists: [], rooms: [] }); + expect(extensions.receipts).toMatchObject({ lists: [], rooms: [] }); + }); +}); + +describe('SlidingSyncManager local membership reconciliation', () => { + it('updates an existing invite immediately after a successful join', () => { + const updateMyMembership = vi.fn<() => void>(); + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({ + updateMyMembership, + }), + }) + ); + + manager.reconcileRoomMembership('!invite:example.com', KnownMembership.Join); + + expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Join); + }); + + it('updates and unsubscribes a room immediately after a successful leave', () => { + const updateMyMembership = vi.fn<() => void>(); + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({ + updateMyMembership, + }), + }) + ); + manager.subscribeToRoom('!room:example.com'); + + manager.reconcileRoomMembership('!room:example.com', KnownMembership.Leave); + + expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Leave); + expect(manager.isRoomActive('!room:example.com')).toBe(false); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set()); + }); + + it('waits for cache hydration and unions authoritative joined rooms before reconciling', async () => { + let finishHydration: ((hydrated: boolean) => void) | undefined; + const hydration = new Promise((resolve) => { + finishHydration = resolve; + }); + const getJoinedRooms = vi.fn<() => Promise<{ joined_rooms: string[] }>>().mockResolvedValue({ + joined_rooms: ['!authoritative:example.com'], + }); + const manager = makeManager(makeMockMx({ getJoinedRooms })); + const internals = manager as unknown as { + cacheHydrationPromise: Promise; + reconcileSidebarCacheMembership: () => void; + serverMembershipRoomIds: Set; + sidebarCache: { reconcileRooms: (roomIds: ReadonlySet) => string[] }; + }; + internals.cacheHydrationPromise = hydration; + internals.serverMembershipRoomIds.add('!observed:example.com'); + const reconcileRooms = vi.spyOn(internals.sidebarCache, 'reconcileRooms').mockReturnValue([]); + + internals.reconcileSidebarCacheMembership(); + expect(getJoinedRooms).not.toHaveBeenCalled(); + + finishHydration?.(true); + await vi.waitFor(() => expect(reconcileRooms).toHaveBeenCalledOnce()); + + expect(getJoinedRooms).toHaveBeenCalledOnce(); + expect(reconcileRooms).toHaveBeenCalledWith( + new Set(['!observed:example.com', '!authoritative:example.com']) + ); + }); + + it('keeps cached rooms when authoritative membership cannot be fetched', async () => { + const getJoinedRooms = vi + .fn<() => Promise<{ joined_rooms: string[] }>>() + .mockRejectedValue(new Error('offline')); + const manager = makeManager(makeMockMx({ getJoinedRooms })); + const internals = manager as unknown as { + reconcileSidebarCacheMembership: () => void; + sidebarCache: { reconcileRooms: (roomIds: ReadonlySet) => string[] }; + }; + const reconcileRooms = vi.spyOn(internals.sidebarCache, 'reconcileRooms').mockReturnValue([]); + + internals.reconcileSidebarCacheMembership(); + await vi.waitFor(() => expect(getJoinedRooms).toHaveBeenCalledOnce()); + + expect(reconcileRooms).not.toHaveBeenCalled(); + }); }); // ── dispose() ──────────────────────────────────────────────────────────────── @@ -109,6 +563,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 +576,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 +588,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 +601,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..eba55eea16 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -1,5 +1,6 @@ import type { MatrixClient, + MatrixEvent, MSC3575List, MSC3575RoomData, MSC3575RoomSubscription, @@ -15,29 +16,36 @@ import { MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_ME, EventType, + EventEmitterEvents, + ClientEvent, } 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'; +import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; const log = createLogger('slidingSync'); const debugLog = createDebugLogger('slidingSync'); export const LIST_JOINED = 'joined'; export const LIST_INVITES = 'invites'; +export const LIST_UPDATES = 'updates'; export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; const LIST_TIMELINE_LIMIT = 1; -const DEFAULT_LIST_PAGE_SIZE = 250; -const DEFAULT_POLL_TIMEOUT_MS = 20000; -const DEFAULT_MAX_ROOMS = 5000; +const LIST_PAGE_SIZE = 30; +const STEADY_STATE_DETAILED_ROOMS = 3; +const DEFAULT_POLL_TIMEOUT_MS = 45000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; -const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; -const ACTIVE_ROOM_TIMELINE_LIMIT = 50; +const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room'; +const SIDEBAR_ROOM_SUBSCRIPTION_KEY = 'sidebar_room'; +const SPACE_SUBSCRIPTION_KEY = 'space'; +const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; +const ACTIVE_ROOM_TIMELINE_LIMIT = 30; export type PartialSlidingSyncRequest = { filters?: MSC3575List['filters']; @@ -45,28 +53,22 @@ export type PartialSlidingSyncRequest = { ranges?: [number, number][]; }; -export type SlidingSyncConfig = { - enabled?: boolean; - proxyBaseUrl?: string; - bootstrapClassicOnColdCache?: boolean; - listPageSize?: number; +type SlidingSyncOptions = { timelineLimit?: number; pollTimeoutMs?: number; - maxRooms?: number; - includeInviteList?: boolean; - probeTimeoutMs?: number; + initialRoomIds?: Iterable; }; export type SlidingSyncListDiagnostics = { key: string; knownCount: number; rangeEnd: number; + requestedRangeEnd: number; }; export type SlidingSyncDiagnostics = { - proxyBaseUrl: string; + baseUrl: string; timelineLimit: number; - listPageSize: number; lists: SlidingSyncListDiagnostics[]; }; @@ -76,23 +78,32 @@ 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], - ['m.space.child', MSC3575_WILDCARD], - [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], - [CustomStateEvent.ImagePack, MSC3575_WILDCARD], - [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], - [CustomStateEvent.RoomAbbreviations, ''], +]; + +const SPACE_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + [EventType.RoomCreate, ''], + [EventType.RoomName, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomJoinRules, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomTombstone, ''], [CustomStateEvent.RoomBanner, ''], + ['m.space.child', MSC3575_WILDCARD], + ['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 +129,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,29 +137,51 @@ 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 buildSidebarRoomSubscription = (): MSC3575RoomSubscription => ({ + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: buildListRequiredState(), +}); + +const buildLists = (): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); - const initialRange = Math.min(pageSize, 100); - lists.set(LIST_JOINED, { - ranges: [[0, Math.max(0, initialRange - 1)]], + ranges: [[0, LIST_PAGE_SIZE - 1]], sort: LIST_SORT_ORDER, timeline_limit: LIST_TIMELINE_LIMIT, required_state: listRequiredState, filters: { is_invite: false }, }); - if (includeInviteList) { - lists.set(LIST_INVITES, { - ranges: [[0, Math.max(0, initialRange - 1)]], - sort: LIST_SORT_ORDER, - timeline_limit: LIST_TIMELINE_LIMIT, - required_state: listRequiredState, - filters: { is_invite: true }, - }); - } + lists.set(LIST_INVITES, { + ranges: [[0, LIST_PAGE_SIZE - 1]], + sort: LIST_SORT_ORDER, + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: listRequiredState, + filters: { is_invite: true }, + }); + + lists.set(LIST_UPDATES, { + ranges: [[0, LIST_PAGE_SIZE - 1]], + sort: LIST_SORT_ORDER, + timeline_limit: LIST_TIMELINE_LIMIT, + required_state: [[EventType.RoomMember, MSC3575_STATE_KEY_ME]], + filters: { is_invite: false }, + }); return lists; }; @@ -158,19 +191,64 @@ const getListEndIndex = (list: MSC3575List | null): number => { return list.ranges.reduce((max, range) => Math.max(max, range[1] ?? -1), -1); }; +type RoomScopedExtension = { + enabled?: boolean; + lists?: string[]; + rooms?: string[]; +}; + +export const scopeEphemeralExtensions = ( + extensions: object | undefined, + roomIds: readonly string[] +): void => { + if (!extensions) return; + const extensionMap = extensions as Record; + + ['typing', 'receipts'].forEach((name) => { + const extension = extensionMap[name]; + if (!extension || typeof extension !== 'object') return; + + const scopedExtension = extension as RoomScopedExtension; + scopedExtension.lists = []; + scopedExtension.rooms = [...roomIds]; + }); +}; + export class SlidingSyncManager { private disposed = false; - private readonly maxRooms: number; - private readonly listKeys: string[]; private readonly activeRoomSubscriptions = new Set(); - private readonly listPageSize: number; + private readonly sidebarRoomSubscriptions = new Set(); + + private readonly spaceSubscriptions = new Set(); + + private deferredSpaceSubscriptions = new Set(); + + private readonly imagePackRoomSubscriptions = new Set(); + + private roomSubscriptionSyncQueued = false; private readonly roomTimelineLimit: number; + private readonly sidebarCache: SlidingSyncSidebarCache; + + private cacheHydrationPromise: Promise = Promise.resolve(false); + + private cacheHydrationNewListener: + | ((eventName: string | symbol, listener: (...args: unknown[]) => void) => void) + | undefined; + + private cacheHydrationResolve: ((hydrated: boolean) => void) | undefined; + + private hydratingSidebarCache = false; + + private readonly onCacheRoomData: (roomId: string, data: MSC3575RoomData) => void; + + private readonly onCacheAccountData: (event: MatrixEvent) => void; + private readonly onLifecycle: ( state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, @@ -184,12 +262,30 @@ export class SlidingSyncManager { private listsFullyLoaded = false; + private initialListHydrationCompleted = false; + + private sidebarCacheReconciled = false; + + private sidebarCacheReconciliationQueued = false; + + private readonly serverMembershipRoomIds = new Set(); + private initialSyncCompleted = false; private syncCount = 0; + private responseProcessingStartedAt: number | undefined; + + private responseProcessing = false; + + private readonly responseSettledListeners = new Set<() => void>(); + 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. @@ -200,6 +296,13 @@ export class SlidingSyncManager { (roomId: string, data: MSC3575RoomData) => void >(); + private readonly roomSubscriptionStatusListeners = new Map< + string, + Set<(loading: boolean) => void> + >(); + + private readonly roomDataAwaitingSyncCompletion = new Set(); + /** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */ private attachTime: number | null = null; @@ -208,44 +311,43 @@ 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); - this.listPageSize = listPageSize; - const includeInviteList = config.includeInviteList !== false; - - const roomTimelineLimit = clampPositive(config.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); + const pollTimeoutMs = clampPositive(options.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS); + + const roomTimelineLimit = clampPositive(options.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; + this.sidebarCache = new SlidingSyncSidebarCache(mx.getSafeUserId()); const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); - const lists = buildLists(listPageSize, includeInviteList); + const lists = buildLists(); 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, + ACTIVE_ROOM_SUBSCRIPTION_KEY, buildUnencryptedSubscription(roomTimelineLimit) ); + this.slidingSync.addCustomSubscription( + SIDEBAR_ROOM_SUBSCRIPTION_KEY, + buildSidebarRoomSubscription() + ); + this.slidingSync.addCustomSubscription( + IMAGE_PACK_SUBSCRIPTION_KEY, + buildImagePackSubscription() + ); + this.slidingSync.addCustomSubscription(SPACE_SUBSCRIPTION_KEY, buildSpaceSubscription()); this.onLifecycle = (state, resp, err) => { - const syncStartTime = performance.now(); - this.syncCount += 1; - Sentry.metrics.count('sable.sync.cycle', 1, { - attributes: { transport: 'sliding', state }, - }); - - debugLog.info('sync', `Sliding sync lifecycle: ${state} (cycle #${this.syncCount})`, { + debugLog.info('sync', `Sliding sync lifecycle: ${state}`, { state, hasError: !!err, - syncNumber: this.syncCount, isInitialSync: !this.initialSyncCompleted, }); @@ -262,12 +364,38 @@ export class SlidingSyncManager { } if (this.disposed) { - debugLog.warn('sync', 'Sync lifecycle called after disposal', { state }); + debugLog.warn('sync', 'Sync lifecycle called after disposal', { + state, + }); + return; + } + + if (state === SlidingSyncState.RequestFinished) { + if (!err && resp) { + this.responseProcessing = true; + this.responseProcessingStartedAt = performance.now(); + } return; } if (err || !resp || state !== SlidingSyncState.Complete) return; + this.recordServerMembershipRooms(resp); + + this.roomDataAwaitingSyncCompletion.forEach((roomId) => + this.notifyRoomSubscriptionStatus(roomId, false) + ); + this.roomDataAwaitingSyncCompletion.clear(); + + this.syncCount += 1; + Sentry.metrics.count('sable.sync.cycle', 1, { + attributes: { transport: 'sliding' }, + }); + + this.requestedListRangeEnds.forEach((rangeEnd, key) => { + this.confirmedListRangeEnds.set(key, rangeEnd); + }); + const changes: Record = {}; let totalRoomCount = 0; let hasChanges = false; @@ -277,7 +405,7 @@ export class SlidingSyncManager { const currentCount = listData?.joinedCount ?? 0; const previousCount = this.previousListCounts.get(key) ?? 0; - totalRoomCount += currentCount; + if (key !== LIST_UPDATES) totalRoomCount += currentCount; if (currentCount !== previousCount) { changes[key] = { @@ -299,7 +427,11 @@ export class SlidingSyncManager { }); } - const syncDuration = performance.now() - syncStartTime; + const syncDuration = + this.responseProcessingStartedAt === undefined + ? 0 + : performance.now() - this.responseProcessingStartedAt; + this.responseProcessingStartedAt = undefined; if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; @@ -324,7 +456,7 @@ export class SlidingSyncManager { this.initialSyncSpan = null; } - this.expandListsToKnownCount(); + this.expandListsByPage(); Sentry.metrics.distribution('sable.sync.processing_ms', syncDuration, { attributes: { transport: 'sliding' }, @@ -336,23 +468,59 @@ export class SlidingSyncManager { totalRoomCount, }); } + + globalThis.queueMicrotask(() => { + if (this.disposed) return; + this.responseProcessing = false; + this.responseSettledListeners.forEach((listener) => listener()); + }); }; this.onMembershipLeave = (_event, member) => { if (member.userId !== this.mx.getUserId()) return; if (member.membership !== KnownMembership.Leave && member.membership !== KnownMembership.Ban) return; - if (!this.activeRoomSubscriptions.has(member.roomId)) return; - this.unsubscribeFromRoom(member.roomId); + this.sidebarCache.removeRoom(member.roomId); + const removedSpaceSubscription = this.spaceSubscriptions.delete(member.roomId); + const removedSidebarSubscription = this.sidebarRoomSubscriptions.delete(member.roomId); + if (this.activeRoomSubscriptions.has(member.roomId)) { + this.unsubscribeFromRoom(member.roomId); + } else if (removedSpaceSubscription || removedSidebarSubscription) { + this.queueRoomSubscriptionSync(); + } }; + + this.onCacheRoomData = (roomId, data) => { + this.sidebarCache.cacheRoom(roomId, data); + + if (!this.initialListHydrationCompleted || this.hydratingSidebarCache) return; + + let subscriptionsChanged = false; + const completedSidebarHydration = this.sidebarRoomSubscriptions.delete(roomId); + if (completedSidebarHydration) subscriptionsChanged = true; + + if ( + data.initial === true && + !completedSidebarHydration && + !this.activeRoomSubscriptions.has(roomId) + ) { + this.sidebarRoomSubscriptions.add(roomId); + subscriptionsChanged = true; + } + + if (subscriptionsChanged) this.queueRoomSubscriptionSync(); + }; + this.onCacheAccountData = (event) => this.sidebarCache.cacheAccountData(event); + + for (const roomId of options.initialRoomIds ?? []) { + this.subscribeToRoom(roomId); + } } public attach(): void { debugLog.info('sync', 'Attaching sliding sync listeners', { - proxyBaseUrl: this.proxyBaseUrl, - listPageSize: this.listPageSize, + baseUrl: this.baseUrl, roomTimelineLimit: this.roomTimelineLimit, - maxRooms: this.maxRooms, lists: this.listKeys, }); @@ -360,11 +528,16 @@ 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); + this.slidingSync.on(SlidingSyncEvent.RoomData, this.onCacheRoomData); this.mx.on(RoomMemberEvent.Membership, this.onMembershipLeave); + this.mx.on(ClientEvent.AccountData, this.onCacheAccountData); debugLog.info('sync', 'Sliding sync listeners attached successfully'); } @@ -378,36 +551,153 @@ export class SlidingSyncManager { }); this.pendingRoomDataListeners.clear(); + this.responseProcessing = false; + this.responseSettledListeners.clear(); + this.roomDataAwaitingSyncCompletion.clear(); + this.roomSubscriptionStatusListeners.forEach((listeners) => + listeners.forEach((listener) => listener(false)) + ); + this.roomSubscriptionStatusListeners.clear(); this.disposed = true; this.slidingSync.stop(); this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, this.onCacheRoomData); + if (this.cacheHydrationNewListener) { + this.slidingSync.removeListener( + EventEmitterEvents.NewListener, + this.cacheHydrationNewListener as never + ); + this.cacheHydrationNewListener = undefined; + } + this.cacheHydrationResolve?.(false); + this.cacheHydrationResolve = undefined; this.mx.removeListener(RoomMemberEvent.Membership, this.onMembershipLeave); + this.mx.removeListener(ClientEvent.AccountData, this.onCacheAccountData); + this.sidebarCache.dispose(); debugLog.info('sync', 'Sliding sync disposed successfully', { totalSyncCycles: this.syncCount, }); } + private recordServerMembershipRooms(response: MSC3575SlidingSyncResponse): void { + const userId = this.mx.getSafeUserId(); + Object.entries(response.rooms ?? {}).forEach(([roomId, roomData]) => { + const membershipEvent = [ + ...(roomData.required_state ?? []), + ...(roomData.invite_state ?? []), + ].find( + (event) => event.type === (EventType.RoomMember as string) && event.state_key === userId + ); + const content = membershipEvent?.content; + const membership = + content && typeof content === 'object' + ? (content as { membership?: unknown }).membership + : undefined; + + if (membership === KnownMembership.Leave || membership === KnownMembership.Ban) { + this.serverMembershipRoomIds.delete(roomId); + } else { + this.serverMembershipRoomIds.add(roomId); + } + }); + } + + private reconcileSidebarCacheMembership(): void { + if (this.sidebarCacheReconciled || this.sidebarCacheReconciliationQueued) return; + this.sidebarCacheReconciliationQueued = true; + + void this.cacheHydrationPromise.then(async () => { + if (this.disposed || this.sidebarCacheReconciled) return; + + let joinedRoomIds: string[]; + try { + const response = await this.mx.getJoinedRooms(); + joinedRoomIds = response.joined_rooms; + } catch (error) { + debugLog.warn('sync', 'Skipped sidebar cache reconciliation: joined rooms unavailable', { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (this.disposed || this.sidebarCacheReconciled) return; + this.sidebarCacheReconciled = true; + + const validRoomIds = new Set([...this.serverMembershipRoomIds, ...joinedRoomIds]); + + const removedRoomIds = this.sidebarCache.reconcileRooms(validRoomIds); + removedRoomIds.forEach((roomId) => { + this.mx.getRoom(roomId)?.updateMyMembership(KnownMembership.Leave); + this.unsubscribeFromRoom(roomId); + }); + + if (removedRoomIds.length > 0) { + debugLog.info('sync', 'Removed stale rooms from the sliding sync sidebar cache', { + removedRoomCount: removedRoomIds.length, + joinedRoomCount: joinedRoomIds.length, + observedRoomCount: this.serverMembershipRoomIds.size, + }); + } + }); + } + public getDiagnostics(): SlidingSyncDiagnostics { return { - proxyBaseUrl: this.proxyBaseUrl, + baseUrl: this.baseUrl, timelineLimit: this.roomTimelineLimit, - listPageSize: this.listPageSize, lists: this.listKeys.map((key) => { const listData = this.slidingSync.getListData(key); const params = this.slidingSync.getListParams(key); return { key, knownCount: listData?.joinedCount ?? 0, - rangeEnd: getListEndIndex(params), + rangeEnd: this.confirmedListRangeEnds.get(key) ?? -1, + requestedRangeEnd: getListEndIndex(params), }; }), }; } - private expandListsToKnownCount(): void { - if (this.listsFullyLoaded) return; + public prepareSidebarCacheHydration(): void { + if (this.disposed || this.cacheHydrationNewListener) return; + + this.cacheHydrationPromise = new Promise((resolve) => { + this.cacheHydrationResolve = resolve; + }); + + this.cacheHydrationNewListener = (eventName) => { + if (eventName !== SlidingSyncEvent.RoomData) return; + const listener = this.cacheHydrationNewListener; + if (listener) { + this.slidingSync.removeListener(EventEmitterEvents.NewListener, listener as never); + } + this.cacheHydrationNewListener = undefined; + + globalThis.queueMicrotask(() => { + this.hydratingSidebarCache = true; + void this.sidebarCache + .hydrate(this.mx, this.slidingSync) + .then((hydrated) => this.cacheHydrationResolve?.(hydrated)) + .catch(() => this.cacheHydrationResolve?.(false)) + .finally(() => { + this.hydratingSidebarCache = false; + this.cacheHydrationResolve = undefined; + }); + }); + }; + + this.slidingSync.on(EventEmitterEvents.NewListener, this.cacheHydrationNewListener as never); + } + + public waitForSidebarCacheHydration(): Promise { + return this.cacheHydrationPromise; + } + + private expandListsByPage(): void { + if (!this.initialSyncCompleted) return; + if (this.initialListHydrationCompleted) return; let allListsComplete = true; let expandedAny = false; @@ -435,9 +725,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 +748,7 @@ export class SlidingSyncManager { allListsComplete = false; - const desiredEnd = maxEnd; + const desiredEnd = Math.min(maxEnd, currentEnd + LIST_PAGE_SIZE); if (desiredEnd === currentEnd) { expansionDetails[key] = { @@ -459,6 +761,7 @@ export class SlidingSyncManager { } this.slidingSync.setListRanges(key, [[0, desiredEnd]]); + this.requestedListRangeEnds.set(key, desiredEnd); expandedAny = true; expansionDetails[key] = { @@ -469,46 +772,39 @@ 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; + this.initialListHydrationCompleted = true; + this.reconcileSidebarCacheMembership(); + globalThis.setTimeout(() => this.flushDeferredSubscriptions(), 0); + this.applySteadyStateListRanges(); + log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); + const totalRooms = + (this.slidingSync.getListData(LIST_JOINED)?.joinedCount ?? 0) + + (this.slidingSync.getListData(LIST_INVITES)?.joinedCount ?? 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()}`); } @@ -530,11 +826,39 @@ export class SlidingSyncManager { } } + private applySteadyStateListRanges(): void { + const joinedList = this.slidingSync.getListParams(LIST_JOINED); + const currentEnd = getListEndIndex(joinedList); + const steadyStateEnd = Math.min(currentEnd, STEADY_STATE_DETAILED_ROOMS - 1); + if (steadyStateEnd < 0 || steadyStateEnd === currentEnd) return; + + const joinedCount = this.slidingSync.getListData(LIST_JOINED)?.joinedCount ?? 0; + const updatesCount = this.slidingSync.getListData(LIST_UPDATES)?.joinedCount ?? 0; + const updatesConfirmedEnd = this.confirmedListRangeEnds.get(LIST_UPDATES) ?? -1; + if (updatesCount !== joinedCount || updatesConfirmedEnd < joinedCount - 1) { + debugLog.warn('sync', 'Kept detailed joined list fully covered: updates list unavailable', { + joinedCount, + updatesCount, + updatesConfirmedEnd, + }); + return; + } + + this.slidingSync.setListRanges(LIST_JOINED, [[0, steadyStateEnd]]); + this.requestedListRangeEnds.set(LIST_JOINED, steadyStateEnd); + debugLog.info('sync', 'Reduced detailed joined list to steady-state window', { + previousEnd: currentEnd, + newEnd: steadyStateEnd, + retainedDetailedRooms: steadyStateEnd + 1, + updatesCoverageEnd: updatesConfirmedEnd, + }); + } + public ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): MSC3575List { let list = this.slidingSync.getListParams(listKey); if (!list) { list = { - ranges: [[0, 20]], + ranges: [[0, LIST_PAGE_SIZE - 1]], sort: LIST_SORT_ORDER, timeline_limit: LIST_TIMELINE_LIMIT, required_state: buildListRequiredState(), @@ -580,7 +904,7 @@ export class SlidingSyncManager { : { is_invite: false }; this.ensureListRegistered(LIST_SPACE, { filters, - ranges: spaceId ? [[0, Math.min(this.listPageSize - 1, 499)]] : [[0, 0]], + ranges: spaceId ? [[0, LIST_PAGE_SIZE - 1]] : [[0, 0]], sort: LIST_SORT_ORDER, }); } @@ -589,18 +913,140 @@ export class SlidingSyncManager { return this.activeRoomSubscriptions.has(roomId); } - public subscribeToRoom(roomId: string): void { + public isResponseProcessing(): boolean { + return this.responseProcessing; + } + + public subscribeToResponseSettled(listener: () => void): () => void { + this.responseSettledListeners.add(listener); + return () => this.responseSettledListeners.delete(listener); + } + + public reconcileRoomMembership( + roomId: string, + membership: KnownMembership.Join | KnownMembership.Leave + ): void { + this.mx.getRoom(roomId)?.updateMyMembership(membership); + + if (membership === KnownMembership.Leave) { + this.sidebarCache.removeRoom(roomId); + const removedSpaceSubscription = this.spaceSubscriptions.delete(roomId); + const removedSidebarSubscription = this.sidebarRoomSubscriptions.delete(roomId); + if (this.activeRoomSubscriptions.has(roomId)) { + this.unsubscribeFromRoom(roomId); + } else if (removedSpaceSubscription || removedSidebarSubscription) { + this.queueRoomSubscriptionSync(); + } + } + } + + private flushDeferredSubscriptions(): void { + if (this.disposed || !this.listsFullyLoaded) return; + + const spaces = this.deferredSpaceSubscriptions; + this.deferredSpaceSubscriptions = new Set(); + this.setSpaceSubscriptions(spaces); + } + + private queueRoomSubscriptionSync(): void { + if (this.disposed || this.roomSubscriptionSyncQueued) return; + this.roomSubscriptionSyncQueued = true; + globalThis.queueMicrotask(() => { + this.roomSubscriptionSyncQueued = false; + if (!this.disposed) this.syncRoomSubscriptions(); + }); + } + + private syncRoomSubscriptions(): void { + const desiredSubscriptions = new Set([ + ...this.activeRoomSubscriptions, + ...this.sidebarRoomSubscriptions, + ...this.spaceSubscriptions, + ...this.imagePackRoomSubscriptions, + ]); + + desiredSubscriptions.forEach((roomId) => { + if (this.activeRoomSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, ACTIVE_ROOM_SUBSCRIPTION_KEY); + } else if (this.sidebarRoomSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SIDEBAR_ROOM_SUBSCRIPTION_KEY); + } else if (this.spaceSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); + } else { + this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY); + } + }); + + this.slidingSync.modifyRoomSubscriptions(desiredSubscriptions); + } + + private pauseUnconfirmedListExpansion(): void { + this.listKeys.forEach((key) => { + const confirmedEnd = this.confirmedListRangeEnds.get(key) ?? -1; + if (confirmedEnd < 0) return; + + const requestedEnd = getListEndIndex(this.slidingSync.getListParams(key)); + if (requestedEnd <= confirmedEnd) return; + + this.slidingSync.setListRanges(key, [[0, confirmedEnd]]); + this.requestedListRangeEnds.set(key, confirmedEnd); + }); + } + + public setImagePackSubscriptions(roomIds: Iterable): void { if (this.disposed) return; + const next = new Set(roomIds); + const unchanged = + next.size === this.imagePackRoomSubscriptions.size && + [...next].every((roomId) => this.imagePackRoomSubscriptions.has(roomId)); + if (unchanged) return; + + this.imagePackRoomSubscriptions.clear(); + next.forEach((roomId) => this.imagePackRoomSubscriptions.add(roomId)); + this.syncRoomSubscriptions(); + } + + /* 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 onRoomSubscriptionStatus( + roomId: string, + listener: (loading: boolean) => void + ): () => void { + const listeners = this.roomSubscriptionStatusListeners.get(roomId) ?? new Set(); + listeners.add(listener); + this.roomSubscriptionStatusListeners.set(roomId, listeners); + listener(this.isRoomSubscriptionLoading(roomId)); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) this.roomSubscriptionStatusListeners.delete(roomId); + }; + } + + public isRoomSubscriptionLoading(roomId: string): boolean { + return ( + this.pendingRoomDataListeners.has(roomId) || this.roomDataAwaitingSyncCompletion.has(roomId) + ); + } + + private notifyRoomSubscriptionStatus(roomId: string, loading: boolean): void { + this.roomSubscriptionStatusListeners.get(roomId)?.forEach((listener) => listener(loading)); + } + + private addActiveRoomSubscription(roomId: string): boolean { + if (this.activeRoomSubscriptions.has(roomId)) return false; 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)); - Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { - attributes: { transport: 'sliding' }, - }); + this.pauseUnconfirmedListExpansion(); log.log(`Sliding Sync active room subscription added: ${roomId}`); debugLog.info('sync', 'Room subscription requested (sliding)', { encrypted: isEncrypted, @@ -624,6 +1070,7 @@ export class SlidingSyncManager { const subscribeMs = performance.now(); const onFirstRoomData = (dataRoomId: string) => { if (dataRoomId !== roomId) return; + if (this.hydratingSidebarCache) return; const latencyMs = Math.round(performance.now() - subscribeMs); const subscribedRoom = this.mx.getRoom(roomId); const eventCount = subscribedRoom?.getLiveTimeline().getEvents().length ?? 0; @@ -646,27 +1093,88 @@ export class SlidingSyncManager { }); this.slidingSync.removeListener(SlidingSyncEvent.RoomData, onFirstRoomData); this.pendingRoomDataListeners.delete(roomId); + this.roomDataAwaitingSyncCompletion.add(roomId); }; this.pendingRoomDataListeners.set(roomId, onFirstRoomData); this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); + this.notifyRoomSubscriptionStatus(roomId, true); + return true; } - public unsubscribeFromRoom(roomId: string): void { - if (this.disposed) return; + private removeActiveRoomSubscription(roomId: string): boolean { + if (!this.activeRoomSubscriptions.has(roomId)) return false; const pendingListener = this.pendingRoomDataListeners.get(roomId); if (pendingListener) { this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); this.pendingRoomDataListeners.delete(roomId); } + this.roomDataAwaitingSyncCompletion.delete(roomId); + this.notifyRoomSubscriptionStatus(roomId, false); this.activeRoomSubscriptions.delete(roomId); - this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); - Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { - attributes: { transport: 'sliding' }, - }); log.log(`Sliding Sync active room subscription removed: ${roomId}`); debugLog.info('sync', 'Room subscription removed (sliding)', { remainingSubscriptions: this.activeRoomSubscriptions.size, syncCycle: this.syncCount, }); + return true; + } + + private reportActiveSubscriptionCount(): void { + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); + } + + public setActiveRoomSubscriptions(roomIds: Iterable): void { + if (this.disposed) return; + const next = new Set(roomIds); + let changed = false; + + this.activeRoomSubscriptions.forEach((roomId) => { + if (!next.has(roomId)) changed = this.removeActiveRoomSubscription(roomId) || changed; + }); + next.forEach((roomId) => { + if (!this.activeRoomSubscriptions.has(roomId)) { + changed = this.addActiveRoomSubscription(roomId) || changed; + } + }); + + if (!changed) return; + this.syncRoomSubscriptions(); + this.reportActiveSubscriptionCount(); + } + + public getActiveRoomSubscriptionIds(): string[] { + return [...this.activeRoomSubscriptions]; + } + + public subscribeToRoom(roomId: string): void { + if (this.disposed || !this.addActiveRoomSubscription(roomId)) return; + this.syncRoomSubscriptions(); + this.reportActiveSubscriptionCount(); + } + + public unsubscribeFromRoom(roomId: string): void { + if (this.disposed || !this.removeActiveRoomSubscription(roomId)) return; + this.syncRoomSubscriptions(); + this.reportActiveSubscriptionCount(); + } + + 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)); + this.syncRoomSubscriptions(); + this.expandListsByPage(); } } diff --git a/src/client/slidingSyncSidebarCache.test.ts b/src/client/slidingSyncSidebarCache.test.ts new file mode 100644 index 0000000000..f288a87f04 --- /dev/null +++ b/src/client/slidingSyncSidebarCache.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk'; +import { EventType, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; +import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; + +const userId = '@user:example.com'; + +const stateEvent = (type: string, stateKey: string, content: Record = {}) => ({ + type, + state_key: stateKey, + content, + event_id: `$${type}-${stateKey}`, + sender: userId, + origin_server_ts: 1, +}); + +beforeEach(() => { + localStorage.clear(); +}); + +describe('SlidingSyncSidebarCache', () => { + it('restores sidebar state without caching timelines or bulk members', async () => { + const cache = new SlidingSyncSidebarCache(userId); + cache.cacheRoom('!room:example.com', { + name: 'Cached Room', + initial: true, + bump_stamp: 42, + required_state: [ + stateEvent(EventType.RoomName, '', { name: 'Cached Room' }), + stateEvent(EventType.SpaceChild, '!child:example.com', { via: ['example.com'] }), + stateEvent(EventType.RoomMember, userId, { membership: 'join' }), + stateEvent(EventType.RoomMember, '@other:example.com', { membership: 'join' }), + ], + timeline: [ + { + type: EventType.RoomMessage, + content: { body: 'do not cache me' }, + event_id: '$message', + sender: '@other:example.com', + origin_server_ts: 2, + }, + ], + } as MSC3575RoomData); + cache.cacheAccountData( + new MatrixEvent({ type: EventType.Direct, content: { '@other:example.com': ['!room'] } }) + ); + cache.cacheAccountData( + new MatrixEvent({ + type: CustomAccountDataEvent.CinnySpaces, + content: { categories: ['!room:example.com'] }, + }) + ); + cache.dispose(); + + const emitPromised = vi + .fn<(event: SlidingSyncEvent, roomId: string, data: MSC3575RoomData) => Promise>() + .mockResolvedValue(true); + const storeAccountDataEvents = vi.fn<(events: MatrixEvent[]) => void>(); + const restored = new SlidingSyncSidebarCache(userId); + const hydrated = await restored.hydrate( + { store: { storeAccountDataEvents } } as unknown as MatrixClient, + { emitPromised } as unknown as SlidingSync + ); + + expect(hydrated).toBe(true); + expect(emitPromised).toHaveBeenCalledWith( + SlidingSyncEvent.RoomData, + '!room:example.com', + expect.objectContaining({ + name: 'Cached Room', + timeline: [], + initial: true, + bump_stamp: 42, + }) + ); + const hydratedRoom = emitPromised.mock.calls[0]?.[2] as MSC3575RoomData; + expect(hydratedRoom.required_state).toHaveLength(3); + expect(hydratedRoom.required_state).toContainEqual( + expect.objectContaining({ + type: EventType.SpaceChild, + state_key: '!child:example.com', + }) + ); + expect(hydratedRoom.required_state).not.toContainEqual( + expect.objectContaining({ state_key: '@other:example.com' }) + ); + expect(storeAccountDataEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ event: expect.objectContaining({ type: EventType.Direct }) }), + expect.objectContaining({ + event: expect.objectContaining({ type: CustomAccountDataEvent.CinnySpaces }), + }), + ]) + ); + }); + + it('merges later state into the existing cached room snapshot', async () => { + const cache = new SlidingSyncSidebarCache(userId); + cache.cacheRoom('!room:example.com', { + name: 'Before', + required_state: [stateEvent(EventType.RoomName, '', { name: 'Before' })], + timeline: [], + }); + cache.cacheRoom('!room:example.com', { + name: 'After', + required_state: [stateEvent(EventType.RoomAvatar, '', { url: 'mxc://example/avatar' })], + timeline: [], + }); + cache.dispose(); + + const emitPromised = vi + .fn<(event: SlidingSyncEvent, roomId: string, data: MSC3575RoomData) => Promise>() + .mockResolvedValue(true); + const restored = new SlidingSyncSidebarCache(userId); + await restored.hydrate( + { + store: { storeAccountDataEvents: vi.fn<(events: MatrixEvent[]) => void>() }, + } as unknown as MatrixClient, + { emitPromised } as unknown as SlidingSync + ); + + const hydratedRoom = emitPromised.mock.calls[0]?.[2] as MSC3575RoomData; + expect(hydratedRoom.name).toBe('After'); + expect(hydratedRoom.required_state).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: EventType.RoomName }), + expect.objectContaining({ type: EventType.RoomAvatar }), + ]) + ); + }); +}); diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts new file mode 100644 index 0000000000..4aad4638d4 --- /dev/null +++ b/src/client/slidingSyncSidebarCache.ts @@ -0,0 +1,223 @@ +import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk'; +import { EventType, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; + +const CACHE_VERSION = 1; +const CACHE_KEY_PREFIX = 'sable.slidingSyncSidebarCache.'; +const CACHE_WRITE_DELAY_MS = 500; +const MAX_CACHED_ROOMS = 2000; +const HYDRATION_BATCH_SIZE = 50; + +type StateEvent = MSC3575RoomData['required_state'][number]; + +type SidebarCacheData = { + version: number; + rooms: Record; + accountData: Record>; +}; + +const CACHED_ACCOUNT_DATA_TYPES = new Set([ + EventType.Direct, + CustomAccountDataEvent.CinnySpaces, +]); + +const CACHED_STATE_TYPES = new Set([ + EventType.RoomCreate, + EventType.RoomName, + EventType.RoomTopic, + EventType.RoomAvatar, + EventType.RoomCanonicalAlias, + EventType.RoomJoinRules, + EventType.RoomHistoryVisibility, + EventType.RoomGuestAccess, + EventType.RoomEncryption, + EventType.RoomTombstone, + EventType.RoomPowerLevels, + EventType.RoomPinnedEvents, + EventType.RoomServerAcl, + EventType.SpaceChild, + EventType.SpaceParent, +]); + +const emptyCache = (): SidebarCacheData => ({ + version: CACHE_VERSION, + rooms: {}, + accountData: {}, +}); + +const stateEventKey = (event: StateEvent): string => `${event.type}\u0000${event.state_key}`; + +const mergeStateEvents = ( + previous: StateEvent[] | undefined, + incoming: StateEvent[] | undefined, + userId: string +): StateEvent[] => { + const merged = new Map(); + previous?.forEach((event) => merged.set(stateEventKey(event), event)); + incoming?.forEach((event) => { + const cacheMember = event.type === 'm.room.member' && event.state_key === userId; + if (cacheMember || CACHED_STATE_TYPES.has(event.type)) { + merged.set(stateEventKey(event), event); + } + }); + return [...merged.values()]; +}; + +const mergeRoomData = ( + previous: MSC3575RoomData | undefined, + incoming: MSC3575RoomData, + userId: string +): MSC3575RoomData => ({ + ...previous, + ...incoming, + name: incoming.name ?? previous?.name ?? '', + heroes: incoming.heroes ?? previous?.heroes, + notification_count: incoming.notification_count ?? previous?.notification_count, + highlight_count: incoming.highlight_count ?? previous?.highlight_count, + joined_count: incoming.joined_count ?? previous?.joined_count, + invited_count: incoming.invited_count ?? previous?.invited_count, + is_dm: incoming.is_dm ?? previous?.is_dm, + bump_stamp: incoming.bump_stamp ?? previous?.bump_stamp, + required_state: mergeStateEvents(previous?.required_state, incoming.required_state, userId), + invite_state: incoming.invite_state + ? mergeStateEvents(previous?.invite_state, incoming.invite_state, userId) + : previous?.invite_state, + timeline: [], + initial: true, + limited: false, + num_live: 0, + prev_batch: undefined, +}); + +const parseCache = (value: string | null): SidebarCacheData => { + if (!value) return emptyCache(); + try { + const parsed = JSON.parse(value) as Partial; + if (parsed.version !== CACHE_VERSION || !parsed.rooms || !parsed.accountData) { + return emptyCache(); + } + return parsed as SidebarCacheData; + } catch { + return emptyCache(); + } +}; + +const hydrateRoomBatch = async ( + slidingSync: SlidingSync, + rooms: [string, MSC3575RoomData][], + startIndex = 0 +): Promise => { + const batch = rooms.slice(startIndex, startIndex + HYDRATION_BATCH_SIZE); + await Promise.all( + batch.map(([roomId, roomData]) => + slidingSync.emitPromised(SlidingSyncEvent.RoomData, roomId, roomData) + ) + ); + if (startIndex + HYDRATION_BATCH_SIZE < rooms.length) { + await hydrateRoomBatch(slidingSync, rooms, startIndex + HYDRATION_BATCH_SIZE); + } +}; + +export class SlidingSyncSidebarCache { + public static clear(userId: string): void { + try { + globalThis.localStorage?.removeItem(`${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`); + } catch { + // Storage can be disabled for this origin. + } + } + + private readonly storageKey: string; + + private data: SidebarCacheData; + + private writeTimer: ReturnType | undefined; + + public constructor(private readonly userId: string) { + this.storageKey = `${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`; + let stored: string | null = null; + try { + stored = globalThis.localStorage?.getItem(this.storageKey) ?? null; + } catch { + // Storage can be disabled for this origin. + } + this.data = parseCache(stored); + } + + public cacheRoom(roomId: string, roomData: MSC3575RoomData): void { + this.data.rooms[roomId] = mergeRoomData(this.data.rooms[roomId], roomData, this.userId); + this.scheduleWrite(); + } + + public removeRoom(roomId: string): void { + if (!this.data.rooms[roomId]) return; + delete this.data.rooms[roomId]; + this.scheduleWrite(); + } + + public reconcileRooms(validRoomIds: ReadonlySet): string[] { + const removedRoomIds = Object.keys(this.data.rooms).filter( + (roomId) => !validRoomIds.has(roomId) + ); + if (removedRoomIds.length === 0) return removedRoomIds; + + removedRoomIds.forEach((roomId) => delete this.data.rooms[roomId]); + this.scheduleWrite(); + return removedRoomIds; + } + + public cacheAccountData(event: MatrixEvent): void { + if (!CACHED_ACCOUNT_DATA_TYPES.has(event.getType())) return; + this.data.accountData[event.getType()] = event.getContent>(); + this.scheduleWrite(); + } + + public async hydrate(mx: MatrixClient, slidingSync: SlidingSync): Promise { + const rooms = Object.entries(this.data.rooms); + const accountData = Object.entries(this.data.accountData); + if (rooms.length === 0 && accountData.length === 0) return false; + + if (accountData.length > 0) { + mx.store.storeAccountDataEvents( + accountData.map(([type, content]) => new MatrixEvent({ type, content })) + ); + } + + await hydrateRoomBatch(slidingSync, rooms); + return rooms.length > 0; + } + + public dispose(): void { + if (this.writeTimer !== undefined) { + globalThis.clearTimeout(this.writeTimer); + this.writeTimer = undefined; + this.write(); + } + } + + private scheduleWrite(): void { + if (this.writeTimer !== undefined) return; + this.writeTimer = globalThis.setTimeout(() => { + this.writeTimer = undefined; + this.write(); + }, CACHE_WRITE_DELAY_MS); + } + + private write(): void { + let rooms = Object.entries(this.data.rooms) + .toSorted(([, a], [, b]) => (b.bump_stamp ?? 0) - (a.bump_stamp ?? 0)) + .slice(0, MAX_CACHED_ROOMS); + + while (true) { + const nextData = { ...this.data, rooms: Object.fromEntries(rooms) }; + try { + globalThis.localStorage?.setItem(this.storageKey, JSON.stringify(nextData)); + this.data = nextData; + return; + } catch { + if (rooms.length <= 1) return; + rooms = rooms.slice(0, Math.floor(rooms.length / 2)); + } + } + } +} diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index 1705580b01..12f41e0712 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -22,6 +22,7 @@ export * from 'matrix-js-sdk/lib/models/user'; export * from 'matrix-js-sdk/lib/models/search-result'; export * from 'matrix-js-sdk/lib/models/event-timeline'; export * from 'matrix-js-sdk/lib/models/event-timeline-set'; +export { EventEmitterEvents } from 'matrix-js-sdk/lib/models/typed-event-emitter'; export { Relations, RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; export * from 'matrix-js-sdk/lib/store/indexeddb';