feat: implement real-time chat with Socket.IO

Implemented WebSocket-based real-time messaging for both event rooms and private match chats using Socket.IO with comprehensive test coverage.

Backend changes:
- Installed socket.io@4.8.1 for WebSocket server
- Created Socket.IO server with JWT authentication middleware
- Implemented event room management (join/leave/messages)
- Added active users tracking with real-time updates
- Implemented private match room messaging
- Integrated Socket.IO with Express HTTP server
- Messages are persisted to PostgreSQL via Prisma
- Added 12 comprehensive unit tests (89.13% coverage)

Frontend changes:
- Installed socket.io-client for WebSocket connections
- Created socket service layer for connection management
- Updated EventChatPage with real-time messaging
- Updated MatchChatPage with real-time private chat
- Added connection status indicators (● Connected/Disconnected)
- Disabled message input when not connected

Infrastructure:
- Updated nginx config to proxy WebSocket connections at /socket.io
- Added Upgrade and Connection headers for WebSocket support
- Set long timeouts (7d) for persistent WebSocket connections

Key features:
- JWT-authenticated socket connections
- Room-based architecture for events and matches
- Real-time message broadcasting
- Active users list with automatic updates
- Automatic cleanup on disconnect
- Message persistence in database

Test coverage:
- 12 tests passing (authentication, event rooms, match rooms, disconnect, errors)
- Socket.IO module: 89.13% statements, 81.81% branches, 91.66% functions
- Overall coverage: 81.19%

Phase 1, Step 4 completed. Ready for Phase 2 (Core Features).
This commit is contained in:
Radosław Gierwiało
2025-11-12 22:42:15 +01:00
parent 3788274f73
commit 75cb4b16e7
11 changed files with 1472 additions and 63 deletions

View File

@@ -3,17 +3,17 @@ import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext';
import { mockEvents } from '../mocks/events';
import { mockEventMessages } from '../mocks/messages';
import { mockUsers } from '../mocks/users';
import { Send, UserPlus } from 'lucide-react';
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
const EventChatPage = () => {
const { eventId } = useParams();
const { user } = useAuth();
const navigate = useNavigate();
const [messages, setMessages] = useState(mockEventMessages);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [activeUsers, setActiveUsers] = useState(mockUsers.slice(1, 5));
const [activeUsers, setActiveUsers] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const messagesEndRef = useRef(null);
const event = mockEvents.find(e => e.id === parseInt(eventId));
@@ -26,29 +26,86 @@ const EventChatPage = () => {
scrollToBottom();
}, [messages]);
useEffect(() => {
// Connect to Socket.IO
const socket = connectSocket();
if (!socket) {
console.error('Failed to connect to socket');
return;
}
// Socket event listeners
socket.on('connect', () => {
setIsConnected(true);
// Join event room
socket.emit('join_event_room', { eventId: parseInt(eventId) });
});
socket.on('disconnect', () => {
setIsConnected(false);
});
// Receive messages
socket.on('event_message', (message) => {
setMessages((prev) => [...prev, message]);
});
// Receive active users list
socket.on('active_users', (users) => {
// Filter out duplicates and current user
const uniqueUsers = users
.filter((u, index, self) =>
index === self.findIndex((t) => t.userId === u.userId)
)
.filter((u) => u.userId !== user.id);
setActiveUsers(uniqueUsers);
});
// User joined notification
socket.on('user_joined', (userData) => {
console.log(`${userData.username} joined the room`);
});
// User left notification
socket.on('user_left', (userData) => {
console.log(`${userData.username} left the room`);
});
// Cleanup
return () => {
socket.emit('leave_event_room', { eventId: parseInt(eventId) });
socket.off('connect');
socket.off('disconnect');
socket.off('event_message');
socket.off('active_users');
socket.off('user_joined');
socket.off('user_left');
};
}, [eventId, user.id]);
const handleSendMessage = (e) => {
e.preventDefault();
if (!newMessage.trim()) return;
const message = {
id: messages.length + 1,
room_id: parseInt(eventId),
user_id: user.id,
username: user.username,
avatar: user.avatar,
content: newMessage,
type: 'text',
created_at: new Date().toISOString(),
};
const socket = getSocket();
if (!socket || !socket.connected) {
alert('Not connected to chat server');
return;
}
// Send message via Socket.IO
socket.emit('send_event_message', {
eventId: parseInt(eventId),
content: newMessage,
});
setMessages([...messages, message]);
setNewMessage('');
};
const handleMatchWith = (userId) => {
// Mockup - in the future will be WebSocket request
// TODO: Implement match request
alert(`Match request sent to user!`);
// Simulate acceptance after 1 second
setTimeout(() => {
navigate(`/matches/1/chat`);
}, 1000);
@@ -70,6 +127,11 @@ const EventChatPage = () => {
<div className="bg-primary-600 text-white p-4">
<h2 className="text-2xl font-bold">{event.name}</h2>
<p className="text-primary-100 text-sm">{event.location}</p>
<div className="mt-2">
<span className={`text-xs px-2 py-1 rounded ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}>
{isConnected ? '● Connected' : '● Disconnected'}
</span>
</div>
</div>
<div className="flex h-[calc(100vh-280px)]">
@@ -78,10 +140,13 @@ const EventChatPage = () => {
<h3 className="font-semibold text-gray-900 mb-4">
Active users ({activeUsers.length})
</h3>
{activeUsers.length === 0 && (
<p className="text-sm text-gray-500">No other users online</p>
)}
<div className="space-y-2">
{activeUsers.map((activeUser) => (
<div
key={activeUser.id}
key={activeUser.userId}
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg"
>
<div className="flex items-center space-x-2">
@@ -94,13 +159,10 @@ const EventChatPage = () => {
<p className="text-sm font-medium text-gray-900">
{activeUser.username}
</p>
<p className="text-xs text-gray-500">
{activeUser.rating}
</p>
</div>
</div>
<button
onClick={() => handleMatchWith(activeUser.id)}
onClick={() => handleMatchWith(activeUser.userId)}
className="p-1 text-primary-600 hover:bg-primary-50 rounded"
title="Connect"
>
@@ -115,8 +177,13 @@ const EventChatPage = () => {
<div className="flex-1 flex flex-col">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-500 py-8">
No messages yet. Start the conversation!
</div>
)}
{messages.map((message) => {
const isOwnMessage = message.user_id === user.id;
const isOwnMessage = message.userId === user.id;
return (
<div
key={message.id}
@@ -134,7 +201,7 @@ const EventChatPage = () => {
{message.username}
</span>
<span className="text-xs text-gray-500">
{new Date(message.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{new Date(message.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div
@@ -162,11 +229,13 @@ const EventChatPage = () => {
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Write a message..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
disabled={!isConnected}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
/>
<button
type="submit"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
disabled={!isConnected}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
>
<Send className="w-5 h-5" />
</button>

View File

@@ -2,15 +2,15 @@ import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext';
import { mockPrivateMessages } from '../mocks/messages';
import { mockUsers } from '../mocks/users';
import { Send, Video, Upload, X, Check, Link as LinkIcon } from 'lucide-react';
import { connectSocket, getSocket } from '../services/socket';
const MatchChatPage = () => {
const { matchId } = useParams();
const { user } = useAuth();
const navigate = useNavigate();
const [messages, setMessages] = useState(mockPrivateMessages);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [selectedFile, setSelectedFile] = useState(null);
const [isTransferring, setIsTransferring] = useState(false);
@@ -18,10 +18,11 @@ const MatchChatPage = () => {
const [webrtcStatus, setWebrtcStatus] = useState('disconnected'); // disconnected, connecting, connected, failed
const [showLinkInput, setShowLinkInput] = useState(false);
const [videoLink, setVideoLink] = useState('');
const [isConnected, setIsConnected] = useState(false);
const messagesEndRef = useRef(null);
const fileInputRef = useRef(null);
// Partner user (mockup)
// Partner user (mockup - TODO: fetch from backend in Phase 2)
const partner = mockUsers[1]; // sarah_swing
const scrollToBottom = () => {
@@ -32,21 +33,56 @@ const MatchChatPage = () => {
scrollToBottom();
}, [messages]);
useEffect(() => {
// Connect to Socket.IO
const socket = connectSocket();
if (!socket) {
console.error('Failed to connect to socket');
return;
}
// Socket event listeners
socket.on('connect', () => {
setIsConnected(true);
// Join match room
socket.emit('join_match_room', { matchId: parseInt(matchId) });
console.log(`Joined match room ${matchId}`);
});
socket.on('disconnect', () => {
setIsConnected(false);
});
// Receive messages
socket.on('match_message', (message) => {
setMessages((prev) => [...prev, message]);
});
// Cleanup
return () => {
socket.off('connect');
socket.off('disconnect');
socket.off('match_message');
};
}, [matchId, user.id]);
const handleSendMessage = (e) => {
e.preventDefault();
if (!newMessage.trim()) return;
const message = {
id: messages.length + 1,
room_id: 10,
user_id: user.id,
username: user.username,
content: newMessage,
type: 'text',
created_at: new Date().toISOString(),
};
const socket = getSocket();
if (!socket || !socket.connected) {
alert('Not connected to chat server');
return;
}
// Send message via Socket.IO
socket.emit('send_match_message', {
matchId: parseInt(matchId),
content: newMessage,
});
setMessages([...messages, message]);
setNewMessage('');
};
@@ -204,8 +240,13 @@ const MatchChatPage = () => {
<div className="flex flex-col h-[calc(100vh-320px)]">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-500 py-8">
No messages yet. Start the conversation!
</div>
)}
{messages.map((message) => {
const isOwnMessage = message.user_id === user.id;
const isOwnMessage = message.userId === user.id;
return (
<div
key={message.id}
@@ -213,7 +254,7 @@ const MatchChatPage = () => {
>
<div className={`flex items-start space-x-2 max-w-md ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
<img
src={isOwnMessage ? user.avatar : partner.avatar}
src={message.avatar}
alt={message.username}
className="w-8 h-8 rounded-full"
/>
@@ -223,7 +264,7 @@ const MatchChatPage = () => {
{message.username}
</span>
<span className="text-xs text-gray-500">
{new Date(message.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{new Date(message.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div
@@ -376,11 +417,13 @@ const MatchChatPage = () => {
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Write a message..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
disabled={!isConnected}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
/>
<button
type="submit"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
disabled={!isConnected}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
>
<Send className="w-5 h-5" />
</button>