import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; import { AlarmSocket, DEFAULT_RECONNECT_POLICY, type AlarmFrame, type SocketState, } from '../services/alarmSocket'; import { alarmSocketUrl, type ResolvedConnection } from '../services/connection'; import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi'; export type LiveDelivery = 'socket' | 'polling' | 'unavailable'; export interface UseAlarmSocketOptions { resolved: ResolvedConnection | null; alertId: string | null; subscribeToken: string | null; /** Poll cadence while the app is foregrounded (spec: 5s). */ foregroundPollMs?: number; /** Poll cadence while backgrounded (spec: 30s). */ backgroundPollMs?: number; onTrigger: (frame: AlarmFrame) => void; onDisarm: (frame: AlarmFrame) => void; enabled?: boolean; /** This device's ledger id (the service hashes it to address the debt). */ deviceId?: string | null; /** The service answered 404: the alert is destroyed, there is nothing to ring. */ onGone?: () => void; } export interface UseAlarmSocketResult { socketState: SocketState; delivery: LiveDelivery; attempts: number; lastStatus: AlarmStatus | null; /** Force an immediate status read (used by pull-to-refresh and Settings). */ refresh: () => Promise; } /** * Owns the live-alarm transport for one subscribed alert. * * The WebSocket is the delivery path: triggers arrive only over * `ws(s)://{instance}/api/alarm/ws/alarm`. `delivery: 'unavailable'` means no * instance is configured, and the UI must say this device is not listening * rather than implying coverage. The status poll below is a display * reconciliation only — it never becomes a delivery claim. */ export function useAlarmSocket({ resolved, alertId, subscribeToken, foregroundPollMs = 5000, backgroundPollMs = 30000, onTrigger, onDisarm, enabled = true, deviceId, onGone, }: UseAlarmSocketOptions): UseAlarmSocketResult { const [socketState, setSocketState] = useState('idle'); const [attempts, setAttempts] = useState(0); const [lastStatus, setLastStatus] = useState(null); const [appState, setAppState] = useState(AppState.currentState); const socketRef = useRef(null); const triggerRef = useRef(onTrigger); const disarmRef = useRef(onDisarm); triggerRef.current = onTrigger; disarmRef.current = onDisarm; const url = useMemo( () => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null), [resolved, alertId, subscribeToken], ); /** Capability, not liveness: the URL exists. Liveness is `socketState`. */ const delivery: LiveDelivery = url ? 'socket' : 'unavailable'; /** * A check-in can carry the durable "fired while you were offline" record * (409 `ERR_ALARM_TRIGGERED_OFFLINE`, mapped to a success-shaped status by * `alarmStatus`). Ack it over the socket when one is open, then let the * caller's status effect raise the takeover; a closed socket is fine because * the service clears the pending entry on the check-in response itself. */ const onGoneRef = useRef(undefined); onGoneRef.current = onGone; const applyStatus = useCallback((status: AlarmStatus) => { setLastStatus(status); if (status.offlineTrigger && status.eventId) { socketRef.current?.ack(status.eventId); } }, []); const refresh = useCallback(async () => { if (!alertId) return; try { applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined)); } catch (e) { if (isAlertGone(e)) onGoneRef.current?.(); // A failed status read is not fatal — the socket is the primary path and // the next poll retries. } }, [alertId, applyStatus, deviceId]); // AppState awareness: while backgrounded the JS socket is unreliable, so the // poll cadence relaxes to the background value and the socket is left to the // native foreground service (which holds its own connection). useEffect(() => { const sub = AppState.addEventListener('change', next => setAppState(next)); return () => sub.remove(); }, []); useEffect(() => { if (!enabled || !url || !alertId || !subscribeToken) { setSocketState('idle'); setAttempts(0); return; } const socket = new AlarmSocket({ url, policy: DEFAULT_RECONNECT_POLICY, onStateChange: (state, nextAttempts) => { setSocketState(state); setAttempts(nextAttempts); }, onAlarm: frame => { if (frame.action === 'trigger') triggerRef.current(frame); else disarmRef.current(frame); }, }); socketRef.current = socket; socket.connect(); return () => { socket.close(); socketRef.current = null; }; }, [enabled, url, alertId, subscribeToken]); // Status reconciliation. It never *delivers* a live trigger (the socket is the // alarm path), but it is also the check-in that carries the durable record of // an alarm fired while this device was offline — so a missed trigger comes // back as a late takeover instead of disappearing. useEffect(() => { if (!enabled || !alertId) return; const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs; let cancelled = false; const tick = async () => { if (cancelled) return; try { const status = await fetchAlarmStatus(alertId, deviceId ?? undefined); if (!cancelled) applyStatus(status); } catch (e) { if (isAlertGone(e)) onGoneRef.current?.(); // ignore otherwise — polling must never surface a spinner } }; tick(); const handle = setInterval(tick, interval); return () => { cancelled = true; clearInterval(handle); }; }, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]); return { socketState, delivery, attempts, lastStatus, refresh }; }