Added chat
This commit is contained in:
parent
d69037ff41
commit
a292b14028
@ -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
43
client/src/LobbyChat.css
Normal 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
194
client/src/LobbyChat.tsx
Normal 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 };
|
105
server/main.py
105
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)
|
||||
|
@ -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
|
||||
# =============================================================================
|
||||
|
Loading…
x
Reference in New Issue
Block a user