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:
@@ -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 (
|
||||
<div className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}>
|
||||
@@ -25,15 +100,29 @@ const ChatMessage = ({ message, isOwn, formatTime }) => {
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={message.avatar}
|
||||
username={message.username}
|
||||
src={user.avatar}
|
||||
username={user.username}
|
||||
size={32}
|
||||
title={message.username}
|
||||
title={user.username}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-baseline space-x-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{message.username}
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-1.5">
|
||||
{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 className="text-xs text-gray-500">
|
||||
{timeFormatter(message.createdAt)}
|
||||
|
||||
@@ -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 (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
user={user}
|
||||
participant={participant}
|
||||
isOwn={isOwnMessage}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user