// Hook: useUserIdentitySync // Purpose: Keeps local identity state synchronized with server session/auth updates. Scope: Handles identity hydration, change propagation, and persistence touch points. import { useCallback, useEffect, useRef } from 'react'; import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx'; import { useSocket } from '../context/SocketContext.jsx'; import { getBrowserFingerprintId } from '../lib/browserFingerprint.js'; import { useSettingsNamespace } from '../settings/index.js'; export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) { const socket = useSocket(); const connected = useSessionSelector((state) => state.connected); const { identifySession } = useSessionActions(); const { value: identity, status: identityStatus, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '', }); const { value: profile, status: profileStatus } = useSettingsNamespace('profile', { nickname: '' }); const { value: overseerPreference, status: overseerPreferenceStatus } = useSettingsNamespace( 'overseerPreference', { enabled: false }, ); const inFlightRef = useRef(false); const lastAckSocketRef = useRef(null); const retryTimerRef = useRef(null); const fingerprintRef = useRef(''); const ready = identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready'; const cookieUserId = (identity?.cookieUserId || '').trim(); const nickname = (profile?.nickname || '').trim(); const overseerEnabled = Boolean(overseerPreference?.enabled); const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive'; const clearRetry = useCallback(() => { if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } }, []); const sendIdentify = useCallback(async () => { if (!ready || !connected || !socket?.id || inFlightRef.current) return; inFlightRef.current = true; try { if (!fingerprintRef.current) { /* The portable cookie key is still the cross-device identity signal. Thumbmark adds a same-device signal that survives cookie clearing, so both are sent together whenever the heartbeat identifies this socket. */ fingerprintRef.current = await getBrowserFingerprintId(); } /* Every route shares the same persisted identity key, but only the main driver page should trigger duplicate-tab prevention. Sending the surface with the heartbeat lets the server make that decision before spectator pages finish their role switch. */ const resp = await identifySession({ cookieUserId, fingerprintId: fingerprintRef.current, nickname, overseerEnabled, identitySurface: normalizedIdentitySurface, }); const nextKey = (resp?.cookieUserId || '').trim(); if (nextKey && nextKey !== cookieUserId) { saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey })); } lastAckSocketRef.current = socket.id; clearRetry(); } catch { clearRetry(); retryTimerRef.current = setTimeout(() => { sendIdentify(); }, 2000); } finally { inFlightRef.current = false; } }, [ clearRetry, connected, cookieUserId, identifySession, normalizedIdentitySurface, nickname, overseerEnabled, ready, saveIdentity, socket?.id, ]); useEffect(() => { if (!ready || !connected || !socket?.id) return; if (lastAckSocketRef.current === socket.id) return; sendIdentify(); }, [connected, ready, sendIdentify, socket?.id]); useEffect(() => { if (!ready || !connected || !socket?.id) return; sendIdentify(); }, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]); useEffect(() => { const handleOnline = () => { if (!socket?.connected) return; sendIdentify(); }; const handleVisibility = () => { if (typeof document === 'undefined' || document.visibilityState !== 'visible') return; if (!socket?.connected) return; sendIdentify(); }; window.addEventListener('online', handleOnline); document.addEventListener('visibilitychange', handleVisibility); return () => { window.removeEventListener('online', handleOnline); document.removeEventListener('visibilitychange', handleVisibility); }; }, [sendIdentify, socket?.connected]); useEffect(() => { if (!ready || !socket?.connected) return undefined; const timer = setInterval(() => { if (!socket?.connected) return; sendIdentify(); }, 60000); return () => clearInterval(timer); }, [ready, sendIdentify, socket?.connected]); useEffect(() => () => clearRetry(), [clearRetry]); }