Added chat

This commit is contained in:
James Ketr 2025-09-01 20:00:42 -07:00
parent d69037ff41
commit a292b14028
5 changed files with 393 additions and 5 deletions

View File

@ -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<LobbyProps> = (props: LobbyProps) => {
return (
<Paper className="Lobby" sx={{ p: 2, m: 2, width: "fit-content" }}>
{readyState !== ReadyState.OPEN || !session ? (
<ConnectionStatus
readyState={readyState}
reconnectAttempt={reconnectAttempt}
/>
<ConnectionStatus readyState={readyState} reconnectAttempt={reconnectAttempt} />
) : (
<>
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "flex-start", flexDirection: "column" }}>
@ -223,7 +221,12 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
</Box>
))} */}
{session && socketUrl && <UserList socketUrl={socketUrl} session={session} />}
<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>
</>
)}
</>

43
client/src/LobbyChat.css Normal file
View File

@ -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;
}
}

194
client/src/LobbyChat.tsx Normal file
View File

@ -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<LobbyChatProps> = ({ socketUrl, session, lobbyId }) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newMessage, setNewMessage] = useState<string>("");
const messagesEndRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<Paper className="lobby-chat" sx={{ height: 400, display: "flex", flexDirection: "column", p: 1 }}>
<Typography variant="h6" sx={{ mb: 1, textAlign: "center" }}>
Chat
</Typography>
<Box className="chat-messages" sx={{ flexGrow: 1, overflowY: "auto", mb: 1 }}>
<List dense>
{messages.length === 0 ? (
<ListItem>
<ListItemText
primary="No messages yet"
sx={{ textAlign: "center", color: "text.secondary" }}
/>
</ListItem>
) : (
messages.map((message) => (
<ListItem
key={message.id}
className={`chat-message ${isOwnMessage(message) ? 'own-message' : 'other-message'}`}
sx={{
flexDirection: "column",
alignItems: isOwnMessage(message) ? "flex-end" : "flex-start",
py: 0.5,
}}
>
<Box
sx={{
maxWidth: "80%",
p: 1,
borderRadius: 2,
backgroundColor: isOwnMessage(message) ? "#e3f2fd" : "#f5f5f5",
}}
>
<Typography variant="body2" sx={{ fontWeight: "bold", mb: 0.5 }}>
{message.sender_name}
</Typography>
<Typography variant="body1" sx={{ wordBreak: "break-word" }}>
{message.message}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", mt: 0.5, display: "block" }}>
{formatTimestamp(message.timestamp)}
</Typography>
</Box>
</ListItem>
))
)}
</List>
<div ref={messagesEndRef} />
</Box>
{session.name ? (
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-end" }}>
<TextField
fullWidth
size="small"
variant="outlined"
placeholder="Type a message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyPress={handleKeyPress}
multiline
maxRows={3}
/>
<IconButton
color="primary"
onClick={handleSendMessage}
disabled={!newMessage.trim()}
sx={{ mb: 0.5 }}
>
<SendIcon />
</IconButton>
</Box>
) : (
<Typography variant="body2" sx={{ textAlign: "center", color: "text.secondary", py: 1 }}>
Please set your name to send messages
</Typography>
)}
</Paper>
);
};
export { LobbyChat };
export type { ChatMessage };

View File

@ -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)

View File

@ -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
# =============================================================================