diff --git a/.changeset/feat-background-pagination.md b/.changeset/feat-background-pagination.md new file mode 100644 index 000000000..9b93320ba --- /dev/null +++ b/.changeset/feat-background-pagination.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add adaptive background pagination for improved message history availability. diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index b7aef9107..1abfd9399 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -1,8 +1,9 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import { Box, Line } from 'folds'; import { useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; import { useAtomValue } from 'jotai'; +import { Direction } from '$types/matrix-sdk'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; @@ -15,6 +16,7 @@ import { useRoomMembers } from '$hooks/useRoomMembers'; import { CallView } from '$features/call/CallView'; import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer'; import { callChatAtom } from '$state/callEmbed'; +import { getBackgroundPaginationConfig } from '$utils/device-capabilities'; import { RoomViewHeader } from './RoomViewHeader'; import { MembersDrawer } from './MembersDrawer'; import { RoomView } from './RoomView'; @@ -45,6 +47,34 @@ export function Room() { ) ); + // Background pagination to load additional message history + useEffect(() => { + const config = getBackgroundPaginationConfig(); + if (!config.enabled) return undefined; + + let cancelled = false; + const timer = setTimeout(() => { + if (cancelled) return; + + const timeline = room.getLiveTimeline(); + const token = timeline.getPaginationToken(Direction.Backward); + + if (token) { + mx.paginateEventTimeline(timeline, { + backwards: true, + limit: config.limit, + }).catch((err) => { + console.warn('Background pagination failed:', err); + }); + } + }, config.delayMs); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [room, mx]); + const callView = room.isCallRoom(); return ( diff --git a/src/app/utils/device-capabilities.ts b/src/app/utils/device-capabilities.ts new file mode 100644 index 000000000..4f0dbc35d --- /dev/null +++ b/src/app/utils/device-capabilities.ts @@ -0,0 +1,119 @@ +/** + * Device performance tier for adaptive features + */ +export type DevicePerformanceTier = 'low' | 'medium' | 'high'; + +/** + * Configuration for background pagination based on device capabilities + */ +export interface BackgroundPaginationConfig { + enabled: boolean; + delayMs: number; + limit: number; +} + +/** + * Adaptive signals from browser APIs for device/network capability detection. + * Shared by sliding sync and background pagination for consistent behavior. + */ +export type AdaptiveSignals = { + saveData: boolean; + effectiveType: string | null; + deviceMemoryGb: number | null; + mobile: boolean; + missingSignals: number; +}; + +/** + * Read adaptive signals from browser APIs. + * Single source of truth for device capability detection across the app. + */ +export function readAdaptiveSignals(): AdaptiveSignals { + const navigatorLike = typeof navigator !== 'undefined' ? navigator : undefined; + const connection = (navigatorLike as any)?.connection; + const effectiveType = connection?.effectiveType; + const deviceMemory = (navigatorLike as any)?.deviceMemory; + const uaMobile = (navigatorLike as any)?.userAgentData?.mobile; + const fallbackMobileUA = navigatorLike?.userAgent ?? ''; + const mobileByUA = + typeof uaMobile === 'boolean' + ? uaMobile + : /Mobi|Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(fallbackMobileUA); + const saveData = connection?.saveData === true; + const normalizedEffectiveType = typeof effectiveType === 'string' ? effectiveType : null; + const normalizedDeviceMemory = typeof deviceMemory === 'number' ? deviceMemory : null; + const missingSignals = + Number(normalizedEffectiveType === null) + Number(normalizedDeviceMemory === null); + + return { + saveData, + effectiveType: normalizedEffectiveType, + deviceMemoryGb: normalizedDeviceMemory, + mobile: mobileByUA, + missingSignals, + }; +} + +/** + * Detect device performance tier based on hardware capabilities + * Uses the same logic as sliding sync for consistency + */ +export function getDevicePerformanceTier(): DevicePerformanceTier { + const signals = readAdaptiveSignals(); + + // Low-end: save data enabled or very slow connection + if (signals.saveData || signals.effectiveType === 'slow-2g' || signals.effectiveType === '2g') { + return 'low'; + } + + // Medium: 3g connection or low memory device + if ( + signals.effectiveType === '3g' || + (signals.deviceMemoryGb !== null && signals.deviceMemoryGb <= 4) + ) { + return 'medium'; + } + + // Medium fallback: mobile with missing signal data + if (signals.mobile && signals.missingSignals > 0) { + return 'medium'; + } + + // High-end: everything else (4g+, desktop, or high memory) + return 'high'; +} + +/** + * Get background pagination configuration based on device capabilities + * Uses the same adaptive detection logic as sliding sync for consistency + */ +export function getBackgroundPaginationConfig(): BackgroundPaginationConfig { + const tier = getDevicePerformanceTier(); + + switch (tier) { + case 'high': + return { + enabled: true, + delayMs: 1000, // 1 second delay + limit: 500, // Load 500 messages + }; + case 'medium': + return { + enabled: true, + delayMs: 2000, // 2 second delay + limit: 250, // Load 250 messages + }; + case 'low': + return { + enabled: true, + delayMs: 3000, // 3 second delay + limit: 100, // Load 100 messages + }; + default: + return { + enabled: false, + delayMs: 0, + limit: 0, + }; + } +} diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index f2c9257dc..c927eb559 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -16,6 +16,7 @@ import { User, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; +import { readAdaptiveSignals, type AdaptiveSignals } from '../app/utils/device-capabilities'; const log = createLogger('slidingSync'); @@ -86,39 +87,6 @@ const clampPositive = (value: number | undefined, fallback: number): number => { return Math.round(value); }; -type AdaptiveSignals = { - saveData: boolean; - effectiveType: string | null; - deviceMemoryGb: number | null; - mobile: boolean; - missingSignals: number; -}; - -const readAdaptiveSignals = (): AdaptiveSignals => { - const navigatorLike = typeof navigator !== 'undefined' ? navigator : undefined; - const connection = (navigatorLike as any)?.connection; - const effectiveType = connection?.effectiveType; - const deviceMemory = (navigatorLike as any)?.deviceMemory; - const uaMobile = (navigatorLike as any)?.userAgentData?.mobile; - const fallbackMobileUA = navigatorLike?.userAgent ?? ''; - const mobileByUA = - typeof uaMobile === 'boolean' - ? uaMobile - : /Mobi|Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(fallbackMobileUA); - const saveData = connection?.saveData === true; - const normalizedEffectiveType = typeof effectiveType === 'string' ? effectiveType : null; - const normalizedDeviceMemory = typeof deviceMemory === 'number' ? deviceMemory : null; - const missingSignals = - Number(normalizedEffectiveType === null) + Number(normalizedDeviceMemory === null); - return { - saveData, - effectiveType: normalizedEffectiveType, - deviceMemoryGb: normalizedDeviceMemory, - mobile: mobileByUA, - missingSignals, - }; -}; - // Resolve the timeline limit for the active-room subscription based on device/network. // The list subscription always uses LIST_TIMELINE_LIMIT=1 regardless of conditions. const resolveAdaptiveRoomTimelineLimit = (