CoompanionApp/mobile/src/state/session.ts

268 lines
7.3 KiB
TypeScript
Raw Normal View History

2026-02-03 13:48:56 +01:00
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";
const STORAGE_KEY = "negopoly:session";
type StoredSession = {
sessionId: string;
sessionCode: string;
playerId: string;
};
async function readStoredSession(): Promise<StoredSession | null> {
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<SessionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "open" | "error"
>("idle");
const [tick, setTick] = useState(0);
const wsRef = useRef<WebSocket | null>(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(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer);
}, []);
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]);
useEffect(() => {
if (!pendingTakeoverId) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
if (!sessionId || !playerId) return;
wsRef.current.send(
JSON.stringify({
type: "takeover_request",
sessionId,
playerId,
dummyId: pendingTakeoverId,
}),
);
setPendingTakeoverId(null);
}, [pendingTakeoverId, sessionId, playerId]);
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 fetchSessionPreview(code: string): Promise<SessionPreview | null> {
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<string, unknown>) {
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);
setPendingTakeoverId(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,
sendMessage,
resetSession,
leaveSession,
setSessionId,
setPlayerId,
setSession,
setPendingTakeoverId,
};
}