import { useEffect, useRef, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types"; import { getApiBaseUrl, getWsUrl } from "../config/api"; import { tStatic } from "../i18n"; import { registerForPushNotificationsAsync } from "../notifications"; const STORAGE_KEY = "negopoly:session"; type StoredSession = { sessionId: string; sessionCode: string; playerId: string; }; 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 [error, setError] = useState(null); const [connectionState, setConnectionState] = useState< "idle" | "connecting" | "open" | "error" >("idle"); const [tick, setTick] = useState(0); const [pushToken, setPushToken] = useState<{ token: string; platform: "ios" | "android"; } | null>(null); const wsRef = useRef(null); const lastPushRegistrationRef = useRef(null); useEffect(() => { let mounted = true; readStoredSession().then((stored) => { if (!mounted || !stored) return; setSessionId(stored.sessionId); setSessionCode(stored.sessionCode); setPlayerId(stored.playerId); }); return () => { mounted = false; }; }, []); useEffect(() => { let mounted = true; registerForPushNotificationsAsync().then((token) => { if (!mounted) return; setPushToken(token); }); return () => { mounted = false; }; }, []); useEffect(() => { const timer = setInterval(() => setTick((value) => value + 1), 1000); return () => clearInterval(timer); }, []); useEffect(() => { if (!pushToken || !sessionId || !playerId) return; void registerPushTokenFor(sessionId, playerId); }, [pushToken, sessionId, playerId]); 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. } } useEffect(() => { if (!sessionId || !playerId) { setConnectionState("idle"); setSession(null); return; } setConnectionState("connecting"); const ws = new WebSocket(getWsUrl(sessionId, playerId)); wsRef.current = ws; ws.onopen = () => setConnectionState("open"); ws.onmessage = (event) => { try { const message = JSON.parse(event.data); if (message.type === "state") { setSession(message.session as SessionSnapshot); } if (message.type === "error") { setError(message.message); } if (message.type === "takeover_approved") { const assignedId = message.assignedPlayerId as string; setPlayerId(assignedId); if (sessionId && sessionCode) { writeStoredSession({ sessionId, sessionCode, playerId: assignedId, }); } } } catch { setError(tStatic("error.parseResponse")); } }; ws.onerror = () => setConnectionState("error"); ws.onclose = (event) => { setConnectionState("error"); const reason = typeof event?.reason === "string" ? event.reason : ""; if (event?.code === 1008 && /session not found|player not found/i.test(reason)) { resetSession(); } }; const pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping", sessionId, playerId })); } }, 15000); return () => { clearInterval(pingTimer); ws.close(); }; }, [sessionId, playerId]); 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); setSession(null); 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; } async function joinSession(code: string, name: string) { setError(null); 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; 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; } 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 { 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; const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`); if (!response.ok) { setError(tStatic("error.loadSessionInfo")); return null; } return (await response.json()) as SessionPreview; } function sendMessage(payload: Record) { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { setError(tStatic("error.connectionNotReady")); return; } wsRef.current.send(JSON.stringify(payload)); } async function resetSession() { try { await clearStoredSession(); } catch { // Ignore storage errors on reset. } setSessionId(""); setSessionCode(""); setPlayerId(""); setSession(null); setError(null); setConnectionState("idle"); } async function leaveSession() { const ws = wsRef.current; wsRef.current = null; if (ws && ws.readyState !== WebSocket.CLOSED) { try { ws.close(); } catch { // Ignore failures while closing the socket. } } await resetSession(); } const me = session?.players.find((player) => player.id === playerId) ?? null; const isBanker = me?.role === "banker"; return { sessionId, sessionCode, playerId, session, me, isBanker, tick, error, connectionState, setError, createSession, joinSession, fetchSessionPreview, requestTakeoverToken, claimTakeover, sendMessage, resetSession, leaveSession, setSessionId, setPlayerId, setSession, requestTakeover, }; }