mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
240 lines
10 KiB
React
240 lines
10 KiB
React
// 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);
|