815 lines
23 KiB
TypeScript
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,
|
|
};
|
|
}
|