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

@@ -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