import { useCallback, useEffect, useRef, useState } from "react"; import { AppState, type AppStateStatus } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types"; import { getApiBaseUrl, getWsUrl } from "../config/api"; import { buildScreenshotFixture, type ScreenshotFixture, type ScreenshotScene, } from "../dev/screenshot-fixtures"; import { tStatic } from "../i18n"; import { registerForPushNotificationsAsync } from "../notifications"; import { CONNECTION_PING_INTERVAL_MS, CONNECTION_WATCHDOG_INTERVAL_MS, type SessionConnectionState, getReconnectDelayMs, isConnectionStale, isTerminalSocketClose, } from "./connection"; const STORAGE_KEY = "negopoly:session"; type StoredSession = { sessionId: string; sessionCode: string; playerId: string; }; type IncomingMessage = | { type: "state"; session: SessionSnapshot } | { type: "error"; message: string } | { type: "takeover_approved"; assignedPlayerId: string } | { type: "pong" }; async function readStoredSession(): Promise { try { const raw = await AsyncStorage.getItem(STORAGE_KEY); if (!raw) return null; return JSON.parse(raw) as StoredSession; } catch { return null; } } async function writeStoredSession(session: StoredSession) { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(session)); } async function clearStoredSession() { await AsyncStorage.removeItem(STORAGE_KEY); } export function useSessionManager() { const [sessionId, setSessionId] = useState(""); const [sessionCode, setSessionCode] = useState(""); const [playerId, setPlayerId] = useState(""); const [session, setSession] = useState(null); const [screenshot, setScreenshot] = useState(null); const [error, setError] = useState(null); const [connectionState, setConnectionState] = useState("idle"); const [pushToken, setPushToken] = useState<{ token: string; platform: "ios" | "android"; } | null>(null); const [reconnectAttempt, setReconnectAttempt] = useState(0); const [lastActivityAt, setLastActivityAt] = useState(null); const wsRef = useRef(null); const sessionIdRef = useRef(sessionId); const sessionCodeRef = useRef(sessionCode); const playerIdRef = useRef(playerId); const sessionRef = useRef(session); const connectionGenerationRef = useRef(0); const reconnectAttemptRef = useRef(0); const reconnectTimerRef = useRef | null>(null); const pingTimerRef = useRef | null>(null); const watchdogTimerRef = useRef | null>(null); const suppressReconnectRef = useRef(false); const lastActivityAtRef = useRef(null); const appStateRef = useRef(AppState.currentState); const lastPushRegistrationRef = useRef(null); const screenshotRef = useRef(null); function markActivity(at = Date.now()) { lastActivityAtRef.current = at; setLastActivityAt(at); } function clearReconnectTimer() { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } } function clearSocketTimers() { if (pingTimerRef.current) { clearInterval(pingTimerRef.current); pingTimerRef.current = null; } if (watchdogTimerRef.current) { clearInterval(watchdogTimerRef.current); watchdogTimerRef.current = null; } } function closeSocket(ws: WebSocket | null, code?: number, reason?: string) { if (!ws || ws.readyState === WebSocket.CLOSED) { return; } try { if (typeof code === "number") { ws.close(code, reason); return; } ws.close(); } catch { // Ignore close failures. } } function teardownConnection() { clearReconnectTimer(); clearSocketTimers(); connectionGenerationRef.current += 1; const ws = wsRef.current; wsRef.current = null; closeSocket(ws); } function scheduleReconnect(generation: number) { if ( suppressReconnectRef.current || generation !== connectionGenerationRef.current || !sessionIdRef.current || !playerIdRef.current ) { return; } clearReconnectTimer(); const nextAttempt = reconnectAttemptRef.current + 1; reconnectAttemptRef.current = nextAttempt; setReconnectAttempt(nextAttempt); setConnectionState("reconnecting"); const delay = getReconnectDelayMs(nextAttempt - 1); reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = null; openSocket("retry"); }, delay); } function startSocketTimers(ws: WebSocket, generation: number) { clearSocketTimers(); pingTimerRef.current = setInterval(() => { if ( generation !== connectionGenerationRef.current || ws.readyState !== WebSocket.OPEN ) { return; } try { ws.send( JSON.stringify({ type: "ping", sessionId: sessionIdRef.current, playerId: playerIdRef.current, }), ); } catch { closeSocket(ws, 4001, "Ping failed"); } }, CONNECTION_PING_INTERVAL_MS); watchdogTimerRef.current = setInterval(() => { if ( generation !== connectionGenerationRef.current || appStateRef.current !== "active" ) { return; } if (!isConnectionStale(lastActivityAtRef.current)) { return; } if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { closeSocket(ws, 4000, "Connection stale"); return; } scheduleReconnect(generation); }, CONNECTION_WATCHDOG_INTERVAL_MS); } function openSocket(reason: "initial" | "retry" | "resume" | "manual") { const targetSessionId = sessionIdRef.current; const targetPlayerId = playerIdRef.current; if (!targetSessionId || !targetPlayerId) { return; } clearReconnectTimer(); clearSocketTimers(); const previousSocket = wsRef.current; const generation = connectionGenerationRef.current + 1; connectionGenerationRef.current = generation; wsRef.current = null; closeSocket(previousSocket); const recovering = Boolean(sessionRef.current) || reason !== "initial"; setConnectionState(recovering ? "reconnecting" : "connecting"); const ws = new WebSocket(getWsUrl(targetSessionId, targetPlayerId)); wsRef.current = ws; ws.onopen = () => { if (connectionGenerationRef.current !== generation) { return; } reconnectAttemptRef.current = 0; setReconnectAttempt(0); setConnectionState("open"); setError(null); markActivity(); startSocketTimers(ws, generation); }; ws.onmessage = (event) => { if (connectionGenerationRef.current !== generation) { return; } markActivity(); try { const message = JSON.parse(event.data) as IncomingMessage; if (message.type === "state") { setSession(message.session); return; } if (message.type === "error") { setError(message.message); return; } if (message.type === "takeover_approved") { const assignedId = message.assignedPlayerId; setPlayerId(assignedId); if (sessionIdRef.current && sessionCodeRef.current) { void writeStoredSession({ sessionId: sessionIdRef.current, sessionCode: sessionCodeRef.current, playerId: assignedId, }); } } } catch { setError(tStatic("error.parseResponse")); } }; ws.onerror = () => { if (connectionGenerationRef.current !== generation) { return; } setConnectionState("reconnecting"); }; ws.onclose = (event) => { if (connectionGenerationRef.current !== generation) { return; } if (wsRef.current === ws) { wsRef.current = null; } clearSocketTimers(); const reasonText = typeof event?.reason === "string" ? event.reason : ""; if (isTerminalSocketClose(event?.code, reasonText)) { setConnectionState("error"); void resetSession(); return; } if ( suppressReconnectRef.current || !sessionIdRef.current || !playerIdRef.current ) { setConnectionState("idle"); return; } scheduleReconnect(generation); }; } function retryConnection() { if (!sessionIdRef.current || !playerIdRef.current) { return; } suppressReconnectRef.current = false; reconnectAttemptRef.current = 0; setReconnectAttempt(0); openSocket("manual"); } useEffect(() => { sessionIdRef.current = sessionId; sessionCodeRef.current = sessionCode; playerIdRef.current = playerId; }, [playerId, sessionCode, sessionId]); useEffect(() => { screenshotRef.current = screenshot; }, [screenshot]); useEffect(() => { sessionRef.current = session; }, [session]); useEffect(() => { let mounted = true; readStoredSession().then((stored) => { if (!mounted || !stored || screenshotRef.current) return; setSessionId(stored.sessionId); setSessionCode(stored.sessionCode); setPlayerId(stored.playerId); }); return () => { mounted = false; }; }, []); useEffect(() => { if (screenshot) return; let mounted = true; registerForPushNotificationsAsync().then((token) => { if (!mounted) return; setPushToken(token); }); return () => { mounted = false; }; }, [screenshot]); useEffect(() => { if (screenshot) return; const subscription = AppState.addEventListener("change", (nextState) => { const previousState = appStateRef.current; appStateRef.current = nextState; if ( previousState !== "active" && nextState === "active" && sessionIdRef.current && playerIdRef.current ) { const socket = wsRef.current; const socketOpen = socket?.readyState === WebSocket.OPEN; if (!socketOpen || isConnectionStale(lastActivityAtRef.current)) { retryConnection(); return; } try { socket.send( JSON.stringify({ type: "ping", sessionId: sessionIdRef.current, playerId: playerIdRef.current, }), ); } catch { retryConnection(); } } }); return () => { subscription.remove(); }; }, [screenshot]); useEffect(() => { if (screenshot) return; if (!pushToken || !sessionId || !playerId) return; void registerPushTokenFor(sessionId, playerId); }, [pushToken, screenshot, sessionId, playerId]); useEffect(() => { if (screenshot) { suppressReconnectRef.current = true; teardownConnection(); reconnectAttemptRef.current = 0; setReconnectAttempt(0); lastActivityAtRef.current = null; setLastActivityAt(null); setConnectionState("idle"); return; } if (!sessionId || !playerId) { suppressReconnectRef.current = true; teardownConnection(); reconnectAttemptRef.current = 0; setReconnectAttempt(0); lastActivityAtRef.current = null; setLastActivityAt(null); setConnectionState("idle"); setSession(null); return; } suppressReconnectRef.current = false; reconnectAttemptRef.current = 0; setReconnectAttempt(0); openSocket("initial"); return () => { teardownConnection(); }; }, [playerId, screenshot, sessionId]); const activateScreenshotScene = useCallback((scene: ScreenshotScene) => { const fixture = buildScreenshotFixture(scene); suppressReconnectRef.current = true; teardownConnection(); reconnectAttemptRef.current = 0; setReconnectAttempt(0); lastActivityAtRef.current = null; setLastActivityAt(null); setConnectionState("idle"); setError(null); setScreenshot(fixture); setSessionId(fixture.sessionId); setSessionCode(fixture.sessionCode); setPlayerId(fixture.playerId); setSession(fixture.session); }, []); async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) { if (!pushToken) return; const signature = `${targetSessionId}:${targetPlayerId}:${pushToken.platform}:${pushToken.token}`; if (lastPushRegistrationRef.current === signature) return; lastPushRegistrationRef.current = signature; try { await fetch(`${getApiBaseUrl()}/api/push/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: targetSessionId, playerId: targetPlayerId, token: pushToken.token, platform: pushToken.platform, }), }); } catch { // Ignore push registration failures. } } async function requestTakeover( dummyId: string, overrideSessionId?: string, overridePlayerId?: string, ): Promise { const targetSessionId = overrideSessionId ?? sessionId; const targetPlayerId = overridePlayerId ?? playerId; if (!targetSessionId || !targetPlayerId) { const message = tStatic("entry.alert.takeoverFailed"); setError(message); return message; } const payload = { type: "takeover_request", sessionId: targetSessionId, playerId: targetPlayerId, dummyId, }; if ( wsRef.current && wsRef.current.readyState === WebSocket.OPEN && targetSessionId === sessionId && targetPlayerId === playerId ) { wsRef.current.send(JSON.stringify(payload)); return null; } try { const response = await fetch( `${getApiBaseUrl()}/api/session/${targetSessionId}/takeover`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: targetPlayerId, dummyId }), }, ); if (!response.ok) { const data = (await response.json()) as { message?: string }; const message = data.message ?? tStatic("entry.alert.takeoverFailed"); setError(message); return message; } return null; } catch { const message = tStatic("error.connectionNotReady"); setError(message); return message; } } async function createSession(bankerName: string) { setError(null); setScreenshot(null); setSessionId(""); setSessionCode(""); setPlayerId(""); setSession(null); try { const response = await fetch(`${getApiBaseUrl()}/api/session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bankerName }), }); if (!response.ok) { setError(tStatic("error.createSession")); return null; } const data = (await response.json()) as JoinResponse; setSessionId(data.sessionId); setSessionCode(data.sessionCode); setPlayerId(data.playerId); await writeStoredSession({ sessionId: data.sessionId, sessionCode: data.sessionCode, playerId: data.playerId, }); return data; } catch { setError(tStatic("error.createSession")); return null; } } async function joinSession(code: string, name: string) { setError(null); setScreenshot(null); setSessionId(""); setSessionCode(""); setPlayerId(""); setSession(null); if (!code) { setError(tStatic("entry.alert.enterCode")); return null; } const storedNow = await readStoredSession(); const reusePlayerId = storedNow && (storedNow.sessionCode === code || storedNow.sessionId === code) ? storedNow.playerId : undefined; try { const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, playerId: reusePlayerId }), }); if (!response.ok) { setError(tStatic("error.joinSession")); return null; } const data = (await response.json()) as JoinResponse; setSessionId(data.sessionId); setSessionCode(data.sessionCode); setPlayerId(data.playerId); await writeStoredSession({ sessionId: data.sessionId, sessionCode: data.sessionCode, playerId: data.playerId, }); return data; } catch { setError(tStatic("error.joinSession")); return null; } } async function requestTakeoverToken( code: string, dummyId: string, name: string, ): Promise { setError(null); if (!code || !dummyId) { setError(tStatic("entry.alert.selectDummy")); return null; } try { const response = await fetch( `${getApiBaseUrl()}/api/session/${code}/takeover-request`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dummyId, name }), }, ); if (!response.ok) { const data = (await response.json()) as { message?: string }; setError(data.message ?? tStatic("entry.alert.takeoverFailed")); return null; } const data = (await response.json()) as { token?: string }; return data.token ?? null; } catch { setError(tStatic("error.connectionNotReady")); return null; } } async function claimTakeover(code: string, token: string) { try { setScreenshot(null); setSessionId(""); setSessionCode(""); setPlayerId(""); const response = await fetch( `${getApiBaseUrl()}/api/session/${code}/takeover-claim`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }, ); if (response.status === 409) { return null; } if (!response.ok) { const data = (await response.json()) as { message?: string }; setError(data.message ?? tStatic("entry.alert.takeoverFailed")); return null; } const data = (await response.json()) as JoinResponse; setSessionId(data.sessionId); setSessionCode(data.sessionCode); setPlayerId(data.playerId); await writeStoredSession({ sessionId: data.sessionId, sessionCode: data.sessionCode, playerId: data.playerId, }); await registerPushTokenFor(data.sessionId, data.playerId); return data; } catch { setError(tStatic("error.connectionNotReady")); return null; } } async function fetchSessionPreview(code: string): Promise { if (!code) return null; try { const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`); if (!response.ok) { setError(tStatic("error.loadSessionInfo")); return null; } return (await response.json()) as SessionPreview; } catch { setError(tStatic("error.loadSessionInfo")); return null; } } function sendMessage(payload: Record) { if (screenshotRef.current) { return; } if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { retryConnection(); setError( connectionState === "reconnecting" || connectionState === "connecting" ? tStatic("error.reconnecting") : tStatic("error.connectionNotReady"), ); return; } wsRef.current.send(JSON.stringify(payload)); } async function resetSession() { suppressReconnectRef.current = true; teardownConnection(); reconnectAttemptRef.current = 0; setReconnectAttempt(0); lastActivityAtRef.current = null; setLastActivityAt(null); try { await clearStoredSession(); } catch { // Ignore storage errors on reset. } setSessionId(""); setSessionCode(""); setPlayerId(""); setSession(null); setScreenshot(null); setError(null); setConnectionState("idle"); } async function leaveSession() { suppressReconnectRef.current = true; teardownConnection(); await resetSession(); } const me = session?.players.find((player) => player.id === playerId) ?? null; const isBanker = me?.role === "banker"; return { sessionId, sessionCode, playerId, session, me, isBanker, error, connectionState, reconnectAttempt, lastActivityAt, setError, createSession, joinSession, fetchSessionPreview, requestTakeoverToken, claimTakeover, sendMessage, resetSession, leaveSession, retryConnection, setSessionId, setPlayerId, setSession, requestTakeover, activateScreenshotScene, screenshot, }; }