feat(chat): add country flags and competitor numbers with normalized data architecture

Implemented display of country flags and competitor numbers in event chat messages:
- Country flags displayed as emoji (🇸🇪, 🇵🇱, etc.) with proper emoji font support
- Competitor numbers shown in #123 format next to usernames
- Normalized data architecture with user and participant caches on frontend
- User data (username, avatar, country) and participant data (competitorNumber) cached separately
- Messages store only core data (id, content, userId, createdAt)
- Prevents data inconsistency when users update profile information
- Fixed duplicate message keys React warning with deduplication logic
- Backend sends nested user/participant objects for cache population
- Auto-updates across all messages when user changes avatar or country

Backend changes:
- Socket.IO event_message and message_history include nested user/participant data
- API /events/:slug/messages endpoint restructured with same nested format
- Batch lookup of competitor numbers for efficiency

Frontend changes:
- useEventChat hook maintains userCache and participantCache
- ChatMessage component accepts separate user/participant props
- ChatMessageList performs cache lookups during render
- Emoji font family support for cross-platform flag rendering
This commit is contained in:
Radosław Gierwiało
2025-11-29 19:49:06 +01:00
parent 671b16cb82
commit 4e9557bd29
6 changed files with 269 additions and 17 deletions

View File

@@ -159,6 +159,7 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => {
id: true, id: true,
username: true, username: true,
avatar: true, avatar: true,
country: true,
}, },
}, },
}, },
@@ -166,6 +167,24 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => {
take: parseInt(limit), 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) // Return in chronological order (oldest first)
res.json({ res.json({
success: true, success: true,
@@ -173,11 +192,20 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => {
id: msg.id, id: msg.id,
roomId: msg.roomId, roomId: msg.roomId,
userId: msg.user.id, userId: msg.user.id,
username: msg.user.username,
avatar: msg.user.avatar,
content: msg.content, content: msg.content,
type: msg.type, type: msg.type,
createdAt: msg.createdAt, 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), hasMore: messages.length === parseInt(limit),
}); });

View File

