Améliorations de stabilité de l'application
This commit is contained in:
parent
62beda2bf7
commit
813ffe2171
15 changed files with 913 additions and 205 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Linking } from "react-native";
|
||||
import { Linking, StyleSheet, View } from "react-native";
|
||||
import {
|
||||
CommonActions,
|
||||
NavigationContainer,
|
||||
type LinkingOptions,
|
||||
useNavigationContainerRef,
|
||||
|
|
@ -13,6 +14,7 @@ import type { RootStackParamList } from "./navigation/types";
|
|||
import { SessionProvider, useSession } from "./state/session-context";
|
||||
import { getNavigationTheme, useTheme } from "./theme";
|
||||
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
|
||||
import ConnectionBanner from "./components/ConnectionBanner";
|
||||
|
||||
function extractGameId(url: string): string | null {
|
||||
try {
|
||||
|
|
@ -93,24 +95,28 @@ function RootNavigationGate() {
|
|||
if (pending.type === "chat") {
|
||||
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
|
||||
navigationRef.navigate(
|
||||
targetStack as never,
|
||||
{
|
||||
screen: targetTab,
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: targetStack,
|
||||
params: {
|
||||
screen: "ChatThread",
|
||||
params: { chatId: pending.chatId },
|
||||
screen: targetTab,
|
||||
params: {
|
||||
screen: "ChatThread",
|
||||
params: { chatId: pending.chatId },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
|
||||
navigationRef.navigate(
|
||||
targetStack as never,
|
||||
{ screen: targetTab } as never,
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: targetStack,
|
||||
params: { screen: targetTab },
|
||||
}),
|
||||
);
|
||||
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
|
||||
|
||||
|
|
@ -145,7 +151,7 @@ function RootNavigationGate() {
|
|||
if (!manager.sessionId) {
|
||||
target = "Entry";
|
||||
} else if (!manager.session) {
|
||||
target = manager.connectionState === "error" ? "Entry" : "Lobby";
|
||||
target = "Lobby";
|
||||
} else if (manager.session.status === "lobby") {
|
||||
target = "Lobby";
|
||||
} else if (manager.isBanker) {
|
||||
|
|
@ -195,14 +201,26 @@ function RootNavigationGate() {
|
|||
}, [handleNotificationResponse]);
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
onReady={() => setNavReady(true)}
|
||||
linking={linking}
|
||||
theme={navigationTheme}
|
||||
>
|
||||
<AppNavigator />
|
||||
</NavigationContainer>
|
||||
<View style={styles.container}>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
onReady={() => setNavReady(true)}
|
||||
linking={linking}
|
||||
theme={navigationTheme}
|
||||
>
|
||||
<AppNavigator />
|
||||
<ConnectionBanner
|
||||
visible={
|
||||
Boolean(manager.sessionId) &&
|
||||
manager.connectionState !== "idle" &&
|
||||
manager.connectionState !== "open"
|
||||
}
|
||||
connectionState={manager.connectionState}
|
||||
reconnectAttempt={manager.reconnectAttempt}
|
||||
onRetry={manager.retryConnection}
|
||||
/>
|
||||
</NavigationContainer>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -220,3 +238,9 @@ export default function App() {
|
|||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
102
mobile/src/components/ConnectionBanner.tsx
Normal file
102
mobile/src/components/ConnectionBanner.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useTheme, type AppTheme } from "../theme";
|
||||
import type { SessionConnectionState } from "../state/connection";
|
||||
|
||||
type ConnectionBannerProps = {
|
||||
connectionState: SessionConnectionState;
|
||||
reconnectAttempt: number;
|
||||
visible: boolean;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
export default function ConnectionBanner({
|
||||
connectionState,
|
||||
reconnectAttempt,
|
||||
visible,
|
||||
onRetry,
|
||||
}: ConnectionBannerProps) {
|
||||
const { t } = useI18n();
|
||||
const theme = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title =
|
||||
connectionState === "connecting"
|
||||
? t("connection.connecting")
|
||||
: t("connection.reconnecting");
|
||||
const detail = t("connection.reconnectingDetail", {
|
||||
count: reconnectAttempt || 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[styles.wrapper, { top: insets.top + 8 }]}
|
||||
>
|
||||
<View style={styles.banner}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.detail}>{detail}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.button} onPress={onRetry}>
|
||||
<Text style={styles.buttonText}>{t("common.retryNow")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (theme: AppTheme) =>
|
||||
StyleSheet.create({
|
||||
wrapper: {
|
||||
position: "absolute",
|
||||
left: 12,
|
||||
right: 12,
|
||||
zIndex: 20,
|
||||
},
|
||||
banner: {
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: theme.colors.headerBackground,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.text,
|
||||
fontWeight: "700",
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.textMuted,
|
||||
fontSize: 12,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 999,
|
||||
backgroundColor: theme.colors.primary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: theme.colors.primaryText,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import type { TransactionKind } from "./shared/types";
|
||||
|
||||
type Locale = "en" | "fr";
|
||||
|
||||
|
|
@ -22,6 +23,8 @@ const translations = {
|
|||
"common.you": "You",
|
||||
"common.join": "Join",
|
||||
"common.continue": "Continue",
|
||||
"common.retryNow": "Retry now",
|
||||
"common.transactions": "Transactions",
|
||||
"common.send": "Send",
|
||||
"common.reset": "Reset",
|
||||
"common.cancel": "Cancel",
|
||||
|
|
@ -153,11 +156,15 @@ const translations = {
|
|||
"transaction.transfer": "Transfer",
|
||||
"transaction.banker_adjust": "Banker adjustment",
|
||||
"transaction.banker_force_transfer": "Forced transfer",
|
||||
"connection.connecting": "Connecting to your game",
|
||||
"connection.reconnecting": "Reconnecting to your game",
|
||||
"connection.reconnectingDetail": "Attempt {count}. Live updates will resume automatically.",
|
||||
"error.parseResponse": "Unable to parse server response",
|
||||
"error.createSession": "Unable to create session",
|
||||
"error.joinSession": "Unable to join session",
|
||||
"error.loadSessionInfo": "Unable to load session info",
|
||||
"error.connectionNotReady": "Connection not ready",
|
||||
"error.reconnecting": "Reconnecting to the game. Please try again in a moment.",
|
||||
},
|
||||
fr: {
|
||||
"app.name": "Negopoly Companion",
|
||||
|
|
@ -178,6 +185,8 @@ const translations = {
|
|||
"common.you": "Vous",
|
||||
"common.join": "Rejoindre",
|
||||
"common.continue": "Continuer",
|
||||
"common.retryNow": "Réessayer",
|
||||
"common.transactions": "Transactions",
|
||||
"common.send": "Envoyer",
|
||||
"common.reset": "Réinitialiser",
|
||||
"common.cancel": "Annuler",
|
||||
|
|
@ -309,11 +318,15 @@ const translations = {
|
|||
"transaction.transfer": "Transfert",
|
||||
"transaction.banker_adjust": "Ajustement banquier",
|
||||
"transaction.banker_force_transfer": "Transfert forcé",
|
||||
"connection.connecting": "Connexion à la partie",
|
||||
"connection.reconnecting": "Reconnexion à la partie",
|
||||
"connection.reconnectingDetail": "Tentative {count}. Les mises à jour vont reprendre automatiquement.",
|
||||
"error.parseResponse": "Impossible de lire la réponse du serveur",
|
||||
"error.createSession": "Impossible de créer la session",
|
||||
"error.joinSession": "Impossible de rejoindre la session",
|
||||
"error.loadSessionInfo": "Impossible de charger les infos de session",
|
||||
"error.connectionNotReady": "Connexion non prête",
|
||||
"error.reconnecting": "Reconnexion à la partie en cours. Réessayez dans un instant.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -326,7 +339,7 @@ export function getLocale(): Locale {
|
|||
|
||||
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
|
||||
const table = translations[locale] ?? translations.en;
|
||||
let template = table[key] ?? translations.en[key] ?? key;
|
||||
let template: string = table[key] ?? translations.en[key] ?? key;
|
||||
if (vars) {
|
||||
Object.entries(vars).forEach(([name, value]) => {
|
||||
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
|
||||
|
|
@ -349,7 +362,7 @@ export function tStatic(key: I18nKey, vars?: Record<string, string | number>) {
|
|||
}
|
||||
|
||||
export function formatTransactionKind(
|
||||
kind: "transfer" | "banker_adjust" | "banker_force_transfer",
|
||||
kind: TransactionKind,
|
||||
t: (key: I18nKey) => string,
|
||||
) {
|
||||
return t(`transaction.${kind}` as I18nKey);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export type NotificationTarget =
|
|||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ function formatTransactionTimestamp(value: number) {
|
|||
}
|
||||
|
||||
function getTransactionLabel(
|
||||
kind: string,
|
||||
kind: Transaction["kind"],
|
||||
note: string | null | undefined,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
): string {
|
||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||
const trimmed = note?.trim();
|
||||
return trimmed || t("common.noReason");
|
||||
|
|
@ -43,15 +43,15 @@ function getTransactionDisplay(
|
|||
viewerId: string | null | undefined,
|
||||
players: Player[],
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||
const absAmount = Math.abs(transaction.amount);
|
||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||
const from = findPlayer(transaction.fromId);
|
||||
const to = findPlayer(transaction.toId);
|
||||
let outgoing = false;
|
||||
let counterparty = t("common.bank");
|
||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
||||
let counterparty: string = t("common.bank");
|
||||
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||
|
||||
if (transaction.kind === "banker_adjust") {
|
||||
outgoing = transaction.amount < 0;
|
||||
|
|
|
|||
|
|
@ -47,10 +47,10 @@ function formatTransactionTimestamp(value: number) {
|
|||
}
|
||||
|
||||
function getTransactionLabel(
|
||||
kind: string,
|
||||
kind: Transaction["kind"],
|
||||
note: string | null | undefined,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
): string {
|
||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||
const trimmed = note?.trim();
|
||||
return trimmed || t("common.noReason");
|
||||
|
|
@ -63,15 +63,15 @@ function getTransactionDisplay(
|
|||
viewerId: string | null | undefined,
|
||||
players: Player[],
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||
const absAmount = Math.abs(transaction.amount);
|
||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||
const from = findPlayer(transaction.fromId);
|
||||
const to = findPlayer(transaction.toId);
|
||||
let outgoing = false;
|
||||
let counterparty = t("common.bank");
|
||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
||||
let counterparty: string = t("common.bank");
|
||||
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||
|
||||
if (transaction.kind === "banker_adjust") {
|
||||
outgoing = transaction.amount < 0;
|
||||
|
|
@ -327,6 +327,9 @@ export default function BankerToolsScreen() {
|
|||
);
|
||||
}
|
||||
|
||||
const session = manager.session;
|
||||
const me = manager.me;
|
||||
|
||||
const normalizedAdjustAmount = adjustAmount.replace(",", ".");
|
||||
const adjustValue = Number(normalizedAdjustAmount);
|
||||
const canAdjust =
|
||||
|
|
@ -436,7 +439,7 @@ export default function BankerToolsScreen() {
|
|||
const display = getTransactionDisplay(
|
||||
transaction,
|
||||
selectedPlayerId,
|
||||
manager.session?.players ?? [],
|
||||
session.players,
|
||||
t,
|
||||
);
|
||||
return (
|
||||
|
|
@ -695,14 +698,14 @@ export default function BankerToolsScreen() {
|
|||
manager.sendMessage({
|
||||
type: "banker_blackout",
|
||||
sessionId: manager.sessionId,
|
||||
bankerId: manager.me?.id,
|
||||
active: !manager.session.blackoutActive,
|
||||
reason: !manager.session.blackoutActive ? blackoutReason : null,
|
||||
bankerId: me.id,
|
||||
active: !session.blackoutActive,
|
||||
reason: !session.blackoutActive ? blackoutReason : null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text style={styles.buttonDangerText}>
|
||||
{manager.session.blackoutActive
|
||||
{session.blackoutActive
|
||||
? t("banker.tools.blackoutDisable")
|
||||
: t("banker.tools.blackoutEnable")}
|
||||
</Text>
|
||||
|
|
@ -713,7 +716,7 @@ export default function BankerToolsScreen() {
|
|||
manager.sendMessage({
|
||||
type: "banker_end",
|
||||
sessionId: manager.sessionId,
|
||||
bankerId: manager.me?.id,
|
||||
bankerId: me.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -65,25 +65,28 @@ export default function LobbyScreen() {
|
|||
);
|
||||
}
|
||||
|
||||
const canStart = manager.isBanker && manager.session.status === "lobby";
|
||||
const pendingTakeover = manager.session.takeoverRequests.find(
|
||||
const session = manager.session;
|
||||
const me = manager.me;
|
||||
|
||||
const canStart = manager.isBanker && session.status === "lobby";
|
||||
const pendingTakeover = session.takeoverRequests.find(
|
||||
(request) =>
|
||||
request.requesterId === manager.playerId && request.status === "pending",
|
||||
);
|
||||
const pendingRequests = manager.isBanker
|
||||
? manager.session.takeoverRequests.filter((request) => request.status === "pending")
|
||||
? session.takeoverRequests.filter((request) => request.status === "pending")
|
||||
: [];
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text style={styles.title}>{t("lobby.title")}</Text>
|
||||
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
|
||||
<Text style={styles.subtitle}>{t("lobby.code", { code: session.code })}</Text>
|
||||
{pendingTakeover ? (
|
||||
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
data={manager.session.players}
|
||||
data={session.players}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
|
|
@ -108,10 +111,9 @@ export default function LobbyScreen() {
|
|||
<View style={styles.takeoverList}>
|
||||
{pendingRequests.map((request) => {
|
||||
const requester =
|
||||
manager.session.players.find((player) => player.id === request.requesterId) ??
|
||||
null;
|
||||
session.players.find((player) => player.id === request.requesterId) ?? null;
|
||||
const dummy =
|
||||
manager.session.players.find((player) => player.id === request.dummyId) ?? null;
|
||||
session.players.find((player) => player.id === request.dummyId) ?? null;
|
||||
const requesterName =
|
||||
requester?.name ?? request.requesterName ?? t("common.player");
|
||||
return (
|
||||
|
|
@ -126,12 +128,12 @@ export default function LobbyScreen() {
|
|||
style={styles.buttonSmall}
|
||||
onPress={() =>
|
||||
manager.sendMessage({
|
||||
type: "banker_takeover_approve",
|
||||
sessionId: manager.sessionId,
|
||||
bankerId: manager.me?.id,
|
||||
dummyId: request.dummyId,
|
||||
requesterId: request.requesterId,
|
||||
})
|
||||
type: "banker_takeover_approve",
|
||||
sessionId: manager.sessionId,
|
||||
bankerId: me.id,
|
||||
dummyId: request.dummyId,
|
||||
requesterId: request.requesterId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
|
||||
|
|
@ -143,7 +145,7 @@ export default function LobbyScreen() {
|
|||
</View>
|
||||
) : null}
|
||||
|
||||
{manager.isBanker && manager.session.status === "lobby" && (
|
||||
{manager.isBanker && session.status === "lobby" && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
|
||||
<Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
|
||||
|
|
@ -168,7 +170,7 @@ export default function LobbyScreen() {
|
|||
manager.sendMessage({
|
||||
type: "banker_create_dummy",
|
||||
sessionId: manager.sessionId,
|
||||
bankerId: manager.me?.id,
|
||||
bankerId: me.id,
|
||||
name: dummyName,
|
||||
balance: Number(dummyBalance) || undefined,
|
||||
});
|
||||
|
|
@ -188,7 +190,7 @@ export default function LobbyScreen() {
|
|||
manager.sendMessage({
|
||||
type: "banker_start",
|
||||
sessionId: manager.sessionId,
|
||||
bankerId: manager.me?.id,
|
||||
bankerId: me.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ function formatTransactionTimestamp(value: number) {
|
|||
}
|
||||
|
||||
function getTransactionLabel(
|
||||
kind: string,
|
||||
kind: Transaction["kind"],
|
||||
note: string | null | undefined,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
): string {
|
||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||
const trimmed = note?.trim();
|
||||
return trimmed || t("common.noReason");
|
||||
|
|
@ -44,15 +44,15 @@ function getTransactionDisplay(
|
|||
viewerId: string | null | undefined,
|
||||
players: Player[],
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||
const absAmount = Math.abs(transaction.amount);
|
||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||
const from = findPlayer(transaction.fromId);
|
||||
const to = findPlayer(transaction.toId);
|
||||
let outgoing = false;
|
||||
let counterparty = t("common.bank");
|
||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
||||
let counterparty: string = t("common.bank");
|
||||
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||
|
||||
if (transaction.kind === "banker_adjust") {
|
||||
outgoing = transaction.amount < 0;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default function ChatThreadScreen() {
|
|||
</View>
|
||||
);
|
||||
}
|
||||
const activeThread = thread;
|
||||
const showEmp = manager.session.blackoutActive && !manager.isBanker;
|
||||
|
||||
function handleSend() {
|
||||
|
|
@ -74,7 +75,7 @@ export default function ChatThreadScreen() {
|
|||
sessionId: manager.sessionId,
|
||||
playerId: manager.me?.id,
|
||||
body: message.trim(),
|
||||
groupId: thread.id === "global" ? null : thread.id,
|
||||
groupId: activeThread.id === "global" ? null : activeThread.id,
|
||||
});
|
||||
setMessage("");
|
||||
}
|
||||
|
|
@ -87,7 +88,7 @@ export default function ChatThreadScreen() {
|
|||
keyboardVerticalOffset={keyboardOffset}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>{thread.name}</Text>
|
||||
<Text style={styles.headerTitle}>{activeThread.name}</Text>
|
||||
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
29
mobile/src/state/connection.test.ts
Normal file
29
mobile/src/state/connection.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
CONNECTION_IDLE_TIMEOUT_MS,
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
getReconnectDelayMs,
|
||||
isConnectionStale,
|
||||
isTerminalSocketClose,
|
||||
} from "./connection";
|
||||
|
||||
test("getReconnectDelayMs grows with backoff and caps at the max delay", () => {
|
||||
expect(getReconnectDelayMs(0, () => 0)).toBe(800);
|
||||
expect(getReconnectDelayMs(1, () => 0)).toBe(1600);
|
||||
expect(getReconnectDelayMs(4, () => 0)).toBe(8000);
|
||||
expect(getReconnectDelayMs(8, () => 1)).toBe(Math.round(RECONNECT_MAX_DELAY_MS * 1.2));
|
||||
});
|
||||
|
||||
test("isConnectionStale respects the idle timeout", () => {
|
||||
const now = 100_000;
|
||||
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS + 1, now)).toBe(false);
|
||||
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS - 1, now)).toBe(true);
|
||||
expect(isConnectionStale(null, now)).toBe(false);
|
||||
});
|
||||
|
||||
test("isTerminalSocketClose only resets on invalid session/player closes", () => {
|
||||
expect(isTerminalSocketClose(1008, "Session not found")).toBe(true);
|
||||
expect(isTerminalSocketClose(1008, "Player not found")).toBe(true);
|
||||
expect(isTerminalSocketClose(4000, "Connection stale")).toBe(false);
|
||||
expect(isTerminalSocketClose(1008, "Policy violation")).toBe(false);
|
||||
});
|
||||
40
mobile/src/state/connection.ts
Normal file
40
mobile/src/state/connection.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export type SessionConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "open"
|
||||
| "reconnecting"
|
||||
| "error";
|
||||
|
||||
export const CONNECTION_PING_INTERVAL_MS = 15_000;
|
||||
export const CONNECTION_IDLE_TIMEOUT_MS = 45_000;
|
||||
export const CONNECTION_WATCHDOG_INTERVAL_MS = 5_000;
|
||||
export const RECONNECT_BASE_DELAY_MS = 1_000;
|
||||
export const RECONNECT_MAX_DELAY_MS = 10_000;
|
||||
|
||||
export function getReconnectDelayMs(
|
||||
attempt: number,
|
||||
random: () => number = Math.random,
|
||||
): number {
|
||||
const normalizedAttempt = Math.max(0, attempt);
|
||||
const baseDelay = Math.min(
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
RECONNECT_BASE_DELAY_MS * 2 ** normalizedAttempt,
|
||||
);
|
||||
const jitterMultiplier = 0.8 + random() * 0.4;
|
||||
return Math.round(baseDelay * jitterMultiplier);
|
||||
}
|
||||
|
||||
export function isTerminalSocketClose(code?: number, reason?: string): boolean {
|
||||
return code === 1008 && /session not found|player not found/i.test(reason ?? "");
|
||||
}
|
||||
|
||||
export function isConnectionStale(
|
||||
lastActivityAt: number | null | undefined,
|
||||
nowMs = Date.now(),
|
||||
timeoutMs = CONNECTION_IDLE_TIMEOUT_MS,
|
||||
): boolean {
|
||||
if (!lastActivityAt) {
|
||||
return false;
|
||||
}
|
||||
return nowMs - lastActivityAt > timeoutMs;
|
||||
}
|
||||
|
|
@ -1,9 +1,18 @@
|
|||
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 { 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";
|
||||
|
||||
|
|
@ -13,6 +22,12 @@ type StoredSession = {
|
|||
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);
|
||||
|
|
@ -37,18 +52,265 @@ export function useSessionManager() {
|
|||
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);
|
||||
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);
|
||||
|
||||
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(() => {
|
||||
sessionRef.current = session;
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
readStoredSession().then((stored) => {
|
||||
|
|
@ -74,8 +336,39 @@ export function useSessionManager() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
||||
return () => clearInterval(timer);
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -83,6 +376,29 @@ export function useSessionManager() {
|
|||
void registerPushTokenFor(sessionId, playerId);
|
||||
}, [pushToken, sessionId, playerId]);
|
||||
|
||||
useEffect(() => {
|
||||
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, sessionId]);
|
||||
|
||||
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
|
||||
if (!pushToken) return;
|
||||
const signature = `${targetSessionId}:${targetPlayerId}:${pushToken.platform}:${pushToken.token}`;
|
||||
|
|
@ -104,64 +420,6 @@ export function useSessionManager() {
|
|||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
async function requestTakeover(
|
||||
dummyId: string,
|
||||
overrideSessionId?: string,
|
||||
|
|
@ -215,25 +473,30 @@ export function useSessionManager() {
|
|||
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) {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
|
|
@ -249,25 +512,30 @@ export function useSessionManager() {
|
|||
? 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) {
|
||||
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;
|
||||
}
|
||||
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 requestTakeoverToken(
|
||||
|
|
@ -339,23 +607,39 @@ export function useSessionManager() {
|
|||
|
||||
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
||||
if (!code) return null;
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
|
||||
if (!response.ok) {
|
||||
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;
|
||||
}
|
||||
return (await response.json()) as SessionPreview;
|
||||
}
|
||||
|
||||
function sendMessage(payload: Record<string, unknown>) {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
setError(tStatic("error.connectionNotReady"));
|
||||
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 {
|
||||
|
|
@ -370,15 +654,8 @@ export function useSessionManager() {
|
|||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
suppressReconnectRef.current = true;
|
||||
teardownConnection();
|
||||
await resetSession();
|
||||
}
|
||||
|
||||
|
|
@ -392,9 +669,10 @@ export function useSessionManager() {
|
|||
session,
|
||||
me,
|
||||
isBanker,
|
||||
tick,
|
||||
error,
|
||||
connectionState,
|
||||
reconnectAttempt,
|
||||
lastActivityAt,
|
||||
setError,
|
||||
createSession,
|
||||
joinSession,
|
||||
|
|
@ -404,6 +682,7 @@ export function useSessionManager() {
|
|||
sendMessage,
|
||||
resetSession,
|
||||
leaveSession,
|
||||
retryConnection,
|
||||
setSessionId,
|
||||
setPlayerId,
|
||||
setSession,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ export type ServerMessage =
|
|||
type: "state";
|
||||
session: SessionSnapshot;
|
||||
}
|
||||
| {
|
||||
type: "pong";
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: string;
|
||||
|
|
|
|||
102
server/websocket.test.ts
Normal file
102
server/websocket.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { afterEach, expect, test } from "bun:test";
|
||||
import { createSession, removeSession } from "./store";
|
||||
import { joinSession } from "./domain";
|
||||
import {
|
||||
STALE_SOCKET_TIMEOUT_MS,
|
||||
handleSocketMessage,
|
||||
reapStaleSockets,
|
||||
registerSocket,
|
||||
resetWebsocketStateForTests,
|
||||
unregisterSocket,
|
||||
} from "./websocket";
|
||||
|
||||
const OPEN = 1;
|
||||
const CLOSED = 3;
|
||||
|
||||
type FakeSocket = {
|
||||
readyState: number;
|
||||
sent: string[];
|
||||
closes: Array<{ code?: number; reason?: string }>;
|
||||
send: (payload: string) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
};
|
||||
|
||||
function createFakeSocket(): FakeSocket {
|
||||
return {
|
||||
readyState: OPEN,
|
||||
sent: [],
|
||||
closes: [],
|
||||
send(payload: string) {
|
||||
this.sent.push(payload);
|
||||
},
|
||||
close(code?: number, reason?: string) {
|
||||
this.closes.push({ code, reason });
|
||||
this.readyState = CLOSED;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetWebsocketStateForTests();
|
||||
});
|
||||
|
||||
test("overlapping sockets do not disconnect a player until the last socket closes", () => {
|
||||
const { session } = createSession("Banker");
|
||||
const player = joinSession(session, "Jules");
|
||||
const firstSocket = createFakeSocket();
|
||||
const secondSocket = createFakeSocket();
|
||||
|
||||
registerSocket(firstSocket as unknown as WebSocket, session.id, player.id);
|
||||
registerSocket(secondSocket as unknown as WebSocket, session.id, player.id);
|
||||
|
||||
unregisterSocket(firstSocket as unknown as WebSocket);
|
||||
expect(session.players.get(player.id)?.connected).toBe(true);
|
||||
expect(session.players.get(player.id)?.isDummy).toBe(false);
|
||||
|
||||
unregisterSocket(secondSocket as unknown as WebSocket);
|
||||
expect(session.players.get(player.id)?.connected).toBe(false);
|
||||
expect(session.players.get(player.id)?.isDummy).toBe(true);
|
||||
|
||||
removeSession(session.id);
|
||||
});
|
||||
|
||||
test("stale sockets are reaped and disconnect the player", () => {
|
||||
const { session } = createSession("Banker");
|
||||
const player = joinSession(session, "Rosa");
|
||||
const socket = createFakeSocket();
|
||||
|
||||
registerSocket(socket as unknown as WebSocket, session.id, player.id);
|
||||
reapStaleSockets(Date.now() + STALE_SOCKET_TIMEOUT_MS + 1);
|
||||
|
||||
expect(socket.closes[0]).toEqual({ code: 4000, reason: "Connection stale" });
|
||||
expect(session.players.get(player.id)?.connected).toBe(false);
|
||||
expect(session.players.get(player.id)?.isDummy).toBe(true);
|
||||
|
||||
removeSession(session.id);
|
||||
});
|
||||
|
||||
test("invalid session registration closes the socket with a terminal code", () => {
|
||||
const socket = createFakeSocket();
|
||||
|
||||
registerSocket(socket as unknown as WebSocket, "missing-session", "missing-player");
|
||||
|
||||
expect(socket.closes[0]).toEqual({ code: 1008, reason: "Session not found" });
|
||||
});
|
||||
|
||||
test("ping messages update liveness and emit a pong", () => {
|
||||
const { session } = createSession("Banker");
|
||||
const player = joinSession(session, "Nina");
|
||||
const socket = createFakeSocket();
|
||||
|
||||
registerSocket(socket as unknown as WebSocket, session.id, player.id);
|
||||
socket.sent = [];
|
||||
|
||||
handleSocketMessage(
|
||||
socket as unknown as WebSocket,
|
||||
JSON.stringify({ type: "ping", sessionId: session.id, playerId: player.id }),
|
||||
);
|
||||
|
||||
expect(socket.sent.some((entry) => entry.includes('"type":"pong"'))).toBe(true);
|
||||
|
||||
removeSession(session.id);
|
||||
});
|
||||
|
|
@ -20,10 +20,24 @@ import { getSession, removeSession } from "./store";
|
|||
import { now } from "./util";
|
||||
import { notifyChat, notifyTransaction } from "./notifications";
|
||||
|
||||
export const STALE_SOCKET_TIMEOUT_MS = 45_000;
|
||||
const STALE_SOCKET_REAP_INTERVAL_MS = 10_000;
|
||||
|
||||
type SocketMeta = {
|
||||
sessionId: string;
|
||||
playerId: string;
|
||||
lastSeenAt: number;
|
||||
};
|
||||
|
||||
const socketsBySession = new Map<string, Set<WebSocket>>();
|
||||
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
|
||||
const socketsByPlayer = new Map<string, Set<WebSocket>>();
|
||||
let metaBySocket = new WeakMap<WebSocket, SocketMeta>();
|
||||
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function playerSocketKey(sessionId: string, playerId: string): string {
|
||||
return `${sessionId}:${playerId}`;
|
||||
}
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
|
@ -55,7 +69,7 @@ function runTestTransfer(sessionId: string): void {
|
|||
let attempts = 0;
|
||||
while (attempts < 5) {
|
||||
const from = players[randomInt(0, players.length - 1)];
|
||||
let to = players[randomInt(0, players.length - 1)];
|
||||
const to = players[randomInt(0, players.length - 1)];
|
||||
if (to.id === from.id) {
|
||||
attempts += 1;
|
||||
continue;
|
||||
|
|
@ -99,6 +113,54 @@ function getSessionSockets(sessionId: string): Set<WebSocket> {
|
|||
return set;
|
||||
}
|
||||
|
||||
function getPlayerSockets(sessionId: string, playerId: string): Set<WebSocket> {
|
||||
const key = playerSocketKey(sessionId, playerId);
|
||||
let set = socketsByPlayer.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
socketsByPlayer.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function deleteSocketFromTracking(ws: WebSocket, meta: SocketMeta): {
|
||||
sessionSocketCount: number;
|
||||
playerSocketCount: number;
|
||||
} {
|
||||
const sessionSockets = socketsBySession.get(meta.sessionId);
|
||||
if (sessionSockets) {
|
||||
sessionSockets.delete(ws);
|
||||
if (sessionSockets.size === 0) {
|
||||
socketsBySession.delete(meta.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const playerKey = playerSocketKey(meta.sessionId, meta.playerId);
|
||||
const playerSockets = socketsByPlayer.get(playerKey);
|
||||
if (playerSockets) {
|
||||
playerSockets.delete(ws);
|
||||
if (playerSockets.size === 0) {
|
||||
socketsByPlayer.delete(playerKey);
|
||||
}
|
||||
}
|
||||
|
||||
metaBySocket.delete(ws);
|
||||
|
||||
return {
|
||||
sessionSocketCount: sessionSockets?.size ?? 0,
|
||||
playerSocketCount: playerSockets?.size ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function touchSocket(ws: WebSocket): SocketMeta | null {
|
||||
const meta = metaBySocket.get(ws);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
meta.lastSeenAt = now();
|
||||
return meta;
|
||||
}
|
||||
|
||||
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
|
|
@ -110,12 +172,15 @@ export function registerSocket(ws: WebSocket, sessionId: string, playerId: strin
|
|||
ws.close(1008, "Player not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const meta: SocketMeta = { sessionId, playerId, lastSeenAt: now() };
|
||||
metaBySocket.set(ws, meta);
|
||||
getSessionSockets(sessionId).add(ws);
|
||||
getPlayerSockets(sessionId, playerId).add(ws);
|
||||
|
||||
player.connected = true;
|
||||
player.isDummy = false;
|
||||
player.lastActiveAt = now();
|
||||
|
||||
metaBySocket.set(ws, { sessionId, playerId });
|
||||
getSessionSockets(sessionId).add(ws);
|
||||
player.lastActiveAt = meta.lastSeenAt;
|
||||
|
||||
sendStateToSession(session);
|
||||
}
|
||||
|
|
@ -125,32 +190,64 @@ export function unregisterSocket(ws: WebSocket): void {
|
|||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, playerId } = meta;
|
||||
const { sessionSocketCount, playerSocketCount } = deleteSocketFromTracking(ws, meta);
|
||||
const session = getSession(sessionId);
|
||||
if (session) {
|
||||
|
||||
if (session && playerSocketCount === 0) {
|
||||
disconnectPlayer(session, playerId);
|
||||
sendStateToSession(session);
|
||||
}
|
||||
const set = socketsBySession.get(sessionId);
|
||||
if (set) {
|
||||
set.delete(ws);
|
||||
if (set.size === 0) {
|
||||
socketsBySession.delete(sessionId);
|
||||
if (session?.isTest) {
|
||||
stopTestSimulation(sessionId);
|
||||
removeSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionSocketCount === 0 && session?.isTest) {
|
||||
stopTestSimulation(sessionId);
|
||||
removeSession(sessionId);
|
||||
}
|
||||
metaBySocket.delete(ws);
|
||||
}
|
||||
|
||||
export function reapStaleSockets(referenceNow = now()): void {
|
||||
const staleSockets: WebSocket[] = [];
|
||||
|
||||
socketsBySession.forEach((sessionSockets) => {
|
||||
sessionSockets.forEach((socket) => {
|
||||
const meta = metaBySocket.get(socket);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
if (referenceNow - meta.lastSeenAt <= STALE_SOCKET_TIMEOUT_MS) {
|
||||
return;
|
||||
}
|
||||
staleSockets.push(socket);
|
||||
});
|
||||
});
|
||||
|
||||
staleSockets.forEach((socket) => {
|
||||
try {
|
||||
socket.close(4000, "Connection stale");
|
||||
} catch {
|
||||
// Ignore close failures.
|
||||
}
|
||||
unregisterSocket(socket);
|
||||
});
|
||||
}
|
||||
|
||||
export function resetWebsocketStateForTests(): void {
|
||||
socketsBySession.clear();
|
||||
socketsByPlayer.clear();
|
||||
testTimers.forEach((timer) => clearTimeout(timer));
|
||||
testTimers.clear();
|
||||
metaBySocket = new WeakMap<WebSocket, SocketMeta>();
|
||||
}
|
||||
|
||||
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
|
||||
touchSocket(ws);
|
||||
|
||||
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
||||
let parsed: ClientMessage;
|
||||
try {
|
||||
parsed = JSON.parse(messageText) as ClientMessage;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
send(ws, { type: "error", message: "Invalid message" });
|
||||
return;
|
||||
}
|
||||
|
|
@ -162,7 +259,7 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
|
|||
}
|
||||
|
||||
try {
|
||||
handleMessage(session, parsed);
|
||||
handleMessage(ws, session, parsed);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof DomainError
|
||||
|
|
@ -175,25 +272,24 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
|
|||
sendStateToSession(session);
|
||||
}
|
||||
|
||||
function handleMessage(session: Session, message: ClientMessage): void {
|
||||
function handleMessage(ws: WebSocket, session: Session, message: ClientMessage): void {
|
||||
switch (message.type) {
|
||||
case "chat_send": {
|
||||
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
|
||||
notifyChat(session, chat);
|
||||
return;
|
||||
}
|
||||
case "transfer":
|
||||
{
|
||||
const transaction = transfer(
|
||||
session,
|
||||
message.playerId,
|
||||
message.toPlayerId,
|
||||
message.amount,
|
||||
message.note,
|
||||
);
|
||||
notifyTransaction(session, transaction);
|
||||
return;
|
||||
}
|
||||
case "transfer": {
|
||||
const transaction = transfer(
|
||||
session,
|
||||
message.playerId,
|
||||
message.toPlayerId,
|
||||
message.amount,
|
||||
message.note,
|
||||
);
|
||||
notifyTransaction(session, transaction);
|
||||
return;
|
||||
}
|
||||
case "banker_adjust": {
|
||||
const transaction = bankerAdjust(
|
||||
session,
|
||||
|
|
@ -247,6 +343,7 @@ function handleMessage(session: Session, message: ClientMessage): void {
|
|||
}
|
||||
case "ping":
|
||||
touchPlayer(session, message.playerId);
|
||||
send(ws, { type: "pong" });
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
|
|
@ -295,6 +392,13 @@ function notifyTakeoverApproval(
|
|||
if (meta.playerId === requesterId) {
|
||||
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
|
||||
meta.playerId = assignedId;
|
||||
deleteSocketFromTracking(socket, {
|
||||
...meta,
|
||||
playerId: requesterId,
|
||||
});
|
||||
getSessionSockets(sessionId).add(socket);
|
||||
getPlayerSockets(sessionId, assignedId).add(socket);
|
||||
metaBySocket.set(socket, meta);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -305,3 +409,7 @@ function send(ws: WebSocket, message: ServerMessage): void {
|
|||
}
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
reapStaleSockets();
|
||||
}, STALE_SOCKET_REAP_INTERVAL_MS);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue