From a292b14028eaa1904e6b65a3e957fcadefcff65b Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Mon, 1 Sep 2025 20:00:42 -0700 Subject: [PATCH] Added chat --- client/src/App.tsx | 13 ++- client/src/LobbyChat.css | 43 +++++++++ client/src/LobbyChat.tsx | 194 +++++++++++++++++++++++++++++++++++++++ server/main.py | 105 +++++++++++++++++++++ shared/models.py | 43 +++++++++ 5 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 client/src/LobbyChat.css create mode 100644 client/src/LobbyChat.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index cc28cee..c8f3ccf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,6 +3,7 @@ import { Input, Paper, Typography } from "@mui/material"; import { Session } 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"; @@ -158,10 +159,7 @@ const LobbyView: React.FC = (props: LobbyProps) => { return ( {readyState !== ReadyState.OPEN || !session ? ( - + ) : ( <> @@ -223,7 +221,12 @@ const LobbyView: React.FC = (props: LobbyProps) => { ))} */} - {session && socketUrl && } + + {session && socketUrl && } + {session && socketUrl && lobby && ( + + )} + )} diff --git a/client/src/LobbyChat.css b/client/src/LobbyChat.css new file mode 100644 index 0000000..e17f83a --- /dev/null +++ b/client/src/LobbyChat.css @@ -0,0 +1,43 @@ +.lobby-chat { + min-width: 300px; + max-width: 400px; +} + +.chat-messages { + border: 1px solid #e0e0e0; + border-radius: 4px; + background-color: #fafafa; +} + +.chat-message { + margin: 2px 0 !important; +} + +.chat-message.own-message { + align-items: flex-end !important; +} + +.chat-message.other-message { + align-items: flex-start !important; +} + +.chat-message .MuiBox-root { + position: relative; +} + +.own-message .MuiBox-root { + background-color: #e3f2fd !important; + border-bottom-right-radius: 4px !important; +} + +.other-message .MuiBox-root { + background-color: #f5f5f5 !important; + border-bottom-left-radius: 4px !important; +} + +@media (max-width: 768px) { + .lobby-chat { + min-width: 250px; + max-width: 300px; + } +} diff --git a/client/src/LobbyChat.tsx b/client/src/LobbyChat.tsx new file mode 100644 index 0000000..c7c21cc --- /dev/null +++ b/client/src/LobbyChat.tsx @@ -0,0 +1,194 @@ +import React, { useState, useEffect, useRef, KeyboardEvent } from "react"; +import { + Paper, + TextField, + Button, + List, + ListItem, + ListItemText, + Typography, + Box, + IconButton, +} from "@mui/material"; +import SendIcon from "@mui/icons-material/Send"; +import useWebSocket from "react-use-websocket"; +import { Session } from "./GlobalContext"; +import "./LobbyChat.css"; + +type ChatMessage = { + id: string; + message: string; + sender_name: string; + sender_session_id: string; + timestamp: number; + lobby_id: string; +}; + +type LobbyChatProps = { + socketUrl: string; + session: Session; + lobbyId: string; +}; + +const LobbyChat: React.FC = ({ socketUrl, session, lobbyId }) => { + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(""); + const messagesEndRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + const { sendJsonMessage } = useWebSocket(socketUrl, { + share: true, + onMessage: (event: MessageEvent) => { + const message = JSON.parse(event.data); + const data: any = message.data; + + switch (message.type) { + case "chat_message": + const chatMessage = data as ChatMessage; + setMessages(prev => [...prev, chatMessage]); + break; + case "chat_messages": + const chatMessages = data.messages as ChatMessage[]; + setMessages(chatMessages); + break; + default: + break; + } + }, + }); + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + useEffect(() => { + // Request recent chat messages when component mounts or when joining a lobby + if (socketUrl && session && session.name) { + sendJsonMessage({ + type: "get_chat_messages", + }); + } + }, [socketUrl, session, sendJsonMessage]); + + const handleSendMessage = () => { + if (!newMessage.trim() || !session.name) { + return; + } + + sendJsonMessage({ + type: "send_chat_message", + data: { + message: newMessage.trim(), + }, + }); + + setNewMessage(""); + }; + + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSendMessage(); + } + }; + + const formatTimestamp = (timestamp: number): string => { + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }; + + const isOwnMessage = (message: ChatMessage): boolean => { + return message.sender_session_id === session.id; + }; + + return ( + + + Chat + + + + + {messages.length === 0 ? ( + + + + ) : ( + messages.map((message) => ( + + + + {message.sender_name} + + + {message.message} + + + {formatTimestamp(message.timestamp)} + + + + )) + )} + +
+ + + {session.name ? ( + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + multiline + maxRows={3} + /> + + + + + ) : ( + + Please set your name to send messages + + )} + + ); +}; + +export { LobbyChat }; +export type { ChatMessage }; diff --git a/server/main.py b/server/main.py index 55e67d5..8e42fa6 100644 --- a/server/main.py +++ b/server/main.py @@ -41,6 +41,8 @@ from shared.models import ( AdminSetPassword, AdminClearPassword, JoinStatusModel, + ChatMessageModel, + ChatMessagesResponse, ) @@ -131,6 +133,7 @@ class Lobby: self.name = name self.sessions: dict[str, Session] = {} # All lobby members self.private = private + self.chat_messages: list[ChatMessageModel] = [] # Store chat messages def getName(self) -> str: return f"{self.short}:{self.name}" @@ -185,6 +188,41 @@ class Lobby: del self.sessions[session.id] await self.update_state() + def add_chat_message(self, session: Session, message: str) -> ChatMessageModel: + """Add a chat message to the lobby and return the message data""" + import time + + chat_message = ChatMessageModel( + id=secrets.token_hex(8), + message=message, + sender_name=session.name or session.short, + sender_session_id=session.id, + timestamp=time.time(), + lobby_id=self.id, + ) + self.chat_messages.append(chat_message) + # Keep only the latest 100 messages per lobby + if len(self.chat_messages) > 100: + self.chat_messages = self.chat_messages[-100:] + return chat_message + + def get_chat_messages(self, limit: int = 50) -> list[ChatMessageModel]: + """Get the most recent chat messages from the lobby""" + return self.chat_messages[-limit:] if self.chat_messages else [] + + async def broadcast_chat_message(self, chat_message: ChatMessageModel) -> None: + """Broadcast a chat message to all connected sessions in the lobby""" + for session in self.sessions.values(): + if session.ws: + try: + await session.ws.send_json( + {"type": "chat_message", "data": chat_message.model_dump()} + ) + except Exception as e: + logger.warning( + f"Failed to send chat message to {session.getName()}: {e}" + ) + class Session: _instances: list[Session] = [] @@ -576,6 +614,25 @@ async def lobby_create( ) +@app.get(public_url + "api/lobby/{lobby_id}/chat", response_model=ChatMessagesResponse) +async def get_chat_messages( + request: Request, + lobby_id: str = Path(...), + limit: int = 50, +) -> Response | ChatMessagesResponse: + """Get chat messages for a lobby""" + try: + lobby = getLobby(lobby_id) + except Exception as e: + return Response( + content=json.dumps({"error": str(e)}), + status_code=404, + media_type="application/json", + ) + + messages = lobby.get_chat_messages(limit) + + return ChatMessagesResponse(messages=messages) # Register websocket endpoint directly on app with full public_url path @app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}") async def lobby_join( @@ -798,6 +855,54 @@ async def lobby_join( case "list_users": await lobby.update_state(session) + case "get_chat_messages": + # Send recent chat messages to the requesting client + messages = lobby.get_chat_messages(50) + await websocket.send_json( + { + "type": "chat_messages", + "data": { + "messages": [msg.model_dump() for msg in messages] + }, + } + ) + + case "send_chat_message": + if not data or "message" not in data: + logger.error( + f"{session.getName()} - send_chat_message missing message" + ) + await websocket.send_json( + { + "type": "error", + "error": "send_chat_message missing message", + } + ) + continue + + if not session.name: + logger.error( + f"{session.getName()} - Cannot send chat message without name" + ) + await websocket.send_json( + { + "type": "error", + "error": "Must set name before sending chat messages", + } + ) + continue + + message_text = str(data["message"]).strip() + if not message_text: + continue + + # Add the message to the lobby and broadcast it + chat_message = lobby.add_chat_message(session, message_text) + await lobby.broadcast_chat_message(chat_message) + logger.info( + f"{session.getName()} sent chat message to {lobby.getName()}: {message_text[:50]}..." + ) + case "join": logger.info(f"{session.getName()} <- join({lobby.getName()})") await session.join(lobby=lobby) diff --git a/shared/models.py b/shared/models.py index 348d3a5..07e9cf2 100644 --- a/shared/models.py +++ b/shared/models.py @@ -152,6 +152,8 @@ class WebSocketMessageModel(BaseModel): | SessionDescriptionModel | IceCandidateModel | LobbyCreateResponse + | ChatMessageModel + | ChatMessagesListModel | Dict[str, str] ) # Generic dict for simple messages @@ -200,6 +202,47 @@ class IceCandidateModel(BaseModel): candidate: ICECandidateDictModel +# ============================================================================= +# Chat Message Models +# ============================================================================= + + +class ChatMessageModel(BaseModel): + """Chat message model""" + + id: str + message: str + sender_name: str + sender_session_id: str + timestamp: float + lobby_id: str + + +class ChatMessagesSendModel(BaseModel): + """WebSocket message for sending a chat message""" + + message: str + + +class ChatMessagesListModel(BaseModel): + """WebSocket message containing list of chat messages""" + + messages: List[ChatMessageModel] + + +class ChatMessagesRequest(BaseModel): + """Request for chat messages""" + + lobby_id: str + limit: Optional[int] = 50 + + +class ChatMessagesResponse(BaseModel): + """Response containing chat messages""" + + messages: List[ChatMessageModel] + + # ============================================================================= # Data Persistence Models # =============================================================================