2025-09-01 20:34:01 -07:00

314 lines
10 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 "./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";
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 { 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),
shouldReconnect: (closeEvent) => true, // Will attempt to reconnect on all close events
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.error}`);
setError(data.error);
break;
default:
break;
}
}, [lastJsonMessage, session, setError, setSession]);
useEffect(() => {
console.log("app - WebSocket connection status: ", readyState);
}, [readyState]);
useEffect(() => {
if (!session || !lobbyName || creatingLobby || (lobby && lobby.name === lobbyName)) {
return;
}
const getLobby = async (lobbyName: string, session: Session) => {
const res = await fetch(`${base}/api/lobby/${session.id}`, {
method: "POST",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "lobby_create",
data: {
name: lobbyName,
private: false,
},
}),
});
if (res.status >= 400) {
const error = `Unable to connect to AI Voice Chat server! Try refreshing your browser in a few seconds.`;
console.error(error);
setError(error);
}
const data = await res.json();
if (data.error) {
console.error(`Lobby - Server error: ${data.error}`);
setError(data.error);
return;
}
if (data.type !== "lobby_created") {
console.error(`Lobby - Unexpected response type: ${data.type}`);
setError(`Unexpected response from server`);
return;
}
const lobby: Lobby = data.data;
console.log(`Lobby - Joined lobby`, lobby);
setLobby(lobby);
};
setCreatingLobby(true);
getLobby(lobbyName, session).then(() => {
setCreatingLobby(false);
});
}, [session, lobbyName, setLobby, setError]);
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 && <UserList socketUrl={socketUrl} session={session} />}
{session && socketUrl && lobby && (
<LobbyChat socketUrl={socketUrl} session={session} lobbyId={lobby.id} />
)}
</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 res = await fetch(`${base}/api/session`, {
method: "GET",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
});
if (res.status >= 400) {
throw new Error(`HTTP ${res.status}: Unable to connect to AI Voice Chat server`);
}
const data = await res.json();
if (data.error) {
throw new Error(`Server error: ${data.error}`);
}
setSession(data);
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;