import React, { useState, useEffect, useMemo, memo } from 'react'; import { MdStars, MdAutorenew, MdCardGiftcard, MdRefresh, MdReply, MdContentCopy, MdRedeem } from 'react-icons/md'; import { GiHeartBeats } from 'react-icons/gi'; import { FaUsers } from 'react-icons/fa'; import type { ChatMessage, Platform } from '../lib/types'; import { parseMessageWithEmotes } from '../lib/emotes'; interface ChatDisplayProps { messages: ChatMessage[]; isLoading: boolean; selectedPlatforms: Platform[]; onReply?: (message: ChatMessage) => void; channelName?: string; channelDisplayName?: string; streamTitle?: string; isStreamLive?: boolean; streamUptime?: string; viewerCount?: number | null; onRefreshEmotes?: () => void; isRefreshingEmotes?: boolean; onToggleSidebar?: () => void; sidebarVisible?: boolean; onCopyMessage?: () => void; showStreamEmbed?: boolean; onToggleStreamEmbed?: () => void; currentUserDisplayName?: string; fontSize?: number; /** When provided (unified chat mode), renders this instead of the normal messages area */ unifiedChatSlot?: React.ReactNode; } function ChatDisplay({ messages, isLoading, selectedPlatforms, onReply, channelName, channelDisplayName, streamTitle, isStreamLive, streamUptime, viewerCount, onRefreshEmotes, isRefreshingEmotes = false, onToggleSidebar, sidebarVisible = true, onCopyMessage, showStreamEmbed = true, onToggleStreamEmbed, currentUserDisplayName, fontSize = 14, unifiedChatSlot, }: ChatDisplayProps) { const [filteredMessages, setFilteredMessages] = useState([]); const [isAtBottom, setIsAtBottom] = useState(true); const [newMessagesCount, setNewMessagesCount] = useState(0); const [previousMessageCount, setPreviousMessageCount] = useState(0); const messagesContainerRef = React.useRef(null); const authorColorById = useMemo(() => { const map = new Map(); messages.forEach((message) => { if (message.authorColor) { map.set(message.id, message.authorColor); } }); return map; }, [messages]); useEffect(() => { let filtered = messages; // Remove optimistic placeholders when a matching real server message exists. filtered = filtered.filter((msg, _, arr) => { if (!msg.optimistic) return true; // If there's a non-optimistic message with same content, reply target, // and userId, prefer the real one and drop this optimistic placeholder. const hasReal = arr.some((other) => other !== msg && !other.optimistic && other.isCurrentUser && other.userId === msg.userId && other.content === msg.content && ((other.replyTo?.id || '') === (msg.replyTo?.id || '')) ); return !hasReal; }); if (selectedPlatforms.length === 0) { setFilteredMessages(filtered); } else { filtered = filtered.filter((msg) => selectedPlatforms.includes(msg.platform)); setFilteredMessages(filtered); } // Update new messages count when not at bottom if (!isAtBottom) { const newCount = filtered.length - previousMessageCount; if (newCount > 0) { setNewMessagesCount(newCount); } } }, [messages, selectedPlatforms, isAtBottom, previousMessageCount]); // Auto-scroll to bottom when messages change (only if user is at bottom) useEffect(() => { if (messagesContainerRef.current && isAtBottom) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; } }, [filteredMessages, isAtBottom]); // Track scroll position useEffect(() => { const handleScroll = () => { if (messagesContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; // Consider user at bottom if within 50px of bottom const atBottom = scrollHeight - scrollTop - clientHeight < 50; setIsAtBottom(atBottom); // Reset new messages count when user scrolls back to bottom if (atBottom) { setNewMessagesCount(0); setPreviousMessageCount(filteredMessages.length); } } }; const container = messagesContainerRef.current; if (container) { container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); } }, [filteredMessages.length]); // Handle emote tooltip on hover useEffect(() => { const handleEmoteHover = (e: Event) => { const target = e.target as HTMLImageElement; if (!target.classList.contains('chat-emote')) return; if (e.type === 'mouseenter') { const tooltipUrl = target.getAttribute('data-tooltip-url'); const emoteName = target.getAttribute('data-emote-name') || 'Emote'; if (tooltipUrl) { const tooltip = document.createElement('div'); tooltip.className = 'chat-emote-tooltip'; tooltip.id = 'emote-tooltip'; const img = document.createElement('img'); img.src = tooltipUrl; img.alt = emoteName; tooltip.appendChild(img); const nameDiv = document.createElement('div'); nameDiv.className = 'chat-emote-tooltip-name'; nameDiv.textContent = emoteName; tooltip.appendChild(nameDiv); document.body.appendChild(tooltip); // Position tooltip near the mouse const rect = target.getBoundingClientRect(); tooltip.style.left = (rect.left + window.scrollX) + 'px'; tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 10) + 'px'; } } else if (e.type === 'mouseleave') { const tooltip = document.getElementById('emote-tooltip'); if (tooltip) { tooltip.remove(); } } }; const messagesContainer = messagesContainerRef.current; if (messagesContainer) { messagesContainer.addEventListener('mouseenter', handleEmoteHover, true); messagesContainer.addEventListener('mouseleave', handleEmoteHover, true); } return () => { if (messagesContainer) { messagesContainer.removeEventListener('mouseenter', handleEmoteHover, true); messagesContainer.removeEventListener('mouseleave', handleEmoteHover, true); } }; }, []); // Helper to render message icon for special events const renderMessageIcon = (msg: ChatMessage) => { if (!msg.messageType) return null; const iconConfig: Record = { cheer: { title: 'Cheer', color: '#FFB700' }, subscription: { title: 'Subscription', color: '#9147FF' }, resub: { title: 'Resub', color: '#9147FF' }, subgift: { title: 'Subgift', color: '#9147FF' }, redemption: { title: 'Channel Points Redemption', color: '#9147FF' }, }; const config = iconConfig[msg.messageType]; if (!config) return null; let iconElement; switch (msg.messageType) { case 'cheer': iconElement = ; break; case 'subscription': iconElement = ; break; case 'resub': iconElement = ; break; case 'subgift': iconElement = ; break; case 'redemption': iconElement = ; break; default: return null; } return ( {iconElement} ); }; // Helper to render badges const renderBadges = (msg: ChatMessage) => { // Prefer detailed badge info if available (contains titles/descriptions) if (msg.badgeDetails && Object.keys(msg.badgeDetails).length > 0) { return (
{Object.entries(msg.badgeDetails).map(([name, detail]) => ( {detail.title} ))}
); } // Fallback for platform-agnostic or historical badges if (!msg.badges || Object.keys(msg.badges).length === 0) return null; return (
{Object.entries(msg.badges).map(([name, badgeUrl]) => { if (!badgeUrl) return null; return ( {name} ); })}
); }; return (
{onToggleSidebar && ( )} {!showStreamEmbed && onToggleStreamEmbed && ( )}

{isStreamLive ? ( LIVE ) : ( OFFLINE )} {channelDisplayName || channelName || 'Chat'} {viewerCount !== undefined && viewerCount !== null && ( {viewerCount.toLocaleString()} )} {streamUptime && isStreamLive && ( ({streamUptime}) )}

{streamTitle && {streamTitle}}
{onRefreshEmotes && ( )} {isLoading && }
{unifiedChatSlot ? ( unifiedChatSlot ) : (
{filteredMessages.length === 0 ? (

No messages yet. Start chatting!

) : ( filteredMessages.map((msg) => { const isMentioned = currentUserDisplayName && msg.content.includes(currentUserDisplayName); const messageClasses = [ 'message', msg.messageType || 'chat', msg.isPreloaded ? 'preloaded' : '', msg.isBot ? 'bot' : '', ((msg.mentionsUser || isMentioned) && !msg.isBot) ? 'mention' : '' ].filter(Boolean).join(' '); return (
{msg.replyTo && (
response to{' '} @{msg.replyTo.author} {msg.replyTo.content && ( <> {': '} )}
)}
{msg.timestamp.toLocaleTimeString()} {msg.authorAvatar && ( {msg.author} )} {renderMessageIcon(msg)} {renderBadges(msg)} {msg.author} :
{onReply && ( )}
); }) )}
)} {!isAtBottom && !unifiedChatSlot && ( )}
); } export default memo(ChatDisplay);