diff --git a/.gitignore b/.gitignore index 4cf8dd1..5f75931 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -logs/* \ No newline at end of file +logs/* +db/* diff --git a/client/package-lock.json b/client/package-lock.json index 9daea82..5936c61 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,10 +12,10 @@ "@emotion/styled": "^11.10.6", "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.15", - "@types/jest": "^29.2.3", - "@types/node": "^18.11.10", - "@types/react": "^18.0.26", - "@types/react-dom": "^18.0.9", + "@types/jest": "^29.5.0", + "@types/node": "^18.15.11", + "@types/react": "^18.0.31", + "@types/react-dom": "^18.0.11", "fast-deep-equal": "^3.1.3", "history": "^5.3.0", "http-proxy-middleware": "^2.0.6", @@ -30,7 +30,8 @@ "react-router-dom": "^6.4.4", "react-scripts": "^5.0.1", "react-select": "^5.7.0", - "react-spinners": "^0.13.6" + "react-spinners": "^0.13.6", + "typescript": "^5.0.3" } }, "node_modules/@ampproject/remapping": { @@ -16414,16 +16415,15 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.3.tgz", + "integrity": "sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=12.20" } }, "node_modules/unbox-primitive": { diff --git a/client/src/App.js b/client/src/App.js deleted file mode 100644 index b1425f5..0000000 --- a/client/src/App.js +++ /dev/null @@ -1,12 +0,0 @@ -import './App.css'; -import { PlayerList } from './PlayerList'; - -function App() { - return ( -
- -
- ); -} - -export default App; diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..25b1e83 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,355 @@ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { + BrowserRouter as Router, + Route, + Routes, + useParams, + useNavigate +} from "react-router-dom"; + +import { GlobalContext, GlobalData } from "./GlobalContext"; +import { PersonList } from "./PersonList"; +import { Chat } from "./Chat"; + +import "./App.css"; +import equal from "fast-deep-equal"; + +// @ts-ignore +const base = process.env.REACT_APP_CHAT_BASE; + +const Table = () => { + const params = useParams(); + const [chatId, setChatId] = useState(params.chatId ? params.chatId : undefined); + const [ws, setWs] = useState (); /* tracks full websocket lifetime */ + const [connection, setConnection] = useState(undefined); /* set after ws is in OPEN */ + const [retryConnection, setRetryConnection] = useState(true); /* set when connection should be re-established */ + const [name, setName] = useState(""); + const [loaded, setLoaded] = useState(false); + + const [color, setColor] = useState(undefined); + const [priv, setPriv] = useState(undefined); + const [global, setGlobal] = useState({ + chatId: undefined, + ws: undefined, + name: '', + chat: [] + }); + const [count, setCount] = useState(0); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); + + const fields = ['id', 'state', 'color', 'name', 'private' ]; + const navigate = useNavigate(); + const onWsOpen = (event: any) => { + console.log(`ws: open`); + setError(""); + + /* We do not set the socket as connected until the 'open' message + * comes through */ + setConnection(ws); + + /* Request a full chat-update + * We only need chatId and name for App, however in the event + * of a network disconnect, we need to refresh the entire chat + * state on reload so all bound components reflect the latest + * state */ + event.target.send(JSON.stringify({ + type: 'chat-update' + })); + event.target.send(JSON.stringify({ + type: 'get', + fields + })); + }; + + const onWsMessage = (event: any) => { + const data = JSON.parse(event.data); + switch (data.type) { + case 'error': + console.error(`App - error`, data.error); + setError(data.error); + break; + case 'warning': + console.warn(`App - warning`, data.warning); + setWarning(data.warning); + setTimeout(() => { + console.log(`todo: stack warnings in a window and have them disappear one at a time.`); + console.log(`app - clearing warning`); + setWarning(""); + }, 3000); + break; + case 'chat-update': + if (!loaded) { + setLoaded(true); + } + console.log(`app - message - ${data.type}`, data.update); + + if ('private' in data.update && !equal(priv, data.update.private)) { + const priv = data.update.private; + if (priv.name !== name) { + console.log(`App - setting name (via private): ${priv.name}`); + setName(priv.name); + } + if (priv.color !== color) { + console.log(`App - setting color (via private): ${priv.color}`); + setColor(priv.color); + } + setPriv(priv); + } + + if ('name' in data.update) { + if (data.update.name) { + console.log(`App - setting name: ${data.update.name}`); + setName(data.update.name); + } else { + setWarning(""); + setError(""); + setPriv(undefined); + } + } + if ('id' in data.update && data.update.id !== chatId) { + console.log(`App - setting chatId ${data.update.id}`); + setChatId(data.update.id); + } + if ('color' in data.update && data.update.color !== color) { + console.log(`App - setting color: ${color}`); + setColor(data.update.color); + } + break; + default: + break; + } + }; + + const sendUpdate = (update: string) => { + if (!ws) { + return; + } + ws.send(JSON.stringify(update)); + }; + + const cbResetConnection = useCallback(() => { + let timer: any = 0; + function reset() { + timer = 0; + setRetryConnection(true); + }; + return () => { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(reset, 5000); + }; + }, [setRetryConnection]); + + const resetConnection = cbResetConnection(); + + if (global.ws !== connection + || global.name !== name + || global.chatId !== chatId) { + console.log(`board - (app) - setting global`, global, { + ws: connection, + name, + chatId + }); + setGlobal({ + ws: connection, + name, + chatId, + chat: [] + }); + } + + const onWsError = (event: any) => { + console.error(`ws: error`, event); + const error = `Connection to Ketr Ketran chat server failed! ` + + `Connection attempt will be retried every 5 seconds.`; + setError(error); + setGlobal(Object.assign({}, global, { ws: undefined })); + setWs(undefined); /* clear the socket */ + setConnection(undefined); /* clear the connection */ + resetConnection(); + }; + + const onWsClose = (event: any) => { + const error = `Connection to Ketr Ketran chat was lost. ` + + `Attempting to reconnect...`; + console.warn(`ws: close`); + setError(error); + setGlobal(Object.assign({}, global, { ws: undefined })); + setWs(undefined); /* clear the socket */ + setConnection(undefined); /* clear the connection */ + resetConnection(); + }; + + /* callback refs are used to provide correct state reference + * in the callback handlers, while also preventing rebinding + * of event handlers on every render */ + const refWsOpen = useRef(onWsOpen); + useEffect(() => { refWsOpen.current = onWsOpen; }); + const refWsMessage = useRef(onWsMessage); + useEffect(() => { refWsMessage.current = onWsMessage; }); + const refWsClose = useRef(onWsClose); + useEffect(() => { refWsClose.current = onWsClose; }); + const refWsError = useRef(onWsError); + useEffect(() => { refWsError.current = onWsError; }); + + /* This effect is responsible for triggering a new chat load if a + * chat id is not provided in the URL. If the chat is provided + * in the URL, the backend will create a new chat if necessary + * during the WebSocket connection sequence. + * + * This should be the only HTTP request made from the chat. + */ + useEffect(() => { + if (chatId) { + console.log(`Chat in use ${chatId}`) + return; + } + + console.log(`Requesting new chat.`); + + window.fetch(`${base}/api/v1/chats/`, { + method: 'POST', + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json' + }, + }).then((res) => { + if (res.status >= 400) { + const error = `Unable to connect to Ketr Ketran chat server! ` + + `Try refreshing your browser in a few seconds.`; + console.error(error); + setError(error); + throw new Error(error); + } + return res.json(); + }).then((update) => { + if (update.id !== chatId) { + console.log(`Chat available: ${update.id}`); + navigate(`${base}/${update.id}`); + setChatId(update.id); + } + }).catch((error) => { + console.error(error); + }); + }, [chatId, setChatId]); + + /* Once a chat id is known, create the sole WebSocket connection + * to the backend. This WebSocket is then shared with any component + * that performs chat state updates. Those components should + * bind to the 'message:chat-update' WebSocket event and parse + * their update information from those messages + */ + useEffect(() => { + if (!chatId) { + return; + } + + const unbind = () => { + console.log(`table - unbind`); + } + + console.log(`table - bind`); + + if (!ws && !connection && retryConnection) { + let loc = window.location, new_uri; + if (loc.protocol === "https:") { + new_uri = "wss"; + } else { + new_uri = "ws"; + } + new_uri = `${new_uri}://${loc.host}${base}/api/v1/chats/ws/${chatId}?${count}`; + console.log(`Attempting WebSocket connection to ${new_uri}`); + setWs(new WebSocket(new_uri)); + setConnection(undefined); + setRetryConnection(false); + setCount(count + 1); + return unbind; + } + + if (!ws) { + return unbind; + } + + const cbOpen = (e: any) => refWsOpen.current(e); + const cbMessage = (e: any) => refWsMessage.current(e); + const cbClose = (e: any) => refWsClose.current(e); + const cbError = (e: any) => refWsError.current(e); + + ws.addEventListener('open', cbOpen); + ws.addEventListener('close', cbClose); + ws.addEventListener('error', cbError); + ws.addEventListener('message', cbMessage); + + return () => { + unbind(); + ws.removeEventListener('open', cbOpen); + ws.removeEventListener('close', cbClose); + ws.removeEventListener('error', cbError); + ws.removeEventListener('message', cbMessage); + } + }, [ws, setWs, connection, setConnection, + retryConnection, setRetryConnection, chatId, + refWsOpen, refWsMessage, refWsClose, refWsError, count, setCount + ]); + + console.log(`board - (app) - Render with ws: ${ws ? '!' : ''}NULL, connection: ${connection ? '!' : ''}NULL`); + + return + { /* */} +
+
+ {name !== "" && } + {name !== "" && } +
+
+
; +}; + +const App = () => { + const [personId, setPersonId] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + if (personId) { + return; + } + window.fetch(`${base}/api/v1/chats/`, { + method: 'GET', + cache: 'no-cache', + credentials: 'same-origin', /* include cookies */ + headers: { + 'Content-Type': 'application/json' + }, + }).then((res) => { + if (res.status >= 400) { + const error = `Unable to connect to Ketr Ketran chat server! ` + + `Try refreshing your browser in a few seconds.`; + console.error(error); + setError(error); + } + console.log(res.headers); + return res.json(); + }).then((data) => { + setPersonId(data.person); + }).catch((error) => { + }); + }, [personId, setPersonId]); + + if (!personId) { + return <>{error}; + } + + return ( + + + } path={`${base}/:chatId`} /> + } path={`${base}`} /> + + + ); +} + +export default App; diff --git a/client/src/Chat.css b/client/src/Chat.css new file mode 100644 index 0000000..5c134df --- /dev/null +++ b/client/src/Chat.css @@ -0,0 +1,115 @@ + +.Chat { + display: flex; + flex-direction: column; + padding: 0.5em; + position: relative; + overflow: hidden; + margin: 0.25rem 0.25rem 0.25rem 0; +} + +.ChatList { + flex-direction: column; + position: relative; + flex-grow: 1; + /* for Firefox */ + min-height: 0; + /*scroll-behavior: smooth;*/ + align-items: flex-start; + overflow-x: hidden; + overflow-y: scroll; +} + +.ChatList .System { + background-color: #f0f0f0; + border-radius: 0.25em; +} + +.ChatInput { + flex-grow: 0; + flex-shrink: 0; +} + +.ChatList .System.MuiListItem-gutters { + margin-bottom: 0.25rem; + max-width: calc(100% - 0.5rem); +} + +.ChatList .System .MuiTypography-body1 { + font-size: 0.6rem; +} + +.ChatList .MuiListItem-gutters { + padding: 2px 0 2px 0; +} + +.ChatList .MuiTypography-body1 { + font-size: 0.8rem; + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.ChatList .MuiTypography-body1 > div { + align-items: center; + display: inline-flex; + flex-wrap: wrap; +} + +.ChatList .System .MuiTypography-body1 { + margin-left: 1em; +} + +.ChatList .MuiTypography-body2 { + font-size: 0.7rem; +} + +.ChatList .MuiListItemText-multiline { + margin-top: 0; + margin-bottom: 0; + padding: 4px 0px 4px 4px; +} + +.ChatList .PersonColor { + width: 1em; + height: 1em; + padding: 0; + margin-top: 6px; + align-self: flex-start; +} + +.ChatList .Resource { + display: inline-flex; + align-items: center; + justify-content: space-around; + height: 1.5rem; + width: 1.5rem; + min-width: 1.5rem; + min-height: 1.5rem; + pointer-events: none; + margin: 0 0.125rem; + background-size: 130%; + border: 2px solid #444; + border-radius: 2px; + margin-right: 0.5rem; + margin-top: 0.5rem; + top: -0.25rem; +} + +.ChatList .Resource > div { + position: absolute; + top: -0.625rem; + right: -0.625rem; + border-radius: 50%; + border: 1px solid white; + background-color: rgb(36, 148, 46); + font-size: 0.75rem; + width: 1rem; + height: 1rem; + text-align: center; + line-height: 1rem; +} + +.ChatList .Dice { + margin-left: 0.25em; +} \ No newline at end of file diff --git a/client/src/Chat.tsx b/client/src/Chat.tsx new file mode 100644 index 0000000..dfd7aa8 --- /dev/null +++ b/client/src/Chat.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect, useContext, useRef, useCallback, useMemo } from "react"; +import Paper from '@mui/material/Paper'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Moment from 'react-moment'; +import TextField from '@mui/material/TextField'; +import 'moment-timezone'; +import equal from "fast-deep-equal"; + +import "./Chat.css"; +import { PersonColor } from './PersonColor'; +import { GlobalContext } from "./GlobalContext"; + +export type ChatData = { + date: string, + message: string +}; + +const Chat = () => { + const [lastTop, setLastTop] = useState(0); + const [autoScroll, setAutoScroll] = useState(true); + const [latest, setLatest] = useState(''); + const [scrollTime, setScrollTime] = useState(0); + const [chat, setChat] = useState([]); + const [startTime, setStartTime] = useState(0); + + const { ws, name } = useContext(GlobalContext); + const fields = useMemo(() => [ + 'chat', 'startTime' + ], []); + const onWsMessage = (event: any) => { + const data = JSON.parse(event.data); + switch (data.type) { + case 'game-update': + console.log(`chat - game update`); + if (data.update.chat && !equal(data.update.chat, chat)) { + console.log(`chat - game update - ${data.update.chat.length} lines`); + setChat(data.update.chat); + } + if (data.update.startTime && data.update.startTime !== startTime) { + setStartTime(data.update.startTime); + } + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + useEffect(() => { refWsMessage.current = onWsMessage; }); + useEffect(() => { + if (!ws) { return; } + const cbMessage = (e: any) => refWsMessage.current(e); + ws.addEventListener('message', cbMessage); + return () => { + ws.removeEventListener('message', cbMessage); + } + }, [ws, refWsMessage]); + useEffect(() => { + if (!ws) { return; } + ws.send(JSON.stringify({ + type: 'get', + fields + })); + }, [ws, fields]); + + const chatKeyPress = useCallback((event: any) => { + if (event.key === "Enter") { + if (!autoScroll) { + setAutoScroll(true); + } + + if (!ws) { + return; + } + ws.send(JSON.stringify({ type: 'chat', message: event.target.value })); + event.target.value = ""; + } + }, [ws, setAutoScroll, autoScroll]); + + const chatScroll = (event: any) => { + const chatList = event.target, + fromBottom = Math.round(Math.abs((chatList.scrollHeight - chatList.offsetHeight) - chatList.scrollTop)); + + /* If scroll is within 20 pixels of the bottom, turn on auto-scroll */ + const shouldAutoscroll = (fromBottom < 20); + + if (shouldAutoscroll !== autoScroll) { + setAutoScroll(shouldAutoscroll); + } + + /* If the list should not auto scroll, then cache the current + * top of the list and record when we did this so we honor + * the auto-scroll for at least 500ms */ + if (!shouldAutoscroll) { + const target = Math.round(chatList.scrollTop); + if (target !== lastTop) { + setLastTop(target); + setScrollTime(Date.now()); + } + } + }; + + useEffect(() => { + const chatList = document.getElementById("ChatList"); + if (!chatList) { + return; + } + const currentTop = Math.round(chatList.scrollTop); + + if (autoScroll) { + /* Auto-scroll to the bottom of the chat window */ + const target = Math.round(chatList.scrollHeight - chatList.offsetHeight); + if (currentTop !== target) { + chatList.scrollTop = target; + } + return; + } + + /* Maintain current position in scrolled view if the user hasn't + * been scrolling in the past 0.5s */ + if ((Date.now() - scrollTime) > 500 && currentTop !== lastTop) { + chatList.scrollTop = lastTop; + } + }); + + const messages = chat.map((item: any, index) => { + let message; + /* Do not perform extra parsing on player-generated + * messages */ + if (item.normalChat) { + message =
{item.message}
; + } else { + const punctuation = item.message.match(/(\.+$)/); + let period: any; + if (punctuation) { + period = punctuation[1]; + } else { + period = ''; + } + let lines = item.message.split('.'); + message = lines + .filter((line: string) => line.trim() !== '') + .map((line: string, index: number) => { + let start = line, message; + while (start) { + message = <>{start}{message}; + start = ''; + } + return
{ message }{ period }
; + }); + } + + return ( + + { item.color && + + } + Date.now() ? + Date.now() : item.date} interval={1000}/>} /> + + ); + }); + + if (chat.length && chat[chat.length - 1].date !== latest) { + setLatest(chat[chat.length - 1].date); + setAutoScroll(true); + } + + return ( + + + { messages } + + Game duration: } + variant="outlined"/> + + ); +} + +export { Chat }; diff --git a/client/src/GlobalContext.js b/client/src/GlobalContext.tsx similarity index 55% rename from client/src/GlobalContext.js rename to client/src/GlobalContext.tsx index 5e70bcf..e06da4d 100644 --- a/client/src/GlobalContext.js +++ b/client/src/GlobalContext.tsx @@ -1,6 +1,12 @@ import { createContext } from "react"; -const global = { +export type GlobalData = { + chatId: string | undefined, + ws: WebSocket | undefined, + name: string, + chat: string[] +} +const global: GlobalData = { chatId: undefined, ws: undefined, name: "", diff --git a/client/src/MediaControl.js b/client/src/MediaControl.tsx similarity index 86% rename from client/src/MediaControl.js rename to client/src/MediaControl.tsx index b2fd10a..356d707 100644 --- a/client/src/MediaControl.js +++ b/client/src/MediaControl.tsx @@ -11,17 +11,17 @@ import Mic from '@mui/icons-material/Mic'; import VideocamOff from '@mui/icons-material/VideocamOff'; import Videocam from '@mui/icons-material/Videocam'; -import { GlobalContext } from "./GlobalContext.js"; +import { GlobalContext, GlobalData } from "./GlobalContext"; const debug = true; /* Proxy object so we can pass in srcObject to