'use client'; import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import type { ChatMessage, Poll } from '@shared/chat'; import { useTimezone } from '../../lib/TimezoneContext'; import { formatTimestamp } from '../../lib/timezone'; import { proxyImageUrl } from '../../lib/imageProxy'; // URL regex for detecting links (http/https) const URL_REGEX = /(https?:\/\/[^\s]+)/gi; function parseTextWithLinks(text: string): Array<{ type: 'text' | 'link'; content: string }> { const parts: Array<{ type: 'text' | 'link'; content: string }> = []; let lastIndex = 0; const matches = text.matchAll(URL_REGEX); for (const match of matches) { const url = match[0]; const index = match.index!; // Add text before the URL if (index > lastIndex) { parts.push({ type: 'text', content: text.slice(lastIndex, index) }); } // Add the URL parts.push({ type: 'link', content: url }); lastIndex = index + url.length; } // Add remaining text if (lastIndex < text.length) { parts.push({ type: 'text', content: text.slice(lastIndex) }); } return parts.length > 0 ? parts : [{ type: 'text', content: text }]; } const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100'; const POLL_INTERVAL = 2500; // Global singleton to prevent multiple SSE connections across all renders/remounts let globalSSEConnection: EventSource | null = null; let globalConnectionListeners: Set<(payload: any) => void> = new Set(); export default function DashboardPage() { const { messages, refresh, error: pollError } = useChatMessages(); const { selection, status: overlayStatus } = useOverlaySelection(); const { poll: currentPoll } = usePoll(); const { connected, liveId, connect, disconnect, connecting } = useConnection(); const [confirmUrl, setConfirmUrl] = useState(null); const [superChatPulse, setSuperChatPulse] = useState(false); const [memberPulse, setMemberPulse] = useState(false); const [previouslySelected, setPreviouslySelected] = useState>(new Set()); const prevSuperChatCountRef = useRef(0); const prevMemberCountRef = useRef(0); const hasInitializedRef = useRef(false); const handleSelect = useCallback( async (message: ChatMessage) => { try { // If clicking the already-selected message, clear it if (selection?.id === message.id) { await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'DELETE' }); } else { // Otherwise, select the new message await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: message.id }) }); // Mark this message as previously selected setPreviouslySelected(prev => new Set([...prev, message.id])); } } catch (error) { console.error('Failed to select message', error); } }, [selection] ); const handleClear = useCallback(async () => { try { await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'DELETE' }); } catch (error) { console.error('Failed to clear selection', error); } }, []); useEffect(() => { const timer = setInterval(() => { refresh(); }, POLL_INTERVAL); return () => clearInterval(timer); }, [refresh]); const statusHint = useMemo(() => { if (pollError) return 'Backend unreachable'; if (overlayStatus === 'connecting') return 'Connecting to overlay…'; if (overlayStatus === 'error') return 'Overlay stream disconnected'; return 'Live'; }, [pollError, overlayStatus]); const handleLinkClick = useCallback((url: string) => { setConfirmUrl(url); }, []); const handleConfirmOpen = useCallback(() => { if (confirmUrl) { window.open(confirmUrl, '_blank', 'noopener,noreferrer'); setConfirmUrl(null); } }, [confirmUrl]); const handleCancelOpen = useCallback(() => { setConfirmUrl(null); }, []); const superChats = useMemo(() => messages.filter(m => m.superChat), [messages]); const newMembers = useMemo(() => messages.filter(m => m.membershipGift || m.membershipGiftPurchase), [messages]); const regularMessages = useMemo(() => messages.filter(m => !m.superChat && !m.membershipGift && !m.membershipGiftPurchase), [messages]); // Initialize counts on first load without triggering pulse useEffect(() => { if (!hasInitializedRef.current && messages.length > 0) { prevSuperChatCountRef.current = superChats.length; prevMemberCountRef.current = newMembers.length; hasInitializedRef.current = true; } }, [messages.length, superChats.length, newMembers.length]); // Detect new superchats and trigger pulse useEffect(() => { if (!hasInitializedRef.current) return; const prevCount = prevSuperChatCountRef.current; const currentCount = superChats.length; // Trigger pulse if count increased if (currentCount > prevCount) { setSuperChatPulse(true); const timer = setTimeout(() => setSuperChatPulse(false), 10000); prevSuperChatCountRef.current = currentCount; return () => clearTimeout(timer); } prevSuperChatCountRef.current = currentCount; }, [superChats.length]); // Detect new members and trigger pulse useEffect(() => { if (!hasInitializedRef.current) return; const prevCount = prevMemberCountRef.current; const currentCount = newMembers.length; // Trigger pulse if count increased if (currentCount > prevCount) { setMemberPulse(true); const timer = setTimeout(() => setMemberPulse(false), 10000); prevMemberCountRef.current = currentCount; return () => clearTimeout(timer); } prevMemberCountRef.current = currentCount; }, [newMembers.length]); return (
{!connected && (

Connect to YouTube Live Stream

{ e.preventDefault(); const formData = new FormData(e.currentTarget); const liveId = formData.get('liveId') as string; if (liveId.trim()) { connect(liveId.trim()); } }}>
)} {connected && ( <>
{currentPoll && (
πŸ“Š Active Poll
)}
Messages {regularMessages.length}
Superchats {superChats.length}
Members {newMembers.length}

πŸ’¬ CHAT MESSAGES

( handleSelect(message)} onLinkClick={handleLinkClick} isPreviouslySelected={previouslySelected.has(message.id)} /> )} emptyState={

⏳ Waiting for chat messages...

} />

