Files
MultiRoombaRover/webui/src/hooks/useUserIdentitySync.js
T
2026-06-27 04:23:58 -04:00

133 lines
4.8 KiB
JavaScript

// 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]);
}