Files
Mixchat/src/components/ChatDisplay.tsx
2026-03-29 22:44:13 +02:00

520 lines
18 KiB
TypeScript

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<ChatMessage[]>([]);
const [isAtBottom, setIsAtBottom] = useState(true);
const [newMessagesCount, setNewMessagesCount] = useState(0);
const [previousMessageCount, setPreviousMessageCount] = useState(0);
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
const authorColorById = useMemo(() => {
const map = new Map<string, string>();
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<string, { title: string; color: string }> = {
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 = <GiHeartBeats size={16} />;
break;
case 'subscription':
iconElement = <MdStars size={16} />;
break;
case 'resub':
iconElement = <MdAutorenew size={16} />;
break;
case 'subgift':
iconElement = <MdCardGiftcard size={16} />;
break;
case 'redemption':
iconElement = <MdRedeem size={16} />;
break;
default:
return null;
}
return (
<span
title={config.title}
style={{
marginRight: '6px',
display: 'inline-flex',
alignItems: 'center',
color: config.color,
filter: `drop-shadow(0 0 2px ${config.color})`,
}}
>
{iconElement}
</span>
);
};
// 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 (
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
{Object.entries(msg.badgeDetails).map(([name, detail]) => (
<img
key={name}
src={detail.url}
alt={detail.title}
className="badge"
title={detail.title}
loading="eager"
style={{
width: '18px',
height: '18px',
verticalAlign: 'middle'
}}
/>
))}
</div>
);
}
// Fallback for platform-agnostic or historical badges
if (!msg.badges || Object.keys(msg.badges).length === 0) return null;
return (
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
{Object.entries(msg.badges).map(([name, badgeUrl]) => {
if (!badgeUrl) return null;
return (
<img
key={`${name}`}
src={badgeUrl}
alt={name}
className="badge"
title={name}
loading="eager"
style={{
width: '18px',
height: '18px',
verticalAlign: 'middle'
}}
/>
);
})}
</div>
);
};
return (
<div className="chat-display">
<div className="chat-header">
<div className="chat-header-content">
{onToggleSidebar && (
<button
onClick={onToggleSidebar}
className="sidebar-toggle-button"
title={sidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
>
{sidebarVisible ? '◀' : '▶'}
</button>
)}
{!showStreamEmbed && onToggleStreamEmbed && (
<button
onClick={onToggleStreamEmbed}
className="show-stream-button"
title="Show stream embed"
>
Show Stream
</button>
)}
<h2>
{isStreamLive ? (
<span className="status-badge-live">
<span className="status-dot pulse" />
LIVE
</span>
) : (
<span className="status-badge-offline">
<span className="status-dot" />
OFFLINE
</span>
)}
<a
href={`https://twitch.tv/${channelName}`}
target="_blank"
rel="noopener noreferrer"
className="channel-link"
>
{channelDisplayName || channelName || 'Chat'}
</a>
{viewerCount !== undefined && viewerCount !== null && (
<span className="viewer-count-display">
<FaUsers size={14} /> {viewerCount.toLocaleString()}
</span>
)}
{streamUptime && isStreamLive && (
<span className="uptime-display">
({streamUptime})
</span>
)}
</h2>
{streamTitle && <span className="stream-title">{streamTitle}</span>}
</div>
<div className="chat-header-actions">
{onRefreshEmotes && (
<button
onClick={onRefreshEmotes}
disabled={isRefreshingEmotes}
className="header-button"
title={isRefreshingEmotes ? 'Reloading emotes...' : 'Reload all emotes (clears cache)'}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<MdRefresh size={14} style={{ animation: isRefreshingEmotes ? 'spin 1s linear infinite' : 'none' }} />
{isRefreshingEmotes ? 'Reloading...' : 'Reload Emotes'}
</button>
)}
{isLoading && <span className="loading-indicator"></span>}
</div>
</div>
{unifiedChatSlot ? (
unifiedChatSlot
) : (
<div
className="messages-container"
ref={messagesContainerRef}
style={{ '--chat-font-size': `${fontSize}px` } as React.CSSProperties}
>
{filteredMessages.length === 0 ? (
<div className="no-messages">
<p>No messages yet. Start chatting!</p>
</div>
) : (
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 (
<div key={msg.id} className={messageClasses}>
{msg.replyTo && (
<div className="reply-info">
<span>response to{' '}
<span
style={{
color: authorColorById.get(msg.replyTo.id) || '#94a3b8',
}}
>
@{msg.replyTo.author}
</span>
{msg.replyTo.content && (
<>
{': '}
<span
className="reply-target-content"
dangerouslySetInnerHTML={{
__html: parseMessageWithEmotes(msg.replyTo.content)
}}
/>
</>
)}
</span>
</div>
)}
<div className="message-single-line">
<span className="timestamp">
{msg.timestamp.toLocaleTimeString()}
</span>
{msg.authorAvatar && (
<img
src={msg.authorAvatar}
alt={msg.author}
className="avatar"
/>
)}
{renderMessageIcon(msg)}
{renderBadges(msg)}
<a
href={`https://twitch.tv/${msg.author}`}
target="_blank"
rel="noopener noreferrer"
className={`author author-link ${msg.isCurrentUser ? 'self' : ''}`}
style={{
color: msg.isCurrentUser ? '#ffffff' : (msg.authorColor || '#e2e8f0'),
}}
>
{msg.author}
</a>
<span className="message-separator">:</span>
<span
className="message-text"
dangerouslySetInnerHTML={{
__html: parseMessageWithEmotes(msg.content, msg.emotes),
}}
/>
<div className="message-actions">
{onReply && (
<button
onClick={() => onReply(msg)}
className="action-button"
title="Reply to this message"
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<MdReply size={12} />
</button>
)}
<button
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(msg.content).then(() => {
onCopyMessage?.();
}).catch((err) => {
console.error('Failed to copy:', err);
});
} else {
// Fallback for environments without clipboard API
const textArea = document.createElement('textarea');
textArea.value = msg.content;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
onCopyMessage?.();
} catch (err) {
console.error('Failed to copy:', err);
}
document.body.removeChild(textArea);
}
}}
className="action-button"
title="Copy message"
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<MdContentCopy size={12} />
</button>
</div>
</div>
</div>
);
})
)}
</div>
)}
{!isAtBottom && !unifiedChatSlot && (
<button
onClick={() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
setIsAtBottom(true);
setNewMessagesCount(0);
setPreviousMessageCount(filteredMessages.length);
}
}}
className="go-to-bottom-button"
title="Go to latest messages"
>
{newMessagesCount > 0 ? `New Messages (${newMessagesCount})` : 'New Messages'}
</button>
)}
</div>
);
}
export default memo(ChatDisplay);