@@ -144,6 +144,7 @@ function initializeSocket(httpServer) {
id: true, id: true,
username: true, username: true,
avatar: true, avatar: true,
country: true,
}, },
}, },
}, },
@@ -151,16 +152,43 @@ function initializeSocket(httpServer) {
take: 20, 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) // Send message history to the joining user (reverse to chronological order)
socket.emit('message_history', messages.reverse().map(msg => ({ socket.emit('message_history', messages.reverse().map(msg => ({
id: msg.id, id: msg.id,
roomId: msg.roomId, roomId: msg.roomId,
userId: msg.user.id, userId: msg.user.id,
username: msg.user.username,
avatar: msg.user.avatar,
content: msg.content, content: msg.content,
type: msg.type, type: msg.type,
createdAt: msg.createdAt, 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, id: true,
username: true, username: true,
avatar: 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 // Broadcast message to room
io.to(roomName).emit('event_message', { io.to(roomName).emit('event_message', {
id: message.id, id: message.id,
roomId: message.roomId, roomId: message.roomId,
userId: message.user.id, userId: message.user.id,
username: message.user.username,
avatar: message.user.avatar,
content: message.content, content: message.content,
type: message.type, type: message.type,
createdAt: message.createdAt, 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}`); console.log(`💬 Message in event ${socket.currentEventSlug} from ${socket.user.username}`);

View File

@@ -1,13 +1,82 @@
import Avatar from '../common/Avatar'; 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 * 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 {boolean} isOwn - Whether this message belongs to the current user
* @param {function} formatTime - Optional custom time formatter (default: toLocaleTimeString) * @param {function} formatTime - Optional custom time formatter (default: toLocaleTimeString)
*/ */
const ChatMessage = ({ message, isOwn, formatTime }) => { const ChatMessage = ({ message, user, participant, isOwn, formatTime }) => {
const defaultFormatTime = (timestamp) => { const defaultFormatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('en-US', { return new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
@@ -16,6 +85,12 @@ const ChatMessage = ({ message, isOwn, formatTime }) => {
}; };
const timeFormatter = formatTime || defaultFormatTime; const timeFormatter = formatTime || defaultFormatTime;
const countryFlag = getCountryFlag(user?.country);
// Fallback for missing user data
if (!user) {
return null;
}
return ( return (
<div className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}> <div className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}>
@@ -25,15 +100,29 @@ const ChatMessage = ({ message, isOwn, formatTime }) => {
}`} }`}
> >
<Avatar <Avatar
src={message.avatar} src={user.avatar}
username={message.username} username={user.username}
size={32} size={32}
title={message.username} title={user.username}
/> />
<div> <div>
<div className="flex items-baseline space-x-2 mb-1"> <div className="flex items-baseline space-x-2 mb-1">
<span className="text-sm font-medium text-gray-900"> <span className="text-sm font-medium text-gray-900 flex items-center gap-1.5">
{message.username} {countryFlag && (
<span
className="text-base leading-none"
style={{ fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif' }}
title={user.country}
>
{countryFlag}
</span>
)}
<span>{user.username}</span>
{participant?.competitorNumber && (
<span className="text-xs font-normal text-gray-600">
#{participant.competitorNumber}
</span>
)}
</span> </span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{timeFormatter(message.createdAt)} {timeFormatter(message.createdAt)}

View File

@@ -5,6 +5,8 @@ import ChatMessage from './ChatMessage';
* Chat Message List component with infinite scroll support * Chat Message List component with infinite scroll support
* *
* @param {Array} messages - Array of message objects * @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 {number} currentUserId - Current user's ID to determine message ownership
* @param {React.Ref} messagesEndRef - Ref for scroll-to-bottom functionality * @param {React.Ref} messagesEndRef - Ref for scroll-to-bottom functionality
* @param {React.Ref} messagesContainerRef - Ref for the scrollable container * @param {React.Ref} messagesContainerRef - Ref for the scrollable container
@@ -15,6 +17,8 @@ import ChatMessage from './ChatMessage';
*/ */
const ChatMessageList = ({ const ChatMessageList = ({
messages = [], messages = [],
userCache = {},
participantCache = {},
currentUserId, currentUserId,
messagesEndRef, messagesEndRef,
messagesContainerRef, messagesContainerRef,
@@ -48,10 +52,16 @@ const ChatMessageList = ({
const messageUserId = message.userId ?? message.user_id; const messageUserId = message.userId ?? message.user_id;
const isOwnMessage = messageUserId === currentUserId; const isOwnMessage = messageUserId === currentUserId;
// Get user and participant data from caches
const user = userCache[messageUserId];
const participant = participantCache[messageUserId];
return ( return (
<ChatMessage <ChatMessage
key={message.id} key={message.id}
message={message} message={message}
user={user}
participant={participant}
isOwn={isOwnMessage} isOwn={isOwnMessage}
formatTime={formatTime} formatTime={formatTime}
/> />

View File

@@ -34,6 +34,26 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => {
const [loadingOlder, setLoadingOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true); 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 // Socket.IO connection and event listeners
useEffect(() => { useEffect(() => {
if (!event) return; if (!event) return;
@@ -81,13 +101,44 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => {
// Receive message history (initial 20 messages) // Receive message history (initial 20 messages)
socket.on('message_history', (history) => { 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); setHasMore(history.length === 20);
}); });
// Receive new messages // Receive new messages
socket.on('event_message', (message) => { 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 // Receive active users list
@@ -161,13 +212,29 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => {
const response = await eventsAPI.getMessages(slug, oldestMessageId, 20); const response = await eventsAPI.getMessages(slug, oldestMessageId, 20);
if (response.data.length > 0) { if (response.data.length > 0) {
// Update caches from all loaded messages
response.data.forEach(msg => updateCaches(msg));
// Save current scroll position // Save current scroll position
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
const oldScrollHeight = container?.scrollHeight || 0; const oldScrollHeight = container?.scrollHeight || 0;
const oldScrollTop = container?.scrollTop || 0; const oldScrollTop = container?.scrollTop || 0;
// Prepend older messages // Prepend older messages (filter out any duplicates) - store only core data
setMessages((prev) => [...response.data, ...prev]); 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); setHasMore(response.hasMore);
// Restore scroll position (adjust for new content) // Restore scroll position (adjust for new content)
@@ -196,6 +263,9 @@ const useEventChat = (slug, userId, event, messagesContainerRef) => {
isConnected, isConnected,
loadingOlder, loadingOlder,
hasMore, hasMore,
// Caches for normalized data
userCache,
participantCache,
// Actions // Actions
sendMessage, sendMessage,
loadOlderMessages loadOlderMessages

View File

@@ -38,6 +38,8 @@ const EventChatPage = () => {
isConnected, isConnected,
loadingOlder, loadingOlder,
hasMore, hasMore,
userCache,
participantCache,
sendMessage: handleSendMessage, sendMessage: handleSendMessage,
loadOlderMessages loadOlderMessages
} = useEventChat(slug, user?.id, event, messagesContainerRef); } = useEventChat(slug, user?.id, event, messagesContainerRef);
@@ -478,6 +480,8 @@ const EventChatPage = () => {
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<ChatMessageList <ChatMessageList
messages={messages} messages={messages}
userCache={userCache}
participantCache={participantCache}
currentUserId={user.id} currentUserId={user.id}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
messagesContainerRef={messagesContainerRef} messagesContainerRef={messagesContainerRef}