CoompanionApp/mobile/src/state/session.ts

413 lines
12 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";
2026-02-03 16:35:01 +01:00
import { registerForPushNotificationsAsync } from "../notifications";
2026-02-03 13:48:56 +01:00
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 [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "open" | "error"
>("idle");
const [tick, setTick] = useState(0);
2026-02-03 16:35:01 +01:00
const [pushToken, setPushToken] = useState<{
token: string;
platform: "ios" | "android";
} | null>(null);
2026-02-03 13:48:56 +01:00
const wsRef = useRef<WebSocket | null>(null);
2026-02-03 16:35:01 +01:00
const lastPushRegistrationRef = useRef<string | null>(null);
2026-02-03 13:48:56 +01:00
useEffect(() => {
let mounted = true;
readStoredSession().then((stored) => {
if (!mounted || !stored) return;
setSessionId(stored.sessionId);
setSessionCode(stored.sessionCode);
setPlayerId(stored.playerId);
});
return () => {
mounted = false;
};
}, []);
2026-02-03 16:35:01 +01:00
useEffect(() => {
let mounted = true;
registerForPushNotificationsAsync().then((token) => {
if (!mounted) return;
setPushToken(token);
});
return () => {
mounted = false;
};
}, []);
2026-02-03 13:48:56 +01:00
useEffect(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer);
}, []);
2026-02-03 16:35:01 +01:00
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.
}
}
2026-02-03 13:48:56 +01:00
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]);
2026-02-03 16:35:01 +01:00
async function requestTakeover(
dummyId: string,
overrideSessionId?: string,
overridePlayerId?: string,
): Promise<string | null> {
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;
}
}
2026-02-03 13:48:56 +01:00
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;
}
2026-02-03 16:35:01 +01:00
async function requestTakeoverToken(
code: string,
dummyId: string,
name: string,
): Promise<string | null> {
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;
}
}
2026-02-03 13:48:56 +01:00
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);
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,
2026-02-03 16:35:01 +01:00
requestTakeoverToken,
claimTakeover,
2026-02-03 13:48:56 +01:00
sendMessage,
resetSession,
leaveSession,
setSessionId,
setPlayerId,
setSession,
2026-02-03 16:35:01 +01:00
requestTakeover,
2026-02-03 13:48:56 +01:00
};
}