Files
MultiRoombaRover/webui/src/components/HudOverlays/HudChatInput/index.jsx
T

240 lines
10 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, useRef, useState } from 'react';
import './styles.css';
import '../../MobileControls/mobileControls.css';
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, variant = 'legacy', open = true, onOpenChange = null }) {
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 formRef = useRef(null);
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 newDrivePresentation = variant === 'newdrive';
const containerClass = newDrivePresentation
? `absolute left-1/2 top-1/2 z-70 flex w-[min(36rem,82%)] -translate-x-1/2 -translate-y-1/2 items-stretch gap-1 rounded-md border border-neutral-700 bg-neutral-900 p-1 transition-opacity duration-100 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`
: compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-36 max-w-[70vw] items-center gap-0.5 rounded-sm bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-48 max-w-[70vw] items-center gap-0.5 rounded-sm 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 = newDrivePresentation
? 'text-base'
: isSafari
? 'mobile-text-entry'
: compact
? 'text-[0.55rem]'
: 'text-[0.7rem]';
// The current HUD composer is a physically larger instance of the normal
// chat-panel entry field, not a separately styled translucent overlay.
const inputClass = newDrivePresentation
? `field-input h-12 min-w-0 flex-1 px-2 ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-hidden`
: `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-hidden`;
// 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 = newDrivePresentation
? 'button-dark min-h-12 shrink-0 px-4 text-base font-semibold disabled:opacity-50'
: compact
? 'mobile-touch-control rounded-sm bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded-sm 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);
// The new-drive composer is intentionally transient. Sending returns the
// HUD to its small corner icon while legacy HUD inputs remain unchanged.
if (newDrivePresentation) onOpenChange?.(false);
} catch (err) {
alert(err.message);
} finally {
setSending(false);
}
}
if (hideHudChat) return null;
return (
<form ref={formRef} onSubmit={handleSend} className={containerClass}>
<input
className={inputClass}
value={draft}
onChange={(event) => {
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()));
// The chat key focuses the always-mounted HUD input through ChatContext.
// Opening from this focus event avoids a second keyboard listener and
// preserves HUD-over-sidebar focus priority.
if (newDrivePresentation) onOpenChange?.(true);
}}
onBlur={(event) => {
onInputBlur(event);
setTypingActive(false);
if (newDrivePresentation) {
// Defer the containment check until focus has reached a submit or
// close button; moving within the composer must not dismiss it.
window.requestAnimationFrame(() => {
if (!formRef.current?.contains(document.activeElement)) {
onOpenChange?.(false);
}
});
}
}}
onKeyDown={(event) => {
if (newDrivePresentation && event.key === 'Escape') {
event.preventDefault();
onOpenChange?.(false);
blurChat();
setTypingActive(false);
return;
}
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' })}
tabIndex={newDrivePresentation && !open ? -1 : undefined}
placeholder={canChat ? (newDrivePresentation ? 'Type a message…' : 'Chat (tts)') : 'Spectator'}
disabled={!canChat}
/>
<button type="submit" disabled={!canChat || sending} className={buttonClass}>
{sending ? '...' : newDrivePresentation ? 'Send' : 'Speak'}
</button>
{newDrivePresentation ? (
<button
type="button"
aria-label="Close chat"
onClick={() => {
onOpenChange?.(false);
blurChat();
setTypingActive(false);
}}
className="flex min-h-12 w-10 shrink-0 items-center justify-center rounded-sm border border-neutral-600 bg-neutral-800 text-xl leading-none text-slate-200 hover:bg-neutral-700 hover:text-white"
>
×
</button>
) : null}
</form>
);
}
export default memo(HudChatInput);