9090 * rawAwareness (too large/circular)
9191 */
9292
93- import { produce } from " immer" ;
94- import type { Awareness } from " y-protocols/awareness" ;
93+ import { produce } from ' immer' ;
94+ import type { Awareness } from ' y-protocols/awareness' ;
9595
96- import _logger from " #/utils/logger" ;
96+ import _logger from ' #/utils/logger' ;
9797
9898import type {
9999 AwarenessState ,
100100 AwarenessStore ,
101101 AwarenessUser ,
102102 LocalUserData ,
103- } from " ../types/awareness" ;
103+ } from ' ../types/awareness' ;
104104
105- import { createWithSelector } from " ./common" ;
106- import { wrapStoreWithDevTools } from " ./devtools" ;
105+ import { createWithSelector } from ' ./common' ;
106+ import { wrapStoreWithDevTools } from ' ./devtools' ;
107107
108- const logger = _logger . ns ( " AwarenessStore" ) . seal ( ) ;
108+ const logger = _logger . ns ( ' AwarenessStore' ) . seal ( ) ;
109109
110110/**
111111 * Creates an awareness store instance with useSyncExternalStore + Immer pattern
@@ -131,12 +131,12 @@ export const createAwarenessStore = (): AwarenessStore => {
131131
132132 // Redux DevTools integration
133133 const devtools = wrapStoreWithDevTools ( {
134- name : " AwarenessStore" ,
135- excludeKeys : [ " rawAwareness" ] , // Exclude Y.js Awareness object
134+ name : ' AwarenessStore' ,
135+ excludeKeys : [ ' rawAwareness' ] , // Exclude Y.js Awareness object
136136 maxAge : 200 , // Higher limit since awareness changes are frequent
137137 } ) ;
138138
139- const notify = ( actionName : string = " stateChange" ) => {
139+ const notify = ( actionName : string = ' stateChange' ) => {
140140 devtools . notifyWithAction ( actionName , ( ) => state ) ;
141141 listeners . forEach ( listener => {
142142 listener ( ) ;
@@ -169,22 +169,22 @@ export const createAwarenessStore = (): AwarenessStore => {
169169
170170 awareness . getStates ( ) . forEach ( ( awarenessState , clientId ) => {
171171 // Validate user data structure
172- if ( awarenessState [ " user" ] ) {
172+ if ( awarenessState [ ' user' ] ) {
173173 try {
174174 // Note: We're not using Zod validation here as it's runtime performance critical
175175 // and we trust the awareness protocol more than external API data
176176 const user : AwarenessUser = {
177177 clientId,
178- user : awarenessState [ " user" ] as AwarenessUser [ " user" ] ,
179- cursor : awarenessState [ " cursor" ] as AwarenessUser [ " cursor" ] ,
178+ user : awarenessState [ ' user' ] as AwarenessUser [ ' user' ] ,
179+ cursor : awarenessState [ ' cursor' ] as AwarenessUser [ ' cursor' ] ,
180180 selection : awarenessState [
181- " selection"
182- ] as AwarenessUser [ " selection" ] ,
183- lastSeen : awarenessState [ " lastSeen" ] as number | undefined ,
181+ ' selection'
182+ ] as AwarenessUser [ ' selection' ] ,
183+ lastSeen : awarenessState [ ' lastSeen' ] as number | undefined ,
184184 } ;
185185 users . push ( user ) ;
186186 } catch ( error ) {
187- logger . warn ( " Invalid user data for client" , clientId , error ) ;
187+ logger . warn ( ' Invalid user data for client' , clientId , error ) ;
188188 }
189189 }
190190 } ) ;
@@ -200,7 +200,7 @@ export const createAwarenessStore = (): AwarenessStore => {
200200 */
201201 const handleAwarenessChange = ( ) => {
202202 if ( ! awarenessInstance ) {
203- logger . warn ( " handleAwarenessChange called without awareness instance" ) ;
203+ logger . warn ( ' handleAwarenessChange called without awareness instance' ) ;
204204 return ;
205205 }
206206
@@ -210,7 +210,7 @@ export const createAwarenessStore = (): AwarenessStore => {
210210 draft . users = users ;
211211 draft . lastUpdated = Date . now ( ) ;
212212 } ) ;
213- notify ( " awarenessChange" ) ;
213+ notify ( ' awarenessChange' ) ;
214214 } ;
215215
216216 // =============================================================================
@@ -224,16 +224,16 @@ export const createAwarenessStore = (): AwarenessStore => {
224224 awareness : Awareness ,
225225 userData : LocalUserData
226226 ) => {
227- logger . debug ( " Initializing awareness" , { userData } ) ;
227+ logger . debug ( ' Initializing awareness' , { userData } ) ;
228228
229229 awarenessInstance = awareness ;
230230
231231 // Set up awareness with user data
232- awareness . setLocalStateField ( " user" , userData ) ;
233- awareness . setLocalStateField ( " lastSeen" , Date . now ( ) ) ;
232+ awareness . setLocalStateField ( ' user' , userData ) ;
233+ awareness . setLocalStateField ( ' lastSeen' , Date . now ( ) ) ;
234234
235235 // Set up awareness observer for Pattern 1 updates
236- awareness . on ( " change" , handleAwarenessChange ) ;
236+ awareness . on ( ' change' , handleAwarenessChange ) ;
237237
238238 // Update local state
239239 state = produce ( state , draft => {
@@ -248,17 +248,17 @@ export const createAwarenessStore = (): AwarenessStore => {
248248 handleAwarenessChange ( ) ;
249249
250250 devtools . connect ( ) ;
251- notify ( " initializeAwareness" ) ;
251+ notify ( ' initializeAwareness' ) ;
252252 } ;
253253
254254 /**
255255 * Clean up awareness instance
256256 */
257257 const destroyAwareness = ( ) => {
258- logger . debug ( " Destroying awareness" ) ;
258+ logger . debug ( ' Destroying awareness' ) ;
259259
260260 if ( awarenessInstance ) {
261- awarenessInstance . off ( " change" , handleAwarenessChange ) ;
261+ awarenessInstance . off ( ' change' , handleAwarenessChange ) ;
262262 awarenessInstance = null ;
263263 }
264264
@@ -277,28 +277,28 @@ export const createAwarenessStore = (): AwarenessStore => {
277277 draft . isConnected = false ;
278278 draft . lastUpdated = Date . now ( ) ;
279279 } ) ;
280- notify ( " destroyAwareness" ) ;
280+ notify ( ' destroyAwareness' ) ;
281281 } ;
282282
283283 /**
284284 * Update local user data in awareness
285285 */
286286 const updateLocalUserData = ( userData : Partial < LocalUserData > ) => {
287287 if ( ! awarenessInstance || ! state . localUser ) {
288- logger . warn ( " Cannot update user data - awareness not initialized" ) ;
288+ logger . warn ( ' Cannot update user data - awareness not initialized' ) ;
289289 return ;
290290 }
291291
292292 const updatedUserData = { ...state . localUser , ...userData } ;
293293
294294 // Update awareness first
295- awarenessInstance . setLocalStateField ( " user" , updatedUserData ) ;
295+ awarenessInstance . setLocalStateField ( ' user' , updatedUserData ) ;
296296
297297 // Update local state for immediate UI response
298298 state = produce ( state , draft => {
299299 draft . localUser = updatedUserData ;
300300 } ) ;
301- notify ( " updateLocalUserData" ) ;
301+ notify ( ' updateLocalUserData' ) ;
302302
303303 // Note: awareness observer will also fire and update the users array
304304 } ;
@@ -308,12 +308,12 @@ export const createAwarenessStore = (): AwarenessStore => {
308308 */
309309 const updateLocalCursor = ( cursor : { x : number ; y : number } | null ) => {
310310 if ( ! awarenessInstance ) {
311- logger . warn ( " Cannot update cursor - awareness not initialized" ) ;
311+ logger . warn ( ' Cannot update cursor - awareness not initialized' ) ;
312312 return ;
313313 }
314314
315315 // Update awareness
316- awarenessInstance . setLocalStateField ( " cursor" , cursor ) ;
316+ awarenessInstance . setLocalStateField ( ' cursor' , cursor ) ;
317317
318318 // Immediate local state update for responsiveness
319319 state = produce ( state , draft => {
@@ -330,22 +330,22 @@ export const createAwarenessStore = (): AwarenessStore => {
330330 }
331331 }
332332 } ) ;
333- notify ( " updateLocalCursor" ) ;
333+ notify ( ' updateLocalCursor' ) ;
334334 } ;
335335
336336 /**
337337 * Update local text selection
338338 */
339339 const updateLocalSelection = (
340- selection : AwarenessUser [ " selection" ] | null
340+ selection : AwarenessUser [ ' selection' ] | null
341341 ) => {
342342 if ( ! awarenessInstance ) {
343- logger . warn ( " Cannot update selection - awareness not initialized" ) ;
343+ logger . warn ( ' Cannot update selection - awareness not initialized' ) ;
344344 return ;
345345 }
346346
347347 // Update awareness
348- awarenessInstance . setLocalStateField ( " selection" , selection ) ;
348+ awarenessInstance . setLocalStateField ( ' selection' , selection ) ;
349349
350350 // Immediate local state update for responsiveness
351351 state = produce ( state , draft => {
@@ -362,19 +362,20 @@ export const createAwarenessStore = (): AwarenessStore => {
362362 }
363363 }
364364 } ) ;
365- notify ( " updateLocalSelection" ) ;
365+ notify ( ' updateLocalSelection' ) ;
366366 } ;
367367
368368 /**
369369 * Update last seen timestamp
370+ * @param forceTimestamp - Optional timestamp to use instead of Date.now()
370371 */
371- const updateLastSeen = ( ) => {
372+ const updateLastSeen = ( forceTimestamp ?: number ) => {
372373 if ( ! awarenessInstance ) {
373374 return ;
374375 }
375376
376- const timestamp = Date . now ( ) ;
377- awarenessInstance . setLocalStateField ( " lastSeen" , timestamp ) ;
377+ const timestamp = forceTimestamp ?? Date . now ( ) ;
378+ awarenessInstance . setLocalStateField ( ' lastSeen' , timestamp ) ;
378379
379380 // Note: We don't update local state here as awareness observer will handle it
380381 } ;
@@ -383,19 +384,96 @@ export const createAwarenessStore = (): AwarenessStore => {
383384 * Set up automatic last seen updates
384385 */
385386 const setupLastSeenTimer = ( ) => {
386- if ( lastSeenTimer ) {
387- clearInterval ( lastSeenTimer ) ;
387+ let frozenTimestamp : number | null = null ;
388+
389+ const startTimer = ( ) => {
390+ if ( lastSeenTimer ) {
391+ clearInterval ( lastSeenTimer ) ;
392+ }
393+ lastSeenTimer = setInterval ( ( ) => {
394+ // If page is hidden, use frozen timestamp, otherwise use current time
395+ if ( frozenTimestamp ) frozenTimestamp ++ ; // This is to make sure that state is updated and data gets transmitted
396+ updateLastSeen ( frozenTimestamp ?? undefined ) ;
397+ } , 10000 ) ; // Update every 10 seconds
398+ } ;
399+
400+ const getVisibilityProps = ( ) => {
401+ if ( typeof document . hidden !== 'undefined' ) {
402+ return { hidden : 'hidden' , visibilityChange : 'visibilitychange' } ;
403+ }
404+
405+ if (
406+ // @ts -expect-error webkitHidden not defined
407+ typeof ( document as unknown as Document ) . webkitHidden !== 'undefined'
408+ ) {
409+ return {
410+ hidden : 'webkitHidden' ,
411+ visibilityChange : 'webkitvisibilitychange' ,
412+ } ;
413+ }
414+ // @ts -expect-error mozHidden not defined
415+ if ( typeof ( document as unknown as Document ) . mozHidden !== 'undefined' ) {
416+ return { hidden : 'mozHidden' , visibilityChange : 'mozvisibilitychange' } ;
417+ }
418+ // @ts -expect-error msHidden not defined
419+ if ( typeof ( document as unknown as Document ) . msHidden !== 'undefined' ) {
420+ return { hidden : 'msHidden' , visibilityChange : 'msvisibilitychange' } ;
421+ }
422+ return null ;
423+ } ;
424+
425+ const visibilityProps = getVisibilityProps ( ) ;
426+
427+ const handleVisibilityChange = ( ) => {
428+ if ( ! visibilityProps ) return ;
429+
430+ const isHidden = ( document as unknown as Document ) [
431+ visibilityProps . hidden as keyof Document
432+ ] ;
433+
434+ if ( isHidden ) {
435+ // Page is hidden, freeze the current timestamp
436+ frozenTimestamp = Date . now ( ) ;
437+ } else {
438+ // Page is visible, unfreeze and update immediately
439+ frozenTimestamp = null ;
440+ updateLastSeen ( ) ;
441+ }
442+ } ;
443+
444+ // Set up visibility change listener if supported
445+ if ( visibilityProps ) {
446+ document . addEventListener (
447+ visibilityProps . visibilityChange ,
448+ handleVisibilityChange
449+ ) ;
450+
451+ // Check initial visibility state
452+ const isHidden = ( document as unknown as Document ) [
453+ visibilityProps . hidden as keyof Document
454+ ] ;
455+ if ( isHidden ) {
456+ // Start with frozen timestamp if already hidden
457+ frozenTimestamp = Date . now ( ) ;
458+ }
388459 }
389460
390- lastSeenTimer = setInterval ( ( ) => {
391- updateLastSeen ( ) ;
392- } , 10000 ) ; // Update every 10 seconds
461+ // Always start the timer (whether visible or hidden)
462+ startTimer ( ) ;
393463
464+ // cleanup
394465 return ( ) => {
395466 if ( lastSeenTimer ) {
396467 clearInterval ( lastSeenTimer ) ;
397468 lastSeenTimer = null ;
398469 }
470+
471+ if ( visibilityProps ) {
472+ document . removeEventListener (
473+ visibilityProps . visibilityChange ,
474+ handleVisibilityChange
475+ ) ;
476+ }
399477 } ;
400478 } ;
401479
@@ -410,7 +488,7 @@ export const createAwarenessStore = (): AwarenessStore => {
410488 state = produce ( state , draft => {
411489 draft . isConnected = isConnected ;
412490 } ) ;
413- notify ( " setConnected" ) ;
491+ notify ( ' setConnected' ) ;
414492 } ;
415493
416494 // =============================================================================
0 commit comments