CoompanionApp/mobile/src/state/session.ts

815 lines
23 KiB
TypeScript

import { 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";
const SCREENSHOT_STORAGE_KEY = "negopoly:screenshot-scene";
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<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);
}
async function readStoredScreenshotScene(): Promise<ScreenshotScene | null> {
try {
const raw = await AsyncStorage.getItem(SCREENSHOT_STORAGE_KEY);
return raw === "start" ||
raw === "lobby" ||
raw === "home" ||
raw === "transfers" ||
raw === "chat"
? raw
: null;
} catch {
return null;
}
}
async function clearStoredScreenshotScene() {
await AsyncStorage.removeItem(SCREENSHOT_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 [screenshot, setScreenshot] = useState<ScreenshotFixture | null>(null);
const [bootstrapReady, setBootstrapReady] = useState(false);
const [pushRegistrationReady, setPushRegistrationReady] = useState(false);
const [screenshotBootstrapPending, setScreenshotBootstrapPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectionState, setConnectionState] =
useState<SessionConnectionState>("idle");
const [pushToken, setPushToken] = useState<{
token: string;
platform: "ios" | "android";
} | null>(null);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [lastActivityAt, setLastActivityAt] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const sessionIdRef = useRef(sessionId);
const sessionCodeRef = useRef(sessionCode);
const playerIdRef = useRef(playerId);
const sessionRef = useRef<SessionSnapshot | null>(session);
const connectionGenerationRef = useRef(0);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const watchdogTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const suppressReconnectRef = useRef(false);
const lastActivityAtRef = useRef<number | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const lastPushRegistrationRef = useRef<string | null>(null);
const screenshotRef = useRef<ScreenshotFixture | null>(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(() => {
if (screenshot) {
setScreenshotBootstrapPending(false);
}
}, [screenshot]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
useEffect(() => {
let mounted = true;
(async () => {
let enablePushRegistration = true;
try {
const screenshotScene = await readStoredScreenshotScene();
if (!mounted) return;
if (screenshotScene) {
enablePushRegistration = false;
setScreenshotBootstrapPending(true);
await clearStoredScreenshotScene();
if (!mounted) return;
activateScreenshotScene(screenshotScene);
return;
}
const stored = await readStoredSession();
if (!mounted || !stored || screenshotRef.current) return;
setSessionId(stored.sessionId);
setSessionCode(stored.sessionCode);
setPlayerId(stored.playerId);
} finally {
if (mounted) {
setPushRegistrationReady(enablePushRegistration);
setBootstrapReady(true);
}
}
})();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (
!bootstrapReady ||
!pushRegistrationReady ||
screenshot ||
screenshotBootstrapPending ||
!sessionId ||
!playerId
) {
return;
}
let mounted = true;
registerForPushNotificationsAsync().then((token) => {
if (!mounted) return;
setPushToken(token);
});
return () => {
mounted = false;
};
}, [
bootstrapReady,
playerId,
pushRegistrationReady,
screenshot,
screenshotBootstrapPending,
sessionId,
]);
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]);
function activateScreenshotScene(scene: ScreenshotScene) {
const fixture = buildScreenshotFixture(scene);
suppressReconnectRef.current = true;
setPushRegistrationReady(false);
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<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;
}
}
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<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 {
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<SessionPreview | null> {
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<string, unknown>) {
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,
};
}