diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 1acc4b2..53924fe 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -159,6 +159,7 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => { id: true, username: true, avatar: true, + country: true, }, }, }, @@ -166,6 +167,24 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => { take: parseInt(limit), }); + // Get competitor numbers for all users in this event + const userIds = [...new Set(messages.map(msg => msg.user.id))]; + const eventParticipants = await prisma.eventParticipant.findMany({ + where: { + eventId: event.id, + userId: { in: userIds }, + }, + select: { + userId: true, + competitorNumber: true, + }, + }); + + // Create a map of userId to competitorNumber + const competitorNumberMap = new Map( + eventParticipants.map(ep => [ep.userId, ep.competitorNumber]) + ); + // Return in chronological order (oldest first) res.json({ success: true, @@ -173,11 +192,20 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => { 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, + // Nested user data for caching + user: { + id: msg.user.id, + username: msg.user.username, + avatar: msg.user.avatar, + country: msg.user.country, + }, + // Nested participant data for caching + participant: { + competitorNumber: competitorNumberMap.get(msg.user.id), + }, })), hasMore: messages.length === parseInt(limit), }); diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js index d969956..d653cec 100644 --- a/backend/src/socket/index.js +++ b/backend/src/socket/index.js @@ -144,6 +144,7 @@ function initializeSocket(httpServer) { id: true, username: true, avatar: true, + country: true, }, }, }, @@ -151,16 +152,43 @@ function initializeSocket(httpServer) { take: 20, }); + // Get competitor numbers for all users in this event + const userIds = [...new Set(messages.map(msg => msg.user.id))]; + const eventParticipants = await prisma.eventParticipant.findMany({ + where: { + eventId: eventId, + userId: { in: userIds }, + }, + select: { + userId: true, + competitorNumber: true, + }, + }); + + // Create a map of userId to competitorNumber + const competitorNumberMap = new Map( + eventParticipants.map(ep => [ep.userId, ep.competitorNumber]) + ); + // 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, + // Nested user data for caching + user: { + id: msg.user.id, + username: msg.user.username, + avatar: msg.user.avatar, + country: msg.user.country, + }, + // Nested participant data for caching + participant: { + competitorNumber: competitorNumberMap.get(msg.user.id), + }, }))); } @@ -249,21 +277,44 @@ function initializeSocket(httpServer) { id: true, username: true, avatar: true, + country: true, }, }, }, }); + // Get competitor number for this user in this event + const eventParticipant = await prisma.eventParticipant.findUnique({ + where: { + userId_eventId: { + userId: socket.user.id, + eventId: eventId, + }, + }, + select: { + competitorNumber: true, + }, + }); + // Broadcast message to room io.to(roomName).emit('event_message', { id: message.id, roomId: message.roomId, userId: message.user.id, - username: message.user.username, - avatar: message.user.avatar, content: message.content, type: message.type, createdAt: message.createdAt, + // Nested user data for caching + user: { + id: message.user.id, + username: message.user.username, + avatar: message.user.avatar, + country: message.user.country, + }, + // Nested participant data for caching + participant: { + competitorNumber: eventParticipant?.competitorNumber, + }, }); console.log(`💬 Message in event ${socket.currentEventSlug} from ${socket.user.username}`); diff --git a/frontend/src/components/chat/ChatMessage.jsx b/frontend/src/components/chat/ChatMessage.jsx index 0774b83..28429fa 100644 --- a/frontend/src/components/chat/ChatMessage.jsx +++ b/frontend/src/components/chat/ChatMessage.jsx @@ -1,13 +1,82 @@ import Avatar from '../common/Avatar'; +// Helper function to convert country name to flag emoji +const getCountryFlag = (country) => { + if (!country) return null; + + // Map of country names to their ISO 3166-1 alpha-2 codes + const countryCodeMap = { + 'United States': 'US', + 'United Kingdom': 'GB', + 'Poland': 'PL', + 'Sweden': 'SE', + 'Germany': 'DE', + 'France': 'FR', + 'Spain': 'ES', + 'Italy': 'IT', + 'Netherlands': 'NL', + 'Belgium': 'BE', + 'Switzerland': 'CH', + 'Austria': 'AT', + 'Denmark': 'DK', + 'Norway': 'NO', + 'Finland': 'FI', + 'Portugal': 'PT', + 'Greece': 'GR', + 'Czech Republic': 'CZ', + 'Hungary': 'HU', + 'Romania': 'RO', + 'Bulgaria': 'BG', + 'Croatia': 'HR', + 'Slovakia': 'SK', + 'Slovenia': 'SI', + 'Lithuania': 'LT', + 'Latvia': 'LV', + 'Estonia': 'EE', + 'Ireland': 'IE', + 'Luxembourg': 'LU', + 'Canada': 'CA', + 'Australia': 'AU', + 'New Zealand': 'NZ', + 'Japan': 'JP', + 'South Korea': 'KR', + 'China': 'CN', + 'Brazil': 'BR', + 'Argentina': 'AR', + 'Mexico': 'MX', + 'Russia': 'RU', + 'Ukraine': 'UA', + 'Turkey': 'TR', + 'Israel': 'IL', + 'South Africa': 'ZA', + 'India': 'IN', + 'Singapore': 'SG', + 'Malaysia': 'MY', + 'Thailand': 'TH', + 'Indonesia': 'ID', + 'Philippines': 'PH', + 'Vietnam': 'VN', + }; + + const code = countryCodeMap[country]; + if (!code) return null; + + // Convert country code to flag emoji + // Flag emojis are made from regional indicator symbols (U+1F1E6 to U+1F1FF) + const codePoints = [...code].map(char => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + /** * Individual Chat Message component * - * @param {object} message - Message object with { id, content, username, avatar, createdAt, userId/user_id } + * @param {object} message - Core message object with { id, content, createdAt, userId } + * @param {object} user - User data { id, username, avatar, country } + * @param {object} participant - Participant data { competitorNumber } * @param {boolean} isOwn - Whether this message belongs to the current user * @param {function} formatTime - Optional custom time formatter (default: toLocaleTimeString) */ -const ChatMessage = ({ message, isOwn, formatTime }) => { +const ChatMessage = ({ message, user, participant, isOwn, formatTime }) => { const defaultFormatTime = (timestamp) => { return new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', @@ -16,6 +85,12 @@ const ChatMessage = ({ message, isOwn, formatTime }) => { }; const timeFormatter = formatTime || defaultFormatTime; + const countryFlag = getCountryFlag(user?.country); + + // Fallback for missing user data + if (!user) { + return null; + } return (
@@ -25,15 +100,29 @@ const ChatMessage = ({ message, isOwn, formatTime }) => { }`} >
- - {message.username} + + {countryFlag && ( + + {countryFlag} + + )} + {user.username} + {participant?.competitorNumber && ( + + #{participant.competitorNumber} + + )} {timeFormatter(message.createdAt)} diff --git a/frontend/src/components/chat/ChatMessageList.jsx b/frontend/src/components/chat/ChatMessageList.jsx index f6e1f9a..545a9f8 100644 --- a/frontend/src/components/chat/ChatMessageList.jsx +++ b/frontend/src/components/chat/ChatMessageList.jsx @@ -5,6 +5,8 @@ import ChatMessage from './ChatMessage'; * Chat Message List component with infinite scroll support * * @param {Array} messages - Array of message objects + * @param {Object} userCache - Map of userId to user data + * @param {Object} participantCache - Map of userId to participant data * @param {number} currentUserId - Current user's ID to determine message ownership * @param {React.Ref} messagesEndRef - Ref for scroll-to-bottom functionality * @param {React.Ref} messagesContainerRef - Ref for the scrollable container @@ -15,6 +17,8 @@ import ChatMessage from './ChatMessage'; */ const ChatMessageList = ({ messages = [], + userCache = {}, + participantCache = {}, currentUserId, messagesEndRef, messagesContainerRef, @@ -48,10 +52,16 @@ const ChatMessageList = ({ const messageUserId = message.userId ?? message.user_id; const isOwnMessage = messageUserId === currentUserId; + // Get user and participant data from caches + const user = userCache[messageUserId]; + const participant = participantCache[messageUserId]; + return ( diff --git a/frontend/src/hooks/useEventChat.js b/frontend/src/hooks/useEventChat.js index 9dbe13d..db661dd 100644 --- a/frontend/src/hooks/useEventChat.js +++ b/frontend/src/hooks/useEventChat.js @@ -34,6 +34,26 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => { const [loadingOlder, setLoadingOlder] = useState(false); const [hasMore, setHasMore] = useState(true); + // User and participant caches (normalized data) + const [userCache, setUserCache] = useState({}); + const [participantCache, setParticipantCache] = useState({}); + + // Helper to update caches from message data + const updateCaches = (messageData) => { + if (messageData.user) { + setUserCache(prev => ({ + ...prev, + [messageData.userId]: messageData.user + })); + } + if (messageData.participant) { + setParticipantCache(prev => ({ + ...prev, + [messageData.userId]: messageData.participant + })); + } + }; + // Socket.IO connection and event listeners useEffect(() => { if (!event) return; @@ -81,13 +101,44 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => { // Receive message history (initial 20 messages) socket.on('message_history', (history) => { - setMessages(history); + // Update caches from all messages + history.forEach(msg => updateCaches(msg)); + + // Store only core message data + const coreMessages = history.map(msg => ({ + id: msg.id, + userId: msg.userId, + content: msg.content, + createdAt: msg.createdAt, + roomId: msg.roomId, + type: msg.type, + })); + + setMessages(coreMessages); setHasMore(history.length === 20); }); // Receive new messages socket.on('event_message', (message) => { - setMessages((prev) => [...prev, message]); + // Update caches with user/participant data + updateCaches(message); + + setMessages((prev) => { + // Prevent duplicates - check if message ID already exists + if (prev.some(m => m.id === message.id)) { + return prev; + } + + // Store only core message data + return [...prev, { + id: message.id, + userId: message.userId, + content: message.content, + createdAt: message.createdAt, + roomId: message.roomId, + type: message.type, + }]; + }); }); // Receive active users list @@ -161,13 +212,29 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => { const response = await eventsAPI.getMessages(slug, oldestMessageId, 20); if (response.data.length > 0) { + // Update caches from all loaded messages + response.data.forEach(msg => updateCaches(msg)); + // Save current scroll position const container = messagesContainerRef.current; const oldScrollHeight = container?.scrollHeight || 0; const oldScrollTop = container?.scrollTop || 0; - // Prepend older messages - setMessages((prev) => [...response.data, ...prev]); + // Prepend older messages (filter out any duplicates) - store only core data + setMessages((prev) => { + const existingIds = new Set(prev.map(m => m.id)); + const newMessages = response.data + .filter(m => !existingIds.has(m.id)) + .map(msg => ({ + id: msg.id, + userId: msg.userId, + content: msg.content, + createdAt: msg.createdAt, + roomId: msg.roomId, + type: msg.type, + })); + return [...newMessages, ...prev]; + }); setHasMore(response.hasMore); // Restore scroll position (adjust for new content) @@ -196,6 +263,9 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => { isConnected, loadingOlder, hasMore, + // Caches for normalized data + userCache, + participantCache, // Actions sendMessage, loadOlderMessages diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx index 17a72a5..29d0fdf 100644 --- a/frontend/src/pages/EventChatPage.jsx +++ b/frontend/src/pages/EventChatPage.jsx @@ -38,6 +38,8 @@ const EventChatPage = () => { isConnected, loadingOlder, hasMore, + userCache, + participantCache, sendMessage: handleSendMessage, loadOlderMessages } = useEventChat(slug, user?.id, event, messagesContainerRef); @@ -478,6 +480,8 @@ const EventChatPage = () => {