Files
MultiRoombaRover/webui/src/hooks/useUserIdentitySync.js
T
2026-08-07 22:34:04 -04:00

102 lines
4.2 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 } 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 serverCookieUserId = useSessionSelector((state) => state.session?.identity?.cookieUserId || '');
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 { value: audioAdjustments, status: audioAdjustmentsStatus } = useSettingsNamespace('audioAdjustments', {
hornPercent: 0,
ttsPercent: 0,
forwardPercent: 0,
});
const ready =
identityStatus === 'ready'
&& profileStatus === 'ready'
&& overseerPreferenceStatus === 'ready'
&& audioAdjustmentsStatus === 'ready';
const cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim();
const overseerEnabled = Boolean(overseerPreference?.enabled);
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
const sendIdentify = useCallback(async () => {
if (!ready || !connected || !socket?.id) return;
try {
/*
Handshake auth establishes identity before connection handlers run.
This event remains the live-update path for settings that change while
the current transport stays connected, so existing callers and server
behavior do not need a second update contract.
*/
const resp = await identifySession({
cookieUserId,
fingerprintId: await getBrowserFingerprintId(),
nickname,
audioAdjustments,
overseerEnabled,
identitySurface: normalizedIdentitySurface,
});
const nextKey = (resp?.cookieUserId || '').trim();
if (nextKey && nextKey !== cookieUserId) {
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
}
} catch {
/*
A failed live update must not replace Socket.IO's connection lifecycle
with custom retry state. The next reconnect reads the latest persisted
settings through handshake auth and re-establishes the complete identity.
*/
}
}, [
audioAdjustments,
connected,
cookieUserId,
identifySession,
normalizedIdentitySurface,
nickname,
overseerEnabled,
ready,
saveIdentity,
socket,
]);
useEffect(() => {
/*
This runs once when the route's settings become ready and again only when
an identity value changes. Reconnects receive the same data from the auth
callback, so there is intentionally no timer, visibility retry, or online
retry here.
*/
if (!ready || !connected || !socket?.id) return;
sendIdentify();
}, [ready, connected, socket?.id, cookieUserId, nickname, audioAdjustments, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
useEffect(() => {
/*
A first-time browser has no portable key to send in its handshake, so the
canonical identity service creates one. Session sync is authoritative for
that generated value; persisting it here makes every later handshake carry
the same user key without depending on a session:identify acknowledgement.
*/
const nextKey = String(serverCookieUserId || '').trim();
if (!nextKey || nextKey === cookieUserId) return;
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
}, [cookieUserId, saveIdentity, serverCookieUserId]);
}