357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
import React, { useState, useEffect, KeyboardEvent, useCallback } from "react";
|
|
import { Input, Paper, Typography } from "@mui/material";
|
|
|
|
import { Session, Lobby } from "./GlobalContext";
|
|
import { UserList } from "./UserList";
|
|
import { LobbyChat } from "./LobbyChat";
|
|
import BotManager from "./BotManager";
|
|
import "./App.css";
|
|
import { ws_base, base } from "./Common";
|
|
import { Box, Button, Tooltip } from "@mui/material";
|
|
import { BrowserRouter as Router, Route, Routes, useParams } from "react-router-dom";
|
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
|
import ConnectionStatus from "./ConnectionStatus";
|
|
import { sessionsApi, LobbyCreateRequest } from "./api-client";
|
|
|
|
console.log(`AI Voice Chat Build: ${process.env.REACT_APP_AI_VOICECHAT_BUILD}`);
|
|
|
|
type LobbyProps = {
|
|
session: Session;
|
|
setSession: React.Dispatch<React.SetStateAction<Session | null>>;
|
|
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
|
};
|
|
|
|
const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|
const { session, setSession, setError } = props;
|
|
const { lobbyName = "default" } = useParams<{ lobbyName: string }>();
|
|
const [lobby, setLobby] = useState<Lobby | null>(null);
|
|
const [editName, setEditName] = useState<string>("");
|
|
const [editPassword, setEditPassword] = useState<string>("");
|
|
const [socketUrl, setSocketUrl] = useState<string | null>(null);
|
|
const [creatingLobby, setCreatingLobby] = useState<boolean>(false);
|
|
const [reconnectAttempt, setReconnectAttempt] = useState<number>(0);
|
|
const [shouldRetryLobby, setShouldRetryLobby] = useState<boolean>(false);
|
|
|
|
// Check if lobbyName looks like a lobby ID (32 hex characters) and redirect to default
|
|
useEffect(() => {
|
|
if (lobbyName && /^[a-f0-9]{32}$/i.test(lobbyName)) {
|
|
console.log(`Lobby - Detected lobby ID in URL (${lobbyName}), redirecting to default lobby`);
|
|
window.history.replaceState(null, "", `${base}/lobby/default`);
|
|
window.location.reload(); // Force reload to use the new URL
|
|
}
|
|
}, [lobbyName]);
|
|
|
|
const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl, {
|
|
onOpen: () => {
|
|
console.log("app - WebSocket connection opened.");
|
|
setReconnectAttempt(0);
|
|
},
|
|
onClose: () => {
|
|
console.log("app - WebSocket connection closed.");
|
|
setReconnectAttempt((prev) => prev + 1);
|
|
},
|
|
onError: (event: Event) => {
|
|
console.error("app - WebSocket error observed:", event);
|
|
// If we get a WebSocket error, it might be due to invalid lobby ID
|
|
// Reset the lobby state to force recreation
|
|
if (lobby) {
|
|
console.log("app - WebSocket error, clearing lobby state to force refresh");
|
|
setLobby(null);
|
|
setSocketUrl(null);
|
|
}
|
|
},
|
|
shouldReconnect: (closeEvent) => {
|
|
// Don't reconnect if the lobby doesn't exist (4xx errors)
|
|
if (closeEvent.code >= 4000 && closeEvent.code < 5000) {
|
|
console.log("app - WebSocket closed with client error, not reconnecting");
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
reconnectInterval: 5000, // Retry every 5 seconds
|
|
onReconnectStop: (numAttempts) => {
|
|
console.log(`Stopped reconnecting after ${numAttempts} attempts`);
|
|
},
|
|
share: true,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (lobby && session) {
|
|
setSocketUrl(`${ws_base}/${lobby.id}/${session.id}`);
|
|
}
|
|
}, [lobby, session]);
|
|
|
|
useEffect(() => {
|
|
if (!lastJsonMessage || !session) {
|
|
return;
|
|
}
|
|
const data: any = lastJsonMessage;
|
|
switch (data.type) {
|
|
case "update_name":
|
|
if (data.data && "name" in data.data) {
|
|
console.log(`Lobby - name set to ${data.data.name}`);
|
|
setSession((s) => (s ? { ...s, name: data.data.name } : null));
|
|
}
|
|
break;
|
|
case "error":
|
|
console.error(`Lobby - Server error: ${data.data.error}`);
|
|
setError(data.data.error);
|
|
|
|
// If the error is about lobby not found, reset the lobby state
|
|
if (data.data.error && data.data.error.includes("Lobby not found")) {
|
|
console.log("Lobby - Lobby not found error, clearing lobby state");
|
|
setLobby(null);
|
|
setSocketUrl(null);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}, [lastJsonMessage, session, setError, setSession]);
|
|
|
|
useEffect(() => {
|
|
console.log("app - WebSocket connection status: ", readyState);
|
|
}, [readyState]);
|
|
|
|
// Retry lobby creation when session is restored after a failure
|
|
useEffect(() => {
|
|
if (session && shouldRetryLobby && !lobby && !creatingLobby) {
|
|
console.log("Lobby - Session restored, retrying lobby creation");
|
|
setShouldRetryLobby(false);
|
|
// The main lobby creation effect will trigger automatically due to session change
|
|
}
|
|
}, [session, shouldRetryLobby, lobby, creatingLobby]);
|
|
|
|
useEffect(() => {
|
|
if (!session || !lobbyName || creatingLobby || (lobby && lobby.name === lobbyName)) {
|
|
return;
|
|
}
|
|
|
|
// Clear any existing lobby state when switching to a new lobby name
|
|
if (lobby && lobby.name !== lobbyName) {
|
|
console.log(`Lobby - Clearing previous lobby state: ${lobby.name} -> ${lobbyName}`);
|
|
setLobby(null);
|
|
setSocketUrl(null);
|
|
}
|
|
|
|
const getLobby = async (lobbyName: string, session: Session) => {
|
|
try {
|
|
const lobbyRequest: LobbyCreateRequest = {
|
|
type: "lobby_create",
|
|
data: {
|
|
name: lobbyName,
|
|
private: false,
|
|
},
|
|
};
|
|
|
|
const response = await sessionsApi.createLobby(session.id, lobbyRequest);
|
|
|
|
if (response.type !== "lobby_created") {
|
|
console.error(`Lobby - Unexpected response type: ${response.type}`);
|
|
setError(`Unexpected response from server`);
|
|
return;
|
|
}
|
|
const lobby: Lobby = response.data;
|
|
console.log(`Lobby - Joined lobby`, lobby);
|
|
setLobby(lobby);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to create/join lobby";
|
|
console.error("Lobby creation error:", errorMessage);
|
|
setError(errorMessage);
|
|
|
|
// If it's a server error (5xx), mark for retry when session is restored
|
|
if (
|
|
err instanceof Error &&
|
|
(err.message.includes("502") || err.message.includes("503") || err.message.includes("500"))
|
|
) {
|
|
console.log("Lobby - Server error detected, will retry when session is restored");
|
|
setShouldRetryLobby(true);
|
|
} else {
|
|
console.log("Lobby - Non-retryable error, clearing session");
|
|
setSession(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
setCreatingLobby(true);
|
|
getLobby(lobbyName, session).finally(() => {
|
|
setCreatingLobby(false);
|
|
});
|
|
}, [session, lobbyName, lobby, setLobby, setError, creatingLobby]);
|
|
const setName = (name: string) => {
|
|
sendJsonMessage({
|
|
type: "set_name",
|
|
data: { name, password: editPassword ? editPassword : undefined },
|
|
});
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const newName = editName.trim();
|
|
if (!newName || session?.name === newName) {
|
|
return;
|
|
}
|
|
setName(newName);
|
|
setEditName("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Paper className="Lobby" sx={{ p: 2, m: 2, width: "fit-content" }}>
|
|
{readyState !== ReadyState.OPEN || !session ? (
|
|
<ConnectionStatus readyState={readyState} reconnectAttempt={reconnectAttempt} />
|
|
) : (
|
|
<>
|
|
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "flex-start", flexDirection: "column" }}>
|
|
<Box>
|
|
<Typography variant="h5">AI Voice Chat Lobby: {lobbyName}</Typography>
|
|
</Box>
|
|
<Box>
|
|
{session.name && <Typography>You are logged in as: {session.name}</Typography>}
|
|
{!session.name && (
|
|
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
|
<Typography>Enter your name to join:</Typography>
|
|
<Typography variant="caption">
|
|
You can optionally set a password to reserve this name; supply it again to takeover the name from
|
|
another client.
|
|
</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, width: "100%" }}>
|
|
<Input
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e): void => {
|
|
setEditName(e.target.value);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Your name"
|
|
/>
|
|
<Input
|
|
type="password"
|
|
value={editPassword}
|
|
onChange={(e): void => setEditPassword(e.target.value)}
|
|
placeholder="Optional password"
|
|
/>
|
|
<Tooltip title="Optional: choose a short password to reserve this name. Keep it secret.">
|
|
<span />
|
|
</Tooltip>
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => {
|
|
setName(editName);
|
|
setEditName("");
|
|
setEditPassword("");
|
|
}}
|
|
disabled={!editName.trim()}
|
|
>
|
|
Join
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{session.name && (
|
|
<>
|
|
{/* {session.lobbies.map((lobby: string) => (
|
|
<Box key={lobby}>
|
|
<Button variant="contained" disabled={lobby === lobbyName} sx={{ mr: 1, mb: 1 }}>
|
|
{lobby === lobbyName ? `In Lobby: ${lobby}` : `Join Lobby: ${lobby}`}
|
|
</Button>
|
|
</Box>
|
|
))} */}
|
|
|
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
|
{session && socketUrl && lobby && (
|
|
<UserList socketUrl={socketUrl} session={session} lobbyId={lobby.id} />
|
|
)}
|
|
{session && socketUrl && lobby && (
|
|
<LobbyChat socketUrl={socketUrl} session={session} lobbyId={lobby.id} />
|
|
)}
|
|
{session && lobby && (
|
|
<BotManager
|
|
lobbyId={lobby.id}
|
|
onBotAdded={(botName) => console.log(`Bot ${botName} added to lobby`)}
|
|
sx={{ minWidth: "300px" }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Paper>
|
|
);
|
|
};
|
|
|
|
const App = () => {
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [sessionRetryAttempt, setSessionRetryAttempt] = useState<number>(0);
|
|
|
|
useEffect(() => {
|
|
if (error) {
|
|
setTimeout(() => setError(null), 5000);
|
|
}
|
|
}, [error]);
|
|
|
|
useEffect(() => {
|
|
if (!session) {
|
|
return;
|
|
}
|
|
console.log(`App - sessionId`, session.id);
|
|
}, [session]);
|
|
|
|
const getSession = useCallback(async () => {
|
|
try {
|
|
const session = await sessionsApi.getCurrent();
|
|
setSession(session);
|
|
setSessionRetryAttempt(0);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
|
console.error("Failed to get session:", errorMessage);
|
|
setError(errorMessage);
|
|
|
|
// Schedule retry after 5 seconds
|
|
setSessionRetryAttempt((prev) => prev + 1);
|
|
setTimeout(() => {
|
|
getSession(); // Retry
|
|
}, 5000);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (session) {
|
|
return;
|
|
}
|
|
getSession();
|
|
}, [session, getSession]);
|
|
|
|
return (
|
|
<Box>
|
|
{!session && (
|
|
<ConnectionStatus
|
|
readyState={sessionRetryAttempt > 0 ? ReadyState.CLOSED : ReadyState.CONNECTING}
|
|
reconnectAttempt={sessionRetryAttempt}
|
|
/>
|
|
)}
|
|
{session && (
|
|
<Router>
|
|
<Routes>
|
|
<Route element={<LobbyView {...{ setError, session, setSession }} />} path={`${base}/:lobbyName`} />
|
|
<Route element={<LobbyView {...{ setError, session, setSession }} />} path={`${base}`} />
|
|
</Routes>
|
|
</Router>
|
|
)}
|
|
{error && (
|
|
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
|
|
<Typography color="red">{error}</Typography>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default App;
|