import { useEffect, useMemo, useRef, useState } from 'react'; import { useChat } from '../context/ChatContext.jsx'; import { useSession } from '../context/SessionContext.jsx'; function roleColors(role, fromDiscord) { if (fromDiscord) return 'text-indigo-200'; switch (role) { case 'admin': case 'lockdown': case 'lockdown-admin': return 'text-amber-300'; case 'spectator': return 'text-slate-400'; default: return 'text-sky-300'; } } function formatTime(ts) { const date = new Date(ts); return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; } function displayName(message) { return message.nickname || message.socketId?.slice(0, 6) || 'unknown'; } const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl']; const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10); export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) { const { session } = useSession(); const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat(); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [speak, setSpeak] = useState(false); const [engine, setEngine] = useState('flite'); const [voice, setVoice] = useState('rms'); const [pitch, setPitch] = useState(50); const canChat = session?.role !== 'spectator'; const listRef = useRef(null); const currentRoverId = session?.assignment?.roverId || null; const rover = useMemo( () => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null, [currentRoverId, session?.roster], ); const ttsSupported = Boolean(rover?.audio?.ttsEnabled); const sorted = useMemo(() => messages.slice(-200), [messages]); useEffect(() => { if (!listRef.current) return; listRef.current.scrollTop = listRef.current.scrollHeight; }, [sorted]); useEffect(() => { if (ttsSupported) { setSpeak(true); } else { setSpeak(false); } }, [ttsSupported]); const ttsPayload = useMemo(() => { if (!ttsSupported || !speak) return null; if (engine === 'espeak') { return { speak: true, engine, pitch }; } return { speak: true, engine, voice }; }, [engine, pitch, speak, ttsSupported, voice]); async function handleSend(event) { event.preventDefault(); if (!canChat || hideInput) return; // Allow users to type "\n" to represent a newline in messages const normalizedDraft = draft.replace(/\\n/g, '\n'); const clean = normalizedDraft.trim(); if (!clean) return; setSending(true); try { await sendMessage(clean, ttsPayload); setDraft(''); blurChat(); } catch (err) { alert(err.message); } finally { setSending(false); } } const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto'; return (
{sorted.length === 0 ? (

No messages yet.

) : ( sorted.map((msg) => { const isAdmin = msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin'; return (
{formatTime(msg.ts)} {displayName(msg)} {msg.fromDiscord && ( Discord )} {msg.roverId && ( rover {msg.roverId} )} {msg.text}
); }) )}
{!hideInput && (
setDraft(e.target.value)} onFocus={onInputFocus} onBlur={onInputBlur} ref={(el) => registerInputRef(el)} placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'} disabled={!canChat} /> {ttsSupported && (
{engine === 'flite' ? ( ) : ( )}
)}
)}
); }