// Hud Chat Input // Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import { memo, useMemo, useState } from 'react'; import { useChatActions } from '../../../context/ChatContext.jsx'; import { useSessionSelector } from '../../../context/SessionContext.jsx'; import { useSettingsNamespace } from '../../../settings/index.js'; import useChatMessageHistoryNavigation from '../../../hooks/useChatMessageHistoryNavigation.js'; function detectSafari() { if (typeof navigator === 'undefined') return false; const userAgent = navigator.userAgent || ''; const vendor = navigator.vendor || ''; /* Safari is the browser that needs the 16px focused-input workaround. Chrome and Firefox on iOS also use WebKit internally, but they identify themselves in the user agent, so leave their HUD chat sizing alone unless they report as Safari proper. */ return /safari/i.test(userAgent) && /apple/i.test(vendor) && !/crios|fxios|edgios|opr|chrome|android/i.test(userAgent); } function HudChatInput({ compact = false }) { const role = useSessionSelector((state) => state.session?.role || null); const socketId = useSessionSelector((state) => state.session?.socketId || null); const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null); const users = useSessionSelector((state) => state.session?.users || []); const roverRoster = useSessionSelector((state) => state.session?.roster || []); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions(); const { value: ttsSettings } = useSettingsNamespace('tts', { // HUD chat shares the normal browser TTS defaults, but it has no visible // controls of its own. Keeping the fallback here on Google speech prevents // the compact HUD path from silently reverting to flite for fresh users. engine: 'chromegtts', voice: 'tpf', pitch: 50, googlePitch: 1, googleSpeed: 1, }); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation(); const canChat = role !== 'spectator'; const hideHudChat = role === 'spectator'; const chatTargetId = useMemo(() => { /* HUD chat does not render the full composer controls, but it still sends TTS payloads when the active target supports speech. PTZ appears only in the session user target, not the physical rover roster, so use the same self-target resolution as the full chat panel. */ const self = users.find((entry) => entry?.socketId === socketId); if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id; return self?.roverId || currentRoverId || null; }, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]); const rover = useMemo( () => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null, [chatTargetId, roverRoster], ); const ptzTtsSupported = Boolean( ptz?.id && String(chatTargetId) === String(ptz.id) && ptz?.audio?.enabled && (ptz?.isOperator || ptz?.queuedPosition), ); const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported); const ttsPayload = useMemo(() => { if (!ttsSupported) return null; const engine = ttsSettings?.engine === 'espeak' ? 'espeak' : ttsSettings?.engine === 'flite' ? 'flite' : 'chromegtts'; if (engine === 'espeak') { let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined; if (typeof pitch === 'number') { pitch = Math.max(0, Math.min(99, pitch)); } return { speak: true, engine, pitch }; } const voice = typeof ttsSettings?.voice === 'string' ? ttsSettings.voice : undefined; if (engine === 'chromegtts') { const pitch = Number.isFinite(ttsSettings?.googlePitch) ? ttsSettings.googlePitch : 1; const speed = Number.isFinite(ttsSettings?.googleSpeed) ? ttsSettings.googleSpeed : 1; return { speak: true, engine, voice, pitch, speed }; } return { speak: true, engine, voice }; }, [ ttsSettings?.engine, ttsSettings?.googlePitch, ttsSettings?.googleSpeed, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported, ]); const containerClass = compact ? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2' : 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25'; const isSafari = useMemo(() => detectSafari(), []); /* Safari zooms focused inputs below 16px. Keep that workaround Safari-only so other mobile browsers keep the deliberately tiny HUD typography. */ const inputTextClass = isSafari ? 'mobile-text-entry' : compact ? 'text-[0.55rem]' : 'text-[0.7rem]'; const inputClass = `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`; // The submit button is still a touch target even though the adjacent input must // remain editable, so it gets press suppression without inheriting input text // selection behavior. const buttonClass = compact ? 'mobile-touch-control rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50' : 'mobile-touch-control rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50'; async function handleSend(event) { event.preventDefault(); if (!canChat) return; const clean = draft.trim(); if (!clean) return; setSending(true); try { await sendMessage(clean, ttsPayload); setDraft(''); resetHistoryNavigation(); blurChat(); setTypingActive(false); } catch (err) { alert(err.message); } finally { setSending(false); } } if (hideHudChat) return null; return (
{ const next = event.target.value; // Keep HUD navigation independent from the panel's cursor even // though both inputs read the same persisted message collection. resetHistoryNavigation(); setDraft(next); setTypingActive(Boolean(next.trim())); }} onFocus={(event) => { onInputFocus(event); setTypingActive(Boolean(draft.trim())); }} onBlur={(event) => { onInputBlur(event); setTypingActive(false); }} onKeyDown={(event) => { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft); if (recalledDraft !== null) { event.preventDefault(); setDraft(recalledDraft); setTypingActive(Boolean(recalledDraft.trim())); } return; } if (event.key === 'Enter' && !draft.trim()) { event.preventDefault(); blurChat(); setTypingActive(false); } }} ref={(el) => registerInputRef(el, { target: 'hud' })} placeholder={canChat ? 'Chat (tts)' : 'Spectator'} disabled={!canChat} />
); } export default memo(HudChatInput);