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