πŸ”₯ SUPER CHATS

( handleSelect(message)} onLinkClick={handleLinkClick} isPreviouslySelected={previouslySelected.has(message.id)} /> )} emptyState={

No super chats yet

} />

⭐ MEMBERSHIPS & MILESTONES

( handleSelect(message)} onLinkClick={handleLinkClick} isPreviouslySelected={previouslySelected.has(message.id)} /> )} emptyState={

No new members yet

} />
)} {confirmUrl && (
e.stopPropagation()}>

Open External Link?

{confirmUrl}

)}
); } function useChatMessages() { const [messages, setMessages] = useState([]); const [error, setError] = useState(null); const refresh = useCallback(async () => { try { const response = await fetch(`${BACKEND_URL}/chat/messages`); if (!response.ok) throw new Error(`Request failed: ${response.status}`); const data = await response.json(); setMessages(Array.isArray(data.messages) ? data.messages : []); setError(null); } catch (err) { setError(err as Error); } }, []); useEffect(() => { refresh(); }, [refresh]); return { messages, refresh, error }; } type OverlayStatus = 'connecting' | 'live' | 'error'; type SelectionPayload = { message: ChatMessage | null; }; function useOverlaySelection() { const [selection, setSelection] = useState(null); const [status, setStatus] = useState('connecting'); const listenerRef = useRef<((payload: any) => void) | null>(null); useEffect(() => { // Create global connection if it doesn't exist if (!globalSSEConnection) { console.log('[Dashboard] Creating GLOBAL SSE connection'); globalSSEConnection = new EventSource(`${BACKEND_URL}/overlay/stream`); globalSSEConnection.addEventListener('selection', ((event: MessageEvent) => { try { const payload: SelectionPayload = JSON.parse(event.data); // Broadcast to all listeners globalConnectionListeners.forEach(listener => listener(payload)); } catch (error) { console.error('Failed to parse selection payload', error); } }) as EventListener); globalSSEConnection.addEventListener('heartbeat', () => { // Heartbeat - connection is alive }); globalSSEConnection.onerror = (error) => { console.error('[Dashboard] SSE connection error - will auto-reconnect', error); // EventSource automatically reconnects, no action needed }; } // Register this component's listener const myListener = (payload: SelectionPayload) => { setSelection(payload.message); setStatus('live'); }; listenerRef.current = myListener; globalConnectionListeners.add(myListener); return () => { // Unregister this component's listener if (listenerRef.current) { globalConnectionListeners.delete(listenerRef.current); } // Don't close global connection - other components might use it }; }, []); return { selection, status }; } function useConnection() { const [connected, setConnected] = useState(false); const [liveId, setLiveId] = useState(null); const [connecting, setConnecting] = useState(false); const checkStatus = useCallback(async () => { try { const response = await fetch(`${BACKEND_URL}/health`); const data = await response.json(); setConnected(data.connected); setLiveId(data.liveId); } catch (error) { console.error('Failed to check connection status', error); } }, []); useEffect(() => { checkStatus(); const interval = setInterval(checkStatus, 5000); return () => clearInterval(interval); }, [checkStatus]); const connect = useCallback(async (liveId: string) => { setConnecting(true); try { const response = await fetch(`${BACKEND_URL}/chat/connect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ liveId }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to connect'); } await checkStatus(); } catch (error) { console.error('Failed to connect', error); alert('Failed to connect to YouTube Live chat. Please check the Live ID.'); } finally { setConnecting(false); } }, [checkStatus]); const disconnect = useCallback(async () => { try { await fetch(`${BACKEND_URL}/chat/disconnect`, { method: 'POST' }); await checkStatus(); } catch (error) { console.error('Failed to disconnect', error); } }, [checkStatus]); return { connected, liveId, connect, disconnect, connecting }; } function usePoll() { const [poll, setPoll] = useState(null); useEffect(() => { // Create SSE connection for polls const eventSource = new EventSource(`${BACKEND_URL}/poll/stream`); eventSource.addEventListener('poll', ((event: MessageEvent) => { try { const payload = JSON.parse(event.data); setPoll(payload.poll); } catch (error) { console.error('Failed to parse poll payload', error); } }) as EventListener); eventSource.addEventListener('heartbeat', () => { // Heartbeat - connection is alive }); eventSource.onerror = (error) => { console.error('[Dashboard] Poll SSE connection error', error); }; return () => { eventSource.close(); }; }, []); return { poll }; } type ConnectionControlProps = { connected: boolean; liveId: string | null; connecting: boolean; onConnect: (liveId: string) => void; onDisconnect: () => void; inline?: boolean; }; function ConnectionControl({ connected, liveId, connecting, onConnect, onDisconnect, inline }: ConnectionControlProps) { const [inputValue, setInputValue] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (inputValue.trim()) { onConnect(inputValue.trim()); } }; return (
{connected ? (
🟒 Connected Live ID: {liveId}
) : (
setInputValue(e.target.value)} placeholder="e.g., dQw4w9WgXcQ or https://youtube.com/watch?v=..." disabled={connecting} />
)}
); } type ChatItemProps = { message: ChatMessage; isSelected: boolean; onSelect: () => void; onLinkClick: (url: string) => void; isPreviouslySelected?: boolean; }; function MessageText({ text, onLinkClick }: { text: string; onLinkClick: (url: string) => void }) { const parts = parseTextWithLinks(text); return ( <> {parts.map((part, i) => part.type === 'link' ? ( { e.preventDefault(); e.stopPropagation(); onLinkClick(part.content); }} > {part.content} ) : ( {part.content} ) )} ); } type ChatListPanelProps = { messages: ChatMessage[]; renderItem: (message: ChatMessage) => React.ReactNode; emptyState: React.ReactNode; }; function ChatListPanel({ messages, renderItem, emptyState }: ChatListPanelProps) { const scrollRef = useRef(null); const wasAtBottomRef = useRef(true); useEffect(() => { const node = scrollRef.current; if (!node) return; const handleScroll = () => { const isAtBottom = node.scrollHeight - node.scrollTop - node.clientHeight <= 150; wasAtBottomRef.current = isAtBottom; }; node.addEventListener('scroll', handleScroll); return () => node.removeEventListener('scroll', handleScroll); }, []); useEffect(() => { const node = scrollRef.current; if (node && wasAtBottomRef.current) { // Use setTimeout with requestAnimationFrame for more reliable scrolling setTimeout(() => { requestAnimationFrame(() => { if (node && wasAtBottomRef.current) { node.scrollTop = node.scrollHeight; } }); }, 0); } }, [messages]); return (
{messages.length > 0 ? messages.map(renderItem) : emptyState}
); } function ChatItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySelected }: ChatItemProps) { const { timezone } = useTimezone(); const className = [ 'chatItem', isSelected ? 'chatItem--active' : '', isPreviouslySelected ? 'chatItem--previously-selected' : '' ].filter(Boolean).join(' '); return ( ); } function MemberItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySelected }: ChatItemProps) { const { timezone } = useTimezone(); const className = [ 'memberItem', isSelected ? 'memberItem--active' : '', isPreviouslySelected ? 'memberItem--previously-selected' : '' ].filter(Boolean).join(' '); return ( ); }