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
|
|
|
};
|
|
|
|
|
}
|