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

@@ -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}`);