From 9d8fc9f6d6ecf98c072fdf1ccf6846563532880d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Thu, 13 Nov 2025 20:16:58 +0100 Subject: [PATCH] feat: add chat message history and infinite scroll Backend changes: - Socket.IO: Send last 20 messages on join_event_room - REST API: Add GET /api/events/:eventId/messages endpoint with pagination - Support for 'before' cursor-based pagination for loading older messages Frontend changes: - Load initial 20 messages when joining event chat - Implement infinite scroll to load older messages on scroll to top - Add loading indicator for older messages - Preserve scroll position when loading older messages - Add eventsAPI.getMessages() function for pagination User experience: - New users see last 20 messages immediately - Scrolling up automatically loads older messages in batches of 20 - Smooth scrolling experience with position restoration Note: Messages are encrypted in transit via HTTPS/WSS but stored as plain text in database (no E2E encryption). --- backend/src/routes/events.js | 63 ++++++++++++++++++++++++ backend/src/socket/index.js | 37 ++++++++++++++ frontend/src/pages/EventChatPage.jsx | 72 ++++++++++++++++++++++++++-- frontend/src/services/api.js | 9 ++++ 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 9a57a55..a31d751 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -1,5 +1,6 @@ const express = require('express'); const { prisma } = require('../utils/db'); +const { authenticate } = require('../middleware/auth'); const router = express.Router(); @@ -68,4 +69,66 @@ router.get('/:id', async (req, res, next) => { } }); +// GET /api/events/:eventId/messages - Get event chat messages with pagination +router.get('/:eventId/messages', authenticate, async (req, res, next) => { + try { + const { eventId } = req.params; + const { before, limit = 20 } = req.query; + + // Find event chat room + const chatRoom = await prisma.chatRoom.findFirst({ + where: { + eventId: parseInt(eventId), + type: 'event', + }, + }); + + if (!chatRoom) { + return res.status(404).json({ + success: false, + error: 'Chat room not found', + }); + } + + // Build query with pagination + const where = { roomId: chatRoom.id }; + if (before) { + where.id = { lt: parseInt(before) }; + } + + const messages = await prisma.message.findMany({ + where, + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: parseInt(limit), + }); + + // Return in chronological order (oldest first) + res.json({ + success: true, + data: messages.reverse().map(msg => ({ + id: msg.id, + roomId: msg.roomId, + userId: msg.user.id, + username: msg.user.username, + avatar: msg.user.avatar, + content: msg.content, + type: msg.type, + createdAt: msg.createdAt, + })), + hasMore: messages.length === parseInt(limit), + }); + } catch (error) { + next(error); + } +}); + module.exports = router; diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js index e553bfb..e2c5264 100644 --- a/backend/src/socket/index.js +++ b/backend/src/socket/index.js @@ -77,6 +77,43 @@ function initializeSocket(httpServer) { console.log(`👤 ${socket.user.username} joined event room ${eventId}`); + // Load last 20 messages from database + const chatRoom = await prisma.chatRoom.findFirst({ + where: { + eventId: parseInt(eventId), + type: 'event', + }, + }); + + if (chatRoom) { + const messages = await prisma.message.findMany({ + where: { roomId: chatRoom.id }, + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + + // Send message history to the joining user (reverse to chronological order) + socket.emit('message_history', messages.reverse().map(msg => ({ + id: msg.id, + roomId: msg.roomId, + userId: msg.user.id, + username: msg.user.username, + avatar: msg.user.avatar, + content: msg.content, + type: msg.type, + createdAt: msg.createdAt, + }))); + } + // Broadcast updated active users list const users = Array.from(activeUsers.get(eventId)).map(u => JSON.parse(u)); io.to(roomName).emit('active_users', users); diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx index 0c6f6e6..07a37a8 100644 --- a/frontend/src/pages/EventChatPage.jsx +++ b/frontend/src/pages/EventChatPage.jsx @@ -3,8 +3,9 @@ import { useParams, useNavigate } from 'react-router-dom'; import Layout from '../components/layout/Layout'; import { useAuth } from '../contexts/AuthContext'; import { mockEvents } from '../mocks/events'; -import { Send, UserPlus } from 'lucide-react'; +import { Send, UserPlus, Loader2 } from 'lucide-react'; import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; +import { eventsAPI } from '../services/api'; const EventChatPage = () => { const { eventId } = useParams(); @@ -14,7 +15,10 @@ const EventChatPage = () => { const [newMessage, setNewMessage] = useState(''); const [activeUsers, setActiveUsers] = useState([]); const [isConnected, setIsConnected] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [hasMore, setHasMore] = useState(true); const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const event = mockEvents.find(e => e.id === parseInt(eventId)); @@ -46,7 +50,13 @@ const EventChatPage = () => { setIsConnected(false); }); - // Receive messages + // Receive message history (initial 20 messages) + socket.on('message_history', (history) => { + setMessages(history); + setHasMore(history.length === 20); + }); + + // Receive new messages socket.on('event_message', (message) => { setMessages((prev) => [...prev, message]); }); @@ -77,6 +87,7 @@ const EventChatPage = () => { socket.emit('leave_event_room', { eventId: parseInt(eventId) }); socket.off('connect'); socket.off('disconnect'); + socket.off('message_history'); socket.off('event_message'); socket.off('active_users'); socket.off('user_joined'); @@ -103,6 +114,39 @@ const EventChatPage = () => { setNewMessage(''); }; + const loadOlderMessages = async () => { + if (loadingOlder || !hasMore || messages.length === 0) return; + + setLoadingOlder(true); + try { + const oldestMessageId = messages[0].id; + const response = await eventsAPI.getMessages(eventId, oldestMessageId, 20); + + if (response.data.length > 0) { + // Save current scroll position + const container = messagesContainerRef.current; + const oldScrollHeight = container.scrollHeight; + const oldScrollTop = container.scrollTop; + + // Prepend older messages + setMessages((prev) => [...response.data, ...prev]); + setHasMore(response.hasMore); + + // Restore scroll position (adjust for new content) + setTimeout(() => { + const newScrollHeight = container.scrollHeight; + container.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight); + }, 0); + } else { + setHasMore(false); + } + } catch (error) { + console.error('Failed to load older messages:', error); + } finally { + setLoadingOlder(false); + } + }; + const handleMatchWith = (userId) => { // TODO: Implement match request alert(`Match request sent to user!`); @@ -111,6 +155,21 @@ const EventChatPage = () => { }, 1000); }; + // Infinite scroll - detect scroll to top + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) return; + + const handleScroll = () => { + if (container.scrollTop < 100 && !loadingOlder && hasMore) { + loadOlderMessages(); + } + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [loadingOlder, hasMore, messages]); + if (!event) { return ( @@ -176,7 +235,14 @@ const EventChatPage = () => { {/* Chat Area */}
{/* Messages */} -
+
+ {/* Loading older messages indicator */} + {loadingOlder && ( +
+ +
+ )} + {messages.length === 0 && (
No messages yet. Start the conversation! diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 65d297e..56d88f2 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -163,6 +163,15 @@ export const eventsAPI = { const data = await fetchAPI(`/events/${id}`); return data.data; }, + + async getMessages(eventId, before = null, limit = 20) { + const params = new URLSearchParams({ limit: limit.toString() }); + if (before) { + params.append('before', before.toString()); + } + const data = await fetchAPI(`/events/${eventId}/messages?${params}`); + return data; + }, }; export { ApiError };