1- import { useEffect } from 'react' ;
1+ import { useCallback , useEffect , useRef } from 'react' ;
22import { MatrixClient } from '$types/matrix-sdk' ;
3+ import { Session } from '$state/sessions' ;
34import { useAtom } from 'jotai' ;
45import { togglePusher } from '../features/settings/notifications/PushNotifications' ;
56import { appEvents } from '../utils/appEvents' ;
6- import { useClientConfig } from './useClientConfig' ;
7+ import { useClientConfig , useExperimentVariant } from './useClientConfig' ;
78import { useSetting } from '../state/hooks/settings' ;
89import { settingsAtom } from '../state/settings' ;
910import { pushSubscriptionAtom } from '../state/pushSubscription' ;
1011import { mobileOrTablet } from '../utils/user-agent' ;
1112import { createDebugLogger } from '../utils/debugLogger' ;
13+ import { pushSessionToSW } from '../../sw-session' ;
1214
1315const debugLog = createDebugLogger ( 'AppVisibility' ) ;
1416
15- export function useAppVisibility ( mx : MatrixClient | undefined ) {
17+ const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500 ;
18+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000 ;
19+ const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000 ;
20+ const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000 ;
21+
22+ export function useAppVisibility ( mx : MatrixClient | undefined , activeSession ?: Session ) {
1623 const clientConfig = useClientConfig ( ) ;
1724 const [ usePushNotifications ] = useSetting ( settingsAtom , 'usePushNotifications' ) ;
1825 const pushSubAtom = useAtom ( pushSubscriptionAtom ) ;
1926 const isMobile = mobileOrTablet ( ) ;
2027
28+ const sessionSyncConfig = clientConfig . sessionSync ;
29+ const sessionSyncVariant = useExperimentVariant ( 'sessionSyncStrategy' , activeSession ?. userId ) ;
30+
31+ // Derive phase flags from experiment variant; fall back to direct config when not in experiment.
32+ const inSessionSync = sessionSyncVariant . inExperiment ;
33+ const syncVariant = sessionSyncVariant . variant ;
34+ const phase1ForegroundResync = inSessionSync
35+ ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
36+ : sessionSyncConfig ?. phase1ForegroundResync === true ;
37+ const phase2VisibleHeartbeat = inSessionSync
38+ ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
39+ : sessionSyncConfig ?. phase2VisibleHeartbeat === true ;
40+ const phase3AdaptiveBackoffJitter = inSessionSync
41+ ? syncVariant === 'session-sync-adaptive'
42+ : sessionSyncConfig ?. phase3AdaptiveBackoffJitter === true ;
43+
44+ const foregroundDebounceMs = Math . max (
45+ 0 ,
46+ sessionSyncConfig ?. foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS
47+ ) ;
48+ const heartbeatIntervalMs = Math . max (
49+ 1000 ,
50+ sessionSyncConfig ?. heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
51+ ) ;
52+ const resumeHeartbeatSuppressMs = Math . max (
53+ 0 ,
54+ sessionSyncConfig ?. resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS
55+ ) ;
56+ const heartbeatMaxBackoffMs = Math . max (
57+ heartbeatIntervalMs ,
58+ sessionSyncConfig ?. heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS
59+ ) ;
60+
61+ const lastForegroundPushAtRef = useRef ( 0 ) ;
62+ const suppressHeartbeatUntilRef = useRef ( 0 ) ;
63+ const heartbeatFailuresRef = useRef ( 0 ) ;
64+
65+ const pushSessionNow = useCallback (
66+ ( reason : 'foreground' | 'focus' | 'heartbeat' ) : 'sent' | 'skipped' => {
67+ const baseUrl = activeSession ?. baseUrl ;
68+ const accessToken = activeSession ?. accessToken ;
69+ const userId = activeSession ?. userId ;
70+ const canPush =
71+ ! ! mx &&
72+ typeof baseUrl === 'string' &&
73+ typeof accessToken === 'string' &&
74+ typeof userId === 'string' &&
75+ 'serviceWorker' in navigator &&
76+ ! ! navigator . serviceWorker . controller ;
77+
78+ if ( ! canPush ) {
79+ debugLog . warn ( 'network' , 'Skipped SW session sync' , {
80+ reason,
81+ hasClient : ! ! mx ,
82+ hasBaseUrl : ! ! baseUrl ,
83+ hasAccessToken : ! ! accessToken ,
84+ hasUserId : ! ! userId ,
85+ hasSwController : ! ! navigator . serviceWorker ?. controller ,
86+ } ) ;
87+ return 'skipped' ;
88+ }
89+
90+ pushSessionToSW ( baseUrl , accessToken , userId ) ;
91+ debugLog . info ( 'network' , 'Pushed session to SW' , {
92+ reason,
93+ phase1ForegroundResync,
94+ phase2VisibleHeartbeat,
95+ phase3AdaptiveBackoffJitter,
96+ } ) ;
97+ return 'sent' ;
98+ } ,
99+ [
100+ activeSession ?. accessToken ,
101+ activeSession ?. baseUrl ,
102+ activeSession ?. userId ,
103+ mx ,
104+ phase1ForegroundResync ,
105+ phase2VisibleHeartbeat ,
106+ phase3AdaptiveBackoffJitter ,
107+ ]
108+ ) ;
109+
21110 useEffect ( ( ) => {
22111 const handleVisibilityChange = ( ) => {
23112 const isVisible = document . visibilityState === 'visible' ;
@@ -29,15 +118,56 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
29118 appEvents . onVisibilityChange ?.( isVisible ) ;
30119 if ( ! isVisible ) {
31120 appEvents . onVisibilityHidden ?.( ) ;
121+ return ;
122+ }
123+
124+ if ( ! phase1ForegroundResync ) return ;
125+
126+ const now = Date . now ( ) ;
127+ if ( now - lastForegroundPushAtRef . current < foregroundDebounceMs ) return ;
128+ lastForegroundPushAtRef . current = now ;
129+
130+ if (
131+ pushSessionNow ( 'foreground' ) === 'sent' &&
132+ phase3AdaptiveBackoffJitter &&
133+ phase2VisibleHeartbeat
134+ ) {
135+ suppressHeartbeatUntilRef . current = now + resumeHeartbeatSuppressMs ;
136+ }
137+ } ;
138+
139+ const handleFocus = ( ) => {
140+ if ( ! phase1ForegroundResync ) return ;
141+ if ( document . visibilityState !== 'visible' ) return ;
142+
143+ const now = Date . now ( ) ;
144+ if ( now - lastForegroundPushAtRef . current < foregroundDebounceMs ) return ;
145+ lastForegroundPushAtRef . current = now ;
146+
147+ if (
148+ pushSessionNow ( 'focus' ) === 'sent' &&
149+ phase3AdaptiveBackoffJitter &&
150+ phase2VisibleHeartbeat
151+ ) {
152+ suppressHeartbeatUntilRef . current = now + resumeHeartbeatSuppressMs ;
32153 }
33154 } ;
34155
35156 document . addEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
157+ window . addEventListener ( 'focus' , handleFocus ) ;
36158
37159 return ( ) => {
38160 document . removeEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
161+ window . removeEventListener ( 'focus' , handleFocus ) ;
39162 } ;
40- } , [ ] ) ;
163+ } , [
164+ foregroundDebounceMs ,
165+ phase1ForegroundResync ,
166+ phase2VisibleHeartbeat ,
167+ phase3AdaptiveBackoffJitter ,
168+ pushSessionNow ,
169+ resumeHeartbeatSuppressMs ,
170+ ] ) ;
41171
42172 useEffect ( ( ) => {
43173 if ( ! mx ) return ;
@@ -52,4 +182,65 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
52182 appEvents . onVisibilityChange = null ;
53183 } ;
54184 } , [ mx , clientConfig , usePushNotifications , pushSubAtom , isMobile ] ) ;
185+
186+ useEffect ( ( ) => {
187+ if ( ! phase2VisibleHeartbeat ) return undefined ;
188+
189+ // Reset adaptive backoff/suppression so a config or session change starts fresh.
190+ heartbeatFailuresRef . current = 0 ;
191+ suppressHeartbeatUntilRef . current = 0 ;
192+
193+ let timeoutId : number | undefined ;
194+
195+ const getDelayMs = ( ) : number => {
196+ let delay = heartbeatIntervalMs ;
197+
198+ if ( phase3AdaptiveBackoffJitter ) {
199+ const failures = heartbeatFailuresRef . current ;
200+ const backoffFactor = Math . min ( 2 ** failures , heartbeatMaxBackoffMs / heartbeatIntervalMs ) ;
201+ delay = Math . min ( heartbeatMaxBackoffMs , Math . round ( heartbeatIntervalMs * backoffFactor ) ) ;
202+
203+ // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients.
204+ const jitter = 0.8 + Math . random ( ) * 0.4 ;
205+ delay = Math . max ( 1000 , Math . round ( delay * jitter ) ) ;
206+ }
207+
208+ return delay ;
209+ } ;
210+
211+ const tick = ( ) => {
212+ const now = Date . now ( ) ;
213+
214+ if ( document . visibilityState !== 'visible' || ! navigator . onLine ) {
215+ timeoutId = window . setTimeout ( tick , getDelayMs ( ) ) ;
216+ return ;
217+ }
218+
219+ if ( phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef . current ) {
220+ timeoutId = window . setTimeout ( tick , getDelayMs ( ) ) ;
221+ return ;
222+ }
223+
224+ const result = pushSessionNow ( 'heartbeat' ) ;
225+ if ( phase3AdaptiveBackoffJitter ) {
226+ // Only reset on a successful send; 'skipped' (prerequisites not ready)
227+ // should not grow the backoff — those aren't push failures.
228+ if ( result === 'sent' ) heartbeatFailuresRef . current = 0 ;
229+ }
230+
231+ timeoutId = window . setTimeout ( tick , getDelayMs ( ) ) ;
232+ } ;
233+
234+ timeoutId = window . setTimeout ( tick , getDelayMs ( ) ) ;
235+
236+ return ( ) => {
237+ if ( timeoutId !== undefined ) window . clearTimeout ( timeoutId ) ;
238+ } ;
239+ } , [
240+ heartbeatIntervalMs ,
241+ heartbeatMaxBackoffMs ,
242+ phase2VisibleHeartbeat ,
243+ phase3AdaptiveBackoffJitter ,
244+ pushSessionNow ,
245+ ] ) ;
55246}
0 commit comments