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 { Session } from "./GlobalContext";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
|
import { LobbyChat } from "./LobbyChat";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { ws_base, base } from "./Common";
|
import { ws_base, base } from "./Common";
|
||||||
import { Box, Button, Tooltip } from "@mui/material";
|
import { Box, Button, Tooltip } from "@mui/material";
|
||||||
@ -158,10 +159,7 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
return (
|
return (
|
||||||
<Paper className="Lobby" sx={{ p: 2, m: 2, width: "fit-content" }}>
|
<Paper className="Lobby" sx={{ p: 2, m: 2, width: "fit-content" }}>
|
||||||
{readyState !== ReadyState.OPEN || !session ? (
|
{readyState !== ReadyState.OPEN || !session ? (
|
||||||
<ConnectionStatus
|
<ConnectionStatus readyState={readyState} reconnectAttempt={reconnectAttempt} />
|
||||||
readyState={readyState}
|
|
||||||
reconnectAttempt={reconnectAttempt}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "flex-start", flexDirection: "column" }}>
|
<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>
|
</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,
|
AdminSetPassword,
|
||||||
AdminClearPassword,
|
AdminClearPassword,
|
||||||
JoinStatusModel,
|
JoinStatusModel,
|
||||||
|
ChatMessageModel,
|
||||||
|
ChatMessagesResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -131,6 +133,7 @@ class Lobby:
|
|||||||
self.name = name
|
self.name = name
|
||||||
self.sessions: dict[str, Session] = {} # All lobby members
|
self.sessions: dict[str, Session] = {} # All lobby members
|
||||||
self.private = private
|
self.private = private
|
||||||
|
self.chat_messages: list[ChatMessageModel] = [] # Store chat messages
|
||||||
|
|
||||||
def getName(self) -> str:
|
def getName(self) -> str:
|
||||||
return f"{self.short}:{self.name}"
|
return f"{self.short}:{self.name}"
|
||||||
@ -185,6 +188,41 @@ class Lobby:
|
|||||||
del self.sessions[session.id]
|
del self.sessions[session.id]
|
||||||
await self.update_state()
|
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:
|
class Session:
|
||||||
_instances: list[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
|
# Register websocket endpoint directly on app with full public_url path
|
||||||
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
|
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
|
||||||
async def lobby_join(
|
async def lobby_join(
|
||||||
@ -798,6 +855,54 @@ async def lobby_join(
|
|||||||
case "list_users":
|
case "list_users":
|
||||||
await lobby.update_state(session)
|
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":
|
case "join":
|
||||||
logger.info(f"{session.getName()} <- join({lobby.getName()})")
|
logger.info(f"{session.getName()} <- join({lobby.getName()})")
|
||||||
await session.join(lobby=lobby)
|
await session.join(lobby=lobby)
|
||||||
|
@ -152,6 +152,8 @@ class WebSocketMessageModel(BaseModel):
|
|||||||
| SessionDescriptionModel
|
| SessionDescriptionModel
|
||||||
| IceCandidateModel
|
| IceCandidateModel
|
||||||
| LobbyCreateResponse
|
| LobbyCreateResponse
|
||||||
|
| ChatMessageModel
|
||||||
|
| ChatMessagesListModel
|
||||||
| Dict[str, str]
|
| Dict[str, str]
|
||||||
) # Generic dict for simple messages
|
) # Generic dict for simple messages
|
||||||
|
|
||||||
@ -200,6 +202,47 @@ class IceCandidateModel(BaseModel):
|
|||||||
candidate: ICECandidateDictModel
|
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
|
# Data Persistence Models
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
Loading…
x
Reference in New Issue
Block a user