'use client'; import { useCallback, useEffect, useRef } from 'react'; import { AUTH_SESSION_KEY, AUTH_TOKEN_KEY, fetchAuthSession, isPinRequiredError, } from '@/lib/api'; const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'touchstart', 'scroll', 'wheel', 'pointerdown', 'click'] as const; const ACTIVITY_DEBOUNCE_MS = 250; const SERVER_TOUCH_INTERVAL_MS = 30_000; interface UsePinIdleLockOptions { enabled: boolean; timeoutMinutes: number; accessToken: string | null; onLockRequired: (sessionId: string) => void; } export function usePinIdleLock({ enabled, timeoutMinutes, accessToken, onLockRequired, }: UsePinIdleLockOptions) { const onLockRequiredRef = useRef(onLockRequired); const accessTokenRef = useRef(accessToken); const timeoutMsRef = useRef(Math.max(1, timeoutMinutes) * 60_000); const idleDeadlineRef = useRef(Date.now() + timeoutMsRef.current); const idleTimerRef = useRef(null); const activityDebounceRef = useRef(null); const lastServerTouchRef = useRef(0); const checkInFlightRef = useRef(false); const enabledRef = useRef(enabled); useEffect(() => { onLockRequiredRef.current = onLockRequired; }, [onLockRequired]); useEffect(() => { accessTokenRef.current = accessToken; }, [accessToken]); useEffect(() => { enabledRef.current = enabled; }, [enabled]); useEffect(() => { timeoutMsRef.current = Math.max(1, timeoutMinutes) * 60_000; if (enabledRef.current) { idleDeadlineRef.current = Date.now() + timeoutMsRef.current; } }, [timeoutMinutes]); const clearIdleTimer = useCallback(() => { if (idleTimerRef.current !== null) { window.clearTimeout(idleTimerRef.current); idleTimerRef.current = null; } }, []); const syncServerActivity = useCallback(async () => { const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY); if (!token) return; const now = Date.now(); if (now - lastServerTouchRef.current < SERVER_TOUCH_INTERVAL_MS) return; lastServerTouchRef.current = now; try { await fetchAuthSession(token); } catch (error) { if (isPinRequiredError(error)) { onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); } } }, []); const checkIdleLock = useCallback(async () => { if (!enabledRef.current || checkInFlightRef.current) return; const remainingMs = idleDeadlineRef.current - Date.now(); if (remainingMs > 0) { clearIdleTimer(); idleTimerRef.current = window.setTimeout(() => { void checkIdleLock(); }, remainingMs); return; } checkInFlightRef.current = true; clearIdleTimer(); const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY); if (!token) { checkInFlightRef.current = false; return; } try { const session = await fetchAuthSession(token); if (session.requiresPin) { onLockRequiredRef.current(session.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return; } idleDeadlineRef.current = Date.now() + timeoutMsRef.current; clearIdleTimer(); idleTimerRef.current = window.setTimeout(() => { void checkIdleLock(); }, timeoutMsRef.current); } catch (error) { if (isPinRequiredError(error)) { onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return; } idleDeadlineRef.current = Date.now() + timeoutMsRef.current; clearIdleTimer(); idleTimerRef.current = window.setTimeout(() => { void checkIdleLock(); }, timeoutMsRef.current); } finally { checkInFlightRef.current = false; } }, [clearIdleTimer]); const registerActivity = useCallback(() => { if (!enabledRef.current) return; idleDeadlineRef.current = Date.now() + timeoutMsRef.current; clearIdleTimer(); idleTimerRef.current = window.setTimeout(() => { void checkIdleLock(); }, timeoutMsRef.current); void syncServerActivity(); }, [checkIdleLock, clearIdleTimer, syncServerActivity]); const scheduleActivityReset = useCallback(() => { if (activityDebounceRef.current !== null) { window.clearTimeout(activityDebounceRef.current); } activityDebounceRef.current = window.setTimeout(() => { activityDebounceRef.current = null; registerActivity(); }, ACTIVITY_DEBOUNCE_MS); }, [registerActivity]); useEffect(() => { if (!enabled) { clearIdleTimer(); if (activityDebounceRef.current !== null) { window.clearTimeout(activityDebounceRef.current); activityDebounceRef.current = null; } return; } idleDeadlineRef.current = Date.now() + timeoutMsRef.current; lastServerTouchRef.current = Date.now(); void checkIdleLock(); for (const eventName of ACTIVITY_EVENTS) { window.addEventListener(eventName, scheduleActivityReset, { passive: true }); } function handleVisibilityChange() { if (document.visibilityState !== 'visible') { clearIdleTimer(); return; } void checkIdleLock(); } document.addEventListener('visibilitychange', handleVisibilityChange); return () => { clearIdleTimer(); if (activityDebounceRef.current !== null) { window.clearTimeout(activityDebounceRef.current); activityDebounceRef.current = null; } for (const eventName of ACTIVITY_EVENTS) { window.removeEventListener(eventName, scheduleActivityReset); } document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [checkIdleLock, clearIdleTimer, enabled, scheduleActivityReset]); }