diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 2190a53f3e..83804b9685 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -485,7 +485,7 @@ export function EmojiBoard({ if (groups.length > 0) { virtualizer.scrollToIndex(0, { align: 'start' }); } - }, [tab, virtualizer, groups]); + }, [tab, virtualizer, groups.length]); return ( { + if (reason === 'sliding_active') return 'Sliding Sync active'; + if (reason === 'sliding_disabled_server') return 'Server-side sliding sync disabled'; + if (reason === 'session_opt_out') return 'Session opt-in is off'; + if (reason === 'missing_proxy') return 'Sliding proxy URL missing'; + if (reason === 'cold_cache_bootstrap') return 'Cold-cache bootstrap (classic for this run)'; + if (reason === 'probe_failed_fallback') return 'Sliding probe failed, using fallback'; + return reason; +}; + type DeveloperToolsProps = { requestClose: () => void; }; @@ -42,7 +52,6 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const mx = useMatrixClient(); const room = useRoom(); - const roomState = useRoomState(room); const accountData = useRoomAccountData(room); @@ -124,7 +133,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { }; }, [mx, room, roomToUnread, allRooms, allInvites]); - const syncDiagnostics = useMemo(() => getClientSyncDiagnostics(mx), [mx]); + const syncDiagnostics = getClientSyncDiagnostics(mx); const handleClose = useCallback(() => { setOpenStateEvent(undefined); @@ -412,6 +421,21 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { Sliding configured:{' '} {syncDiagnostics.slidingConfigured ? 'yes' : 'no'} + + Sliding server-enabled:{' '} + {syncDiagnostics.slidingEnabledOnServer ? 'yes' : 'no'} + + + Sliding session opt-in:{' '} + {syncDiagnostics.sessionOptIn ? 'yes' : 'no'} + + + Sliding requested:{' '} + {syncDiagnostics.slidingRequested ? 'yes' : 'no'} + + + Sync reason: {formatSyncReason(syncDiagnostics.reason)} + Client sync state: {syncDiagnostics.syncState ?? 'null'} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index aa3c88363f..b5339de513 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -428,23 +428,20 @@ export const RoomInput = forwardRef( if (replyDraft) { content['m.relates_to'] = getReplyContent(replyDraft); } - try { - await mx.sendMessage(roomId, content as any); - } catch (error) { - log.error('failed to send message', { roomId }, error); - return; - } - if (replyDraft) { setReplyDraft(undefined); } resetEditor(editor); resetEditorHistory(editor); - setInputKey((prev) => prev + 1); - sendTypingStatus(false); + + try { + await mx.sendMessage(roomId, content as any); + } catch (error) { + log.error('failed to send message', { roomId }, error); + } }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); const handleKeyDown: KeyboardEventHandler = useCallback( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 15c77115dc..96d8c769ff 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -3,6 +3,7 @@ import { Fragment, Dispatch, MouseEventHandler, + ReactNode, RefObject, SetStateAction, useCallback, @@ -17,6 +18,7 @@ import { EventTimeline, EventTimelineSet, EventTimelineSetHandlerMap, + EventStatus, IContent, IRoomTimelineData, MatrixClient, @@ -694,7 +696,7 @@ export function RoomTimeline({ // keep paginating timeline and conditionally mark as read // otherwise we update timeline without paginating // so timeline can be updated with evt like: edits, reactions etc - if (atBottomRef.current) { + if (atBottomRef.current && atLiveEndRef.current) { if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { // Check if the document is in focus (user is actively viewing the app), // and either there are no unread messages or the latest message is from the current user. @@ -729,6 +731,24 @@ export function RoomTimeline({ ) ); + useEffect(() => { + const handleLocalEchoUpdated: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = ( + _mEvent: MatrixEvent, + eventRoom: Room | undefined + ) => { + if (eventRoom?.roomId !== room.roomId) return; + setTimeline((ct) => ({ ...ct })); + if (!unreadInfo) { + setUnreadInfo(getRoomUnreadInfo(room)); + } + }; + + room.on(RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); + return () => { + room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); + }; + }, [room, unreadInfo, setTimeline, setUnreadInfo]); + const handleOpenEvent = useCallback( async ( evtId: string, @@ -1167,6 +1187,15 @@ export function RoomTimeline({ }, [mx, room] ); + + const handleResend = useCallback( + (mEvent: MatrixEvent) => { + if (mEvent.getAssociatedStatus() !== EventStatus.NOT_SENT) return; + mx.resendEvent(mEvent, room).catch(() => undefined); + }, + [mx, room] + ); + const handleEdit = useCallback( (editEvtId?: string) => { if (editEvtId) { @@ -1237,6 +1266,8 @@ export function RoomTimeline({ onReactionToggle={handleReactionToggle} senderId={senderId} senderDisplayName={senderDisplayName} + sendStatus={mEvent.getAssociatedStatus()} + onResend={handleResend} onEditId={handleEdit} activeReplyId={activeReplyId} reply={ @@ -1322,6 +1353,8 @@ export function RoomTimeline({ senderId={senderId} activeReplyId={activeReplyId} senderDisplayName={senderDisplayName} + sendStatus={mEvent.getAssociatedStatus()} + onResend={handleResend} reply={ replyEventId && ( { const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); if (!eventTimeline) return null; @@ -1859,6 +1895,86 @@ export function RoomTimeline({ return eventJSX; }; + let backPaginationJSX: ReactNode | undefined; + if (canPaginateBack || !rangeAtStart) { + if (timelineItems.length === 0) { + backPaginationJSX = + messageLayout === MessageLayout.Compact ? ( + <> + + + + + + + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + ); + } else { + backPaginationJSX =
; + } + } + + let frontPaginationJSX: ReactNode | undefined; + if (!liveTimelineLinked || !rangeAtEnd) { + if (timelineItems.length === 0) { + frontPaginationJSX = + messageLayout === MessageLayout.Compact ? ( + <> + + + + + + + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + ); + } else { + frontPaginationJSX =
; + } + } + return ( {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && ( @@ -1902,73 +2018,11 @@ export function RoomTimeline({
)} - {(canPaginateBack || !rangeAtStart) && - (messageLayout === MessageLayout.Compact ? ( - <> - - - - - - - - - - - - - - - - - ) : ( - <> - - - - - - - - - - - ))} - - {getItems().map(eventRenderer)} - - {(!liveTimelineLinked || !rangeAtEnd) && - (messageLayout === MessageLayout.Compact ? ( - <> - - - - - - - - - - - - - - - - - ) : ( - <> - - - - - - - - - - - ))} + {backPaginationJSX} + + {timelineItems.map(eventRenderer)} + + {frontPaginationJSX} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 66d186e77c..cbf854009a 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -28,7 +28,13 @@ import { } from 'react'; import FocusTrap from 'focus-trap-react'; import { useHover, useFocusWithin } from 'react-aria'; -import { MatrixEvent, Room, Relations, RoomPinnedEventsEventContent } from '$types/matrix-sdk'; +import { + EventStatus, + MatrixEvent, + Room, + Relations, + RoomPinnedEventsEventContent, +} from '$types/matrix-sdk'; import classNames from 'classnames'; import { useAtomValue, useSetAtom } from 'jotai'; import { @@ -219,6 +225,8 @@ export type MessageProps = { senderDisplayName: string; content?: string; activeReplyId?: string | null; + sendStatus?: EventStatus | null; + onResend?: (event: MatrixEvent) => void; }; function useMobileDoubleTap(callback: () => void, delay = 300) { @@ -298,6 +306,8 @@ function MessageInternal( senderId, senderDisplayName, activeReplyId, + sendStatus, + onResend, ...props }: MessageProps & { className?: string; children?: ReactNode }, ref: any @@ -429,11 +439,34 @@ function MessageInternal( ); const stableContent = useMemo(() => mEvent.getContent().body || '', [mEvent]); + const isPendingSend = + sendStatus === EventStatus.ENCRYPTING || + sendStatus === EventStatus.QUEUED || + sendStatus === EventStatus.SENDING; + const isFailedSend = sendStatus === EventStatus.NOT_SENT; + const canResend = isFailedSend && senderId === mx.getUserId() && !!onResend; + + const handleResendClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + onResend?.(mEvent); + }, + [mEvent, onResend] + ); const MSG_CONTENT_STYLE = { maxWidth: '100%' }; const msgContentJSX = ( - + {reply} {edit && onEditId ? ( {children} )} {reactions} + {isPendingSend && ( + + + Sending... + + + )} + {isFailedSend && ( + + + Failed to send. + + {canResend && ( + + )} + + )} ); diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index 4be501bdc1..8b52f526ab 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -55,3 +55,28 @@ export const ReactionsContainer = style({ export const ReactionsTooltipText = style({ wordBreak: 'break-word', }); + +export const MessagePending = style({ + opacity: config.opacity.P500, +}); + +export const MessageFailed = style({ + opacity: config.opacity.P400, +}); + +export const SendStatusRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S200, + marginTop: config.space.S100, +}); + +export const SendStatusButton = style([ + DefaultReset, + { + color: 'inherit', + cursor: 'pointer', + textDecoration: 'underline', + fontSize: toRem(12), + }, +]); diff --git a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx index e6a31dd79d..6c5c1b2702 100644 --- a/src/app/features/settings/developer-tools/SyncDiagnostics.tsx +++ b/src/app/features/settings/developer-tools/SyncDiagnostics.tsx @@ -110,6 +110,16 @@ const formatListCoverage = (knownCount: number, rangeEnd: number): string => { return `${loadedCount}/${knownCount}`; }; +const formatSyncReason = (reason: string): string => { + if (reason === 'sliding_active') return 'Sliding Sync active'; + if (reason === 'sliding_disabled_server') return 'Server-side sliding sync disabled'; + if (reason === 'session_opt_out') return 'Session opt-in is off'; + if (reason === 'missing_proxy') return 'Sliding proxy URL missing'; + if (reason === 'cold_cache_bootstrap') return 'Cold-cache bootstrap (classic for this run)'; + if (reason === 'probe_failed_fallback') return 'Sliding probe failed, using fallback'; + return reason; +}; + export function SyncDiagnostics() { const mx = useMatrixClient(); const [, setTick] = useState(0); @@ -142,6 +152,12 @@ export function SyncDiagnostics() { Sliding configured: {diagnostics.slidingConfigured ? 'yes' : 'no'} + + Sliding server-enabled: {diagnostics.slidingEnabledOnServer ? 'yes' : 'no'} + + Sliding session opt-in: {diagnostics.sessionOptIn ? 'yes' : 'no'} + Sliding requested: {diagnostics.slidingRequested ? 'yes' : 'no'} + Sync reason: {formatSyncReason(diagnostics.reason)} Room counts: {roomDiagnostics.totalRooms} total, {roomDiagnostics.joinedRooms} joined,{' '} {roomDiagnostics.inviteRooms} invites diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7696d96a43..deaf6dc18d 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -22,6 +22,7 @@ export type ClientConfig = { slidingSync?: { enabled?: boolean; proxyBaseUrl?: string; + bootstrapClassicOnColdCache?: boolean; listPageSize?: number; timelineLimit?: number; pollTimeoutMs?: number; diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index 68bfbf5256..6f38e376ae 100644 --- a/src/app/hooks/useImagePacks.ts +++ b/src/app/hooks/useImagePacks.ts @@ -14,6 +14,38 @@ import { useMatrixClient } from './useMatrixClient'; import { useAccountDataCallback } from './useAccountDataCallback'; import { useStateEventCallback } from './useStateEventCallback'; +const imagePackEqual = (a: ImagePack | undefined, b: ImagePack | undefined): boolean => { + if (!a && !b) return true; + if (!a || !b) return false; + const aImages = Array.from(a.images.collection.entries()); + const bImages = Array.from(b.images.collection.entries()); + if (aImages.length !== bImages.length) return false; + const sameImages = aImages.every(([shortcode, image], index) => { + const [otherShortcode, otherImage] = bImages[index]; + if (shortcode !== otherShortcode) return false; + return ( + image.url === otherImage.url && + image.body === otherImage.body && + JSON.stringify(image.usage) === JSON.stringify(otherImage.usage) && + JSON.stringify(image.info) === JSON.stringify(otherImage.info) + ); + }); + if (!sameImages) return false; + return ( + a.id === b.id && + a.deleted === b.deleted && + a.meta.name === b.meta.name && + a.meta.avatar === b.meta.avatar && + a.meta.attribution === b.meta.attribution && + JSON.stringify(a.meta.usage) === JSON.stringify(b.meta.usage) + ); +}; + +const imagePackListEqual = (a: ImagePack[], b: ImagePack[]): boolean => { + if (a.length !== b.length) return false; + return a.every((pack, index) => imagePackEqual(pack, b[index])); +}; + export const useUserImagePack = (): ImagePack | undefined => { const mx = useMatrixClient(); const [userPack, setUserPack] = useState(() => getUserImagePack(mx)); @@ -23,7 +55,10 @@ export const useUserImagePack = (): ImagePack | undefined => { useCallback( (mEvent) => { if (mEvent.getType() === AccountDataEvent.PoniesUserEmotes) { - setUserPack(getUserImagePack(mx)); + setUserPack((prev) => { + const next = getUserImagePack(mx); + return imagePackEqual(prev, next) ? prev : next; + }); } }, [mx] @@ -42,7 +77,10 @@ export const useGlobalImagePacks = (): ImagePack[] => { useCallback( (mEvent) => { if (mEvent.getType() === AccountDataEvent.PoniesEmoteRooms) { - setGlobalPacks(getGlobalImagePacks(mx)); + setGlobalPacks((prev) => { + const next = getGlobalImagePacks(mx); + return imagePackListEqual(prev, next) ? prev : next; + }); } }, [mx] @@ -57,16 +95,18 @@ export const useGlobalImagePacks = (): ImagePack[] => { const roomId = mEvent.getRoomId(); const stateKey = mEvent.getStateKey(); if (eventType === StateEvent.PoniesRoomEmotes && roomId && typeof stateKey === 'string') { - const global = !!globalPacks.find( - (pack) => - pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey - ); - if (global) { - setGlobalPacks(getGlobalImagePacks(mx)); - } + setGlobalPacks((prev) => { + const global = !!prev.find( + (pack) => + pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey + ); + if (!global) return prev; + const next = getGlobalImagePacks(mx); + return imagePackListEqual(prev, next) ? prev : next; + }); } }, - [mx, globalPacks] + [mx] ) ); @@ -86,7 +126,10 @@ export const useRoomImagePack = (room: Room, stateKey: string): ImagePack | unde mEvent.getType() === StateEvent.PoniesRoomEmotes && mEvent.getStateKey() === stateKey ) { - setRoomPack(getRoomImagePack(room, stateKey)); + setRoomPack((prev) => { + const next = getRoomImagePack(room, stateKey); + return imagePackEqual(prev, next) ? prev : next; + }); } }, [room, stateKey] @@ -108,7 +151,10 @@ export const useRoomImagePacks = (room: Room): ImagePack[] => { mEvent.getRoomId() === room.roomId && mEvent.getType() === StateEvent.PoniesRoomEmotes ) { - setRoomPacks(getRoomImagePacks(room)); + setRoomPacks((prev) => { + const next = getRoomImagePacks(room); + return imagePackListEqual(prev, next) ? prev : next; + }); } }, [room] @@ -130,7 +176,10 @@ export const useRoomsImagePacks = (rooms: Room[]) => { rooms.find((room) => room.roomId === mEvent.getRoomId()) && mEvent.getType() === StateEvent.PoniesRoomEmotes ) { - setRoomPacks(rooms.flatMap(getRoomImagePacks)); + setRoomPacks((prev) => { + const next = rooms.flatMap(getRoomImagePacks); + return imagePackListEqual(prev, next) ? prev : next; + }); } }, [rooms] diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index b8911b9ec1..16835d4fe8 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,4 +1,5 @@ import { + ClientEvent, createClient, MatrixClient, IndexedDBStore, @@ -23,12 +24,25 @@ const log = createLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); const FAST_SYNC_POLL_TIMEOUT_MS = 10000; type SyncTransport = 'classic' | 'sliding'; +type SyncTransportReason = + | 'sliding_active' + | 'sliding_disabled_server' + | 'session_opt_out' + | 'missing_proxy' + | 'cold_cache_bootstrap' + | 'probe_failed_fallback' + | 'unknown'; type SyncTransportMeta = { transport: SyncTransport; slidingConfigured: boolean; + slidingEnabledOnServer: boolean; + sessionOptIn: boolean; + slidingRequested: boolean; fallbackFromSliding: boolean; + reason: SyncTransportReason; }; const syncTransportByClient = new WeakMap(); +const COLD_CACHE_BOOTSTRAP_TIMEOUT_MS = 20000; export const resolveSlidingEnabled = (enabled: SlidingSyncConfig['enabled']): boolean => { if (enabled === undefined) return false; @@ -113,6 +127,44 @@ const readStoredAccount = (dbName: string): Promise => }; }); +const databaseExists = async (dbName: string): Promise => { + try { + const dbs = await window.indexedDB.databases(); + return dbs.some((db) => db.name === dbName); + } catch { + return false; + } +}; + +const isClientReadyForUi = (syncState: string | null): boolean => + syncState === 'PREPARED' || syncState === 'SYNCING' || syncState === 'CATCHUP'; + +const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise => + new Promise((resolve) => { + if (isClientReadyForUi(mx.getSyncState())) { + resolve(); + return; + } + + let timer = 0; + let finish = () => {}; + const onSync = (state: string) => { + if (isClientReadyForUi(state)) finish(); + }; + + let settled = false; + finish = () => { + if (settled) return; + settled = true; + mx.removeListener(ClientEvent.Sync, onSync); + clearTimeout(timer); + resolve(); + }; + + timer = window.setTimeout(finish, timeoutMs); + mx.on(ClientEvent.Sync, onSync); + }); + /** * Pre-flight check: scans every IndexedDB database and deletes any that * belong to a userId not present in the stored sessions list, or whose @@ -306,11 +358,15 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) hasSlidingProxy, }); - const startClassicSync = async (fallbackFromSliding: boolean) => { + const startClassicSync = async (fallbackFromSliding: boolean, reason: SyncTransportReason) => { syncTransportByClient.set(mx, { transport: 'classic', slidingConfigured: slidingEnabledOnServer, + slidingEnabledOnServer, + sessionOptIn: config?.sessionSlidingSyncOptIn === true, + slidingRequested, fallbackFromSliding, + reason, }); await mx.startClient({ lazyLoadMembers: true, @@ -318,13 +374,47 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) }); }; + const shouldBootstrapClassicOnColdCache = async (): Promise => { + if (slidingConfig?.bootstrapClassicOnColdCache === false) return false; + const userId = mx.getUserId(); + if (!userId) return false; + + const [storeHasAccount, fallbackStoreHasAccount, hasStoreDb, hasFallbackStoreDb] = + await Promise.all([ + readStoredAccount(`sync${userId}`), + readStoredAccount('web-sync-store'), + databaseExists(`sync${userId}`), + databaseExists('web-sync-store'), + ]); + + const hasWarmCache = + storeHasAccount === userId || + fallbackStoreHasAccount === userId || + hasStoreDb || + hasFallbackStoreDb; + + return !hasWarmCache; + }; + if (!slidingEnabledOnServer || !slidingRequested) { - await startClassicSync(false); + await startClassicSync( + false, + slidingEnabledOnServer ? 'session_opt_out' : 'sliding_disabled_server' + ); return; } if (!hasSlidingProxy) { - await startClassicSync(false); + await startClassicSync(false, 'missing_proxy'); + return; + } + + if (await shouldBootstrapClassicOnColdCache()) { + log.log('startClient cold-cache bootstrap: using classic sync for this run', mx.getUserId()); + await startClassicSync(false, 'cold_cache_bootstrap'); + waitForClientReady(mx, COLD_CACHE_BOOTSTRAP_TIMEOUT_MS).catch(() => { + /* ignore */ + }); return; } @@ -348,7 +438,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) }); if (!supported) { log.warn('Sliding Sync unavailable, falling back to classic sync for', mx.getUserId()); - await startClassicSync(true); + await startClassicSync(true, 'probe_failed_fallback'); return; } @@ -357,7 +447,11 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) syncTransportByClient.set(mx, { transport: 'sliding', slidingConfigured: true, + slidingEnabledOnServer, + sessionOptIn: config?.sessionSlidingSyncOptIn === true, + slidingRequested, fallbackFromSliding: false, + reason: 'sliding_active', }); try { @@ -383,7 +477,11 @@ export const getClientSyncDiagnostics = (mx: MatrixClient): ClientSyncDiagnostic const meta = syncTransportByClient.get(mx) ?? { transport: 'classic', slidingConfigured: false, + slidingEnabledOnServer: false, + sessionOptIn: false, + slidingRequested: false, fallbackFromSliding: false, + reason: 'unknown', }; return { ...meta, diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index d27ab9417b..c948884390 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -16,7 +16,7 @@ const log = createLogger('slidingSync'); const LIST_JOINED = 'joined'; const LIST_INVITES = 'invites'; -const DEFAULT_LIST_PAGE_SIZE = 50; +const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_TIMELINE_LIMIT = 30; const TIMELINE_LIMIT_LOW = 10; const TIMELINE_LIMIT_MEDIUM = 15; @@ -27,6 +27,7 @@ const DEFAULT_MAX_ROOMS = 5000; export type SlidingSyncConfig = { enabled?: boolean; proxyBaseUrl?: string; + bootstrapClassicOnColdCache?: boolean; listPageSize?: number; timelineLimit?: number; pollTimeoutMs?: number;