mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
slopfixing / issue 006
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DXJpsJXf.js"></script>
|
<script type="module" crossorigin src="/assets/index-Xz-F59g5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
1. # FIX ALL OF THE PERFORMANCE PROFILED ISSUES!!
|
1. # FIX ALL OF THE PERFORMANCE PROFILED ISSUES!!
|
||||||
2. fix up ALL discord admin commands
|
2. fix up ALL discord admin commands
|
||||||
1. make sure all permissions are correct
|
1. make sure all permissions are correct
|
||||||
2. fuzzy search
|
2. fuzzy search all the things
|
||||||
3. dont break on multi word nicknames
|
3. dont break on multi word nicknames
|
||||||
4. make all rs commands work form both the site chat and discord
|
4. make all rs commands work form both the site chat and discord
|
||||||
1. make sure all the permissions are correct
|
1. make sure all the permissions are correct
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Chat Panel
|
// Chat Panel
|
||||||
// Purpose: Defines the Chat Panel module and the local helpers/components used in this file.
|
// Purpose: Renders the chat transcript, nickname editor, message composer, and optional TTS controls.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps timeline updates isolated from controlled form inputs so incoming chat activity does not
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
// force the composer DOM to re-commit while a user is simply watching or driving.
|
||||||
import { useChat } from '../../context/ChatContext.jsx';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||||
@@ -16,106 +17,226 @@ const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
|||||||
const GOOGLE_TTS_VALUES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
const GOOGLE_TTS_VALUES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||||
const DEFAULT_GOOGLE_TTS_VALUE = 1.0;
|
const DEFAULT_GOOGLE_TTS_VALUE = 1.0;
|
||||||
|
|
||||||
export default function ChatPanel({
|
const TTS_SETTINGS_DEFAULTS = {
|
||||||
hideInput = false,
|
engine: 'flite',
|
||||||
hideSpectatorNotice = false,
|
voice: 'rms',
|
||||||
fillHeight = false,
|
pitch: 50,
|
||||||
allowSpectatorInput = false,
|
googlePitch: DEFAULT_GOOGLE_TTS_VALUE,
|
||||||
title = 'Chat and TTS',
|
googleSpeed: DEFAULT_GOOGLE_TTS_VALUE,
|
||||||
minimal = false,
|
};
|
||||||
}) {
|
|
||||||
|
function resolveTtsSettings(settings) {
|
||||||
|
return {
|
||||||
|
engine: settings?.engine || TTS_SETTINGS_DEFAULTS.engine,
|
||||||
|
voice: settings?.voice || TTS_SETTINGS_DEFAULTS.voice,
|
||||||
|
pitch: Number.isFinite(settings?.pitch) ? settings.pitch : TTS_SETTINGS_DEFAULTS.pitch,
|
||||||
|
googlePitch: Number.isFinite(settings?.googlePitch)
|
||||||
|
? settings.googlePitch
|
||||||
|
: TTS_SETTINGS_DEFAULTS.googlePitch,
|
||||||
|
googleSpeed: Number.isFinite(settings?.googleSpeed)
|
||||||
|
? settings.googleSpeed
|
||||||
|
: TTS_SETTINGS_DEFAULTS.googleSpeed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useChatComposerSessionState(allowSpectatorInput) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
|
|
||||||
|
const rover = useMemo(
|
||||||
|
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||||
|
[currentRoverId, roster],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canChat: role !== 'spectator' || allowSpectatorInput,
|
||||||
|
ttsSupported: Boolean(rover?.audio?.ttsEnabled),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatMessageList({ fillHeight = false }) {
|
||||||
|
const { messages, typing } = useChatTimeline();
|
||||||
|
const listRef = useRef(null);
|
||||||
|
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
||||||
|
const typingRows = useMemo(() => typing || [], [typing]);
|
||||||
|
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listRef.current) return;
|
||||||
|
|
||||||
|
// Keep the transcript pinned to the newest activity. This effect intentionally
|
||||||
|
// lives with the timeline subscriber, because scrolling the message list is the
|
||||||
|
// only DOM work that should happen when messages or typing indicators change.
|
||||||
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
}, [sorted, typingRows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
||||||
|
{sorted.length === 0 && typingRows.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||||
|
) : (
|
||||||
|
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
|
||||||
|
)}
|
||||||
|
{typingRows.map((entry) => (
|
||||||
|
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoizedNicknameForm = memo(NicknameForm);
|
||||||
|
|
||||||
|
function TtsControls({
|
||||||
|
ttsSupported,
|
||||||
|
speak,
|
||||||
|
onSpeakChange,
|
||||||
|
engine,
|
||||||
|
voice,
|
||||||
|
pitch,
|
||||||
|
googlePitch,
|
||||||
|
googleSpeed,
|
||||||
|
saveTtsSettings,
|
||||||
|
}) {
|
||||||
|
if (!ttsSupported) return null;
|
||||||
|
|
||||||
|
// Native selects include generous built-in padding and try to preserve the
|
||||||
|
// width of their longest option. Removing horizontal padding and forcing
|
||||||
|
// min-w-0 lets the row stay intact while keeping labels readable.
|
||||||
|
const compactSelectClass = 'field-input min-w-0 max-w-full px-0 text-xs';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-composer-tts">
|
||||||
|
<label className="flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={speak}
|
||||||
|
onChange={(event) => onSpeakChange(event.target.checked)}
|
||||||
|
className="accent-cyan-500"
|
||||||
|
/>
|
||||||
|
<span>Speak</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={engine}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
const nextVoice =
|
||||||
|
next === 'chromegtts' && !CHROME_TTS_VOICES.includes(voice)
|
||||||
|
? 'tpf'
|
||||||
|
: next === 'flite' && !FLITE_VOICES.includes(voice)
|
||||||
|
? 'rms'
|
||||||
|
: voice;
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
|
||||||
|
}}
|
||||||
|
className={`${compactSelectClass} w-[5.5rem] shrink`}
|
||||||
|
>
|
||||||
|
<option value="flite">flite</option>
|
||||||
|
<option value="espeak">espeak</option>
|
||||||
|
<option value="chromegtts">Google speech</option>
|
||||||
|
</select>
|
||||||
|
{engine === 'flite' || engine === 'chromegtts' ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={voice}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
|
||||||
|
}}
|
||||||
|
className={`${compactSelectClass} w-[3.25rem] shrink`}
|
||||||
|
>
|
||||||
|
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{engine === 'chromegtts' && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={googlePitch}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
|
||||||
|
}}
|
||||||
|
className={`${compactSelectClass} w-[5rem] shrink`}
|
||||||
|
>
|
||||||
|
{GOOGLE_TTS_VALUES.map((value) => (
|
||||||
|
<option key={`pitch-${value}`} value={value}>
|
||||||
|
pitch {value.toFixed(2)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={googleSpeed}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
|
||||||
|
}}
|
||||||
|
className={`${compactSelectClass} w-[5rem] shrink`}
|
||||||
|
>
|
||||||
|
{GOOGLE_TTS_VALUES.map((value) => (
|
||||||
|
<option key={`speed-${value}`} value={value}>
|
||||||
|
speed {value.toFixed(2)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={pitch}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
|
||||||
|
}}
|
||||||
|
className={`${compactSelectClass} w-[4.5rem] shrink`}
|
||||||
|
>
|
||||||
|
{ESPEAK_PITCHES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
pitch {p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatComposer({
|
||||||
|
allowSpectatorInput = false,
|
||||||
|
hideSpectatorNotice = false,
|
||||||
|
}) {
|
||||||
const {
|
const {
|
||||||
messages,
|
|
||||||
typing,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
registerInputRef,
|
registerInputRef,
|
||||||
onInputFocus,
|
onInputFocus,
|
||||||
onInputBlur,
|
onInputBlur,
|
||||||
blurChat,
|
blurChat,
|
||||||
setTypingActive,
|
setTypingActive,
|
||||||
} = useChat();
|
} = useChatActions();
|
||||||
|
const { canChat, ttsSupported } = useChatComposerSessionState(allowSpectatorInput);
|
||||||
const {
|
const {
|
||||||
value: ttsSettings,
|
value: ttsSettings,
|
||||||
save: saveTtsSettings,
|
save: saveTtsSettings,
|
||||||
} = useSettingsNamespace('tts', {
|
} = useSettingsNamespace('tts', TTS_SETTINGS_DEFAULTS);
|
||||||
engine: 'flite',
|
const {
|
||||||
voice: 'rms',
|
|
||||||
pitch: 50,
|
|
||||||
googlePitch: DEFAULT_GOOGLE_TTS_VALUE,
|
|
||||||
googleSpeed: DEFAULT_GOOGLE_TTS_VALUE,
|
|
||||||
});
|
|
||||||
const [draft, setDraft] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [speak, setSpeak] = useState(false);
|
|
||||||
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
|
|
||||||
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
|
|
||||||
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
|
|
||||||
const [googlePitch, setGooglePitch] = useState(() =>
|
|
||||||
Number.isFinite(ttsSettings?.googlePitch) ? ttsSettings.googlePitch : DEFAULT_GOOGLE_TTS_VALUE,
|
|
||||||
);
|
|
||||||
const [googleSpeed, setGoogleSpeed] = useState(() =>
|
|
||||||
Number.isFinite(ttsSettings?.googleSpeed) ? ttsSettings.googleSpeed : DEFAULT_GOOGLE_TTS_VALUE,
|
|
||||||
);
|
|
||||||
const canChat = role !== 'spectator' || allowSpectatorInput;
|
|
||||||
const listRef = useRef(null);
|
|
||||||
|
|
||||||
const rover = useMemo(
|
|
||||||
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
|
||||||
[currentRoverId, roster],
|
|
||||||
);
|
|
||||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
|
||||||
const effectiveHideInput = minimal || hideInput;
|
|
||||||
const effectiveTitle = minimal ? '' : title;
|
|
||||||
|
|
||||||
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
|
||||||
const typingRows = useMemo(() => typing || [], [typing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!listRef.current) return;
|
|
||||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
||||||
}, [sorted, typingRows]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextEngine = ttsSettings?.engine || 'flite';
|
|
||||||
const nextVoice = ttsSettings?.voice || 'rms';
|
|
||||||
const nextPitch = Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50;
|
|
||||||
const nextGooglePitch = Number.isFinite(ttsSettings?.googlePitch)
|
|
||||||
? ttsSettings.googlePitch
|
|
||||||
: DEFAULT_GOOGLE_TTS_VALUE;
|
|
||||||
const nextGoogleSpeed = Number.isFinite(ttsSettings?.googleSpeed)
|
|
||||||
? ttsSettings.googleSpeed
|
|
||||||
: DEFAULT_GOOGLE_TTS_VALUE;
|
|
||||||
if (engine !== nextEngine) setEngine(nextEngine);
|
|
||||||
if (voice !== nextVoice) setVoice(nextVoice);
|
|
||||||
if (pitch !== nextPitch) setPitch(nextPitch);
|
|
||||||
if (googlePitch !== nextGooglePitch) setGooglePitch(nextGooglePitch);
|
|
||||||
if (googleSpeed !== nextGoogleSpeed) setGoogleSpeed(nextGoogleSpeed);
|
|
||||||
}, [
|
|
||||||
engine,
|
engine,
|
||||||
|
voice,
|
||||||
|
pitch,
|
||||||
googlePitch,
|
googlePitch,
|
||||||
googleSpeed,
|
googleSpeed,
|
||||||
pitch,
|
} = resolveTtsSettings(ttsSettings);
|
||||||
ttsSettings?.engine,
|
const [draft, setDraft] = useState('');
|
||||||
ttsSettings?.googlePitch,
|
const [sending, setSending] = useState(false);
|
||||||
ttsSettings?.googleSpeed,
|
const [speak, setSpeak] = useState(true);
|
||||||
ttsSettings?.pitch,
|
const effectiveSpeak = ttsSupported && speak;
|
||||||
ttsSettings?.voice,
|
|
||||||
voice,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ttsSupported) {
|
|
||||||
setSpeak(true);
|
|
||||||
} else {
|
|
||||||
setSpeak(false);
|
|
||||||
}
|
|
||||||
}, [ttsSupported]);
|
|
||||||
|
|
||||||
const ttsPayload = useMemo(() => {
|
const ttsPayload = useMemo(() => {
|
||||||
if (!ttsSupported || !speak) return null;
|
if (!effectiveSpeak) return null;
|
||||||
|
|
||||||
|
// The payload is derived during render from the current settings instead of
|
||||||
|
// mirrored into local state. That removes an effect-driven state sync path and
|
||||||
|
// keeps the composer render count tied to real user/settings changes.
|
||||||
if (engine === 'espeak') {
|
if (engine === 'espeak') {
|
||||||
return { speak: true, engine, pitch };
|
return { speak: true, engine, pitch };
|
||||||
}
|
}
|
||||||
@@ -123,15 +244,19 @@ export default function ChatPanel({
|
|||||||
return { speak: true, engine, voice, pitch: googlePitch, speed: googleSpeed };
|
return { speak: true, engine, voice, pitch: googlePitch, speed: googleSpeed };
|
||||||
}
|
}
|
||||||
return { speak: true, engine, voice };
|
return { speak: true, engine, voice };
|
||||||
}, [engine, googlePitch, googleSpeed, pitch, speak, ttsSupported, voice]);
|
}, [effectiveSpeak, engine, googlePitch, googleSpeed, pitch, voice]);
|
||||||
|
|
||||||
async function handleSend(event) {
|
async function handleSend(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canChat || effectiveHideInput) return;
|
if (!canChat) return;
|
||||||
// Allow users to type "\n" to represent a newline in messages
|
|
||||||
|
// Allow users to type "\n" to represent a newline in messages. This keeps the
|
||||||
|
// single-line composer compatible with users who still want line breaks in the
|
||||||
|
// delivered chat payload.
|
||||||
const normalizedDraft = draft.replace(/\\n/g, '\n');
|
const normalizedDraft = draft.replace(/\\n/g, '\n');
|
||||||
const clean = normalizedDraft.trim();
|
const clean = normalizedDraft.trim();
|
||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await sendMessage(clean, ttsPayload);
|
await sendMessage(clean, ttsPayload);
|
||||||
@@ -145,177 +270,91 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
|
||||||
// The composer class owns the responsive row behavior in CSS. A container
|
// The composer class owns the responsive row behavior in CSS. A container
|
||||||
// query is used instead of JavaScript so the layout responds to the actual
|
// query is used instead of JavaScript so the layout responds to the actual
|
||||||
// panel width, which matters because this component appears in sidebars and
|
// panel width, which matters because this component appears in sidebars and
|
||||||
// mobile layouts that do not map cleanly to viewport breakpoints.
|
// mobile layouts that do not map cleanly to viewport breakpoints.
|
||||||
const composerClass = 'chat-composer';
|
return (
|
||||||
// Native selects include generous built-in padding and try to preserve the
|
<form className="chat-composer" onSubmit={handleSend}>
|
||||||
// width of their longest option. Removing horizontal padding and forcing
|
<div className="chat-composer-nickname">
|
||||||
// min-w-0 lets the row stay intact while keeping labels readable.
|
<MemoizedNicknameForm compact />
|
||||||
const compactSelectClass = 'field-input min-w-0 max-w-full px-0 text-xs';
|
</div>
|
||||||
|
<input
|
||||||
|
className="field-input chat-composer-input"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
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 === 'Enter' && !draft.trim()) {
|
||||||
|
event.preventDefault();
|
||||||
|
blurChat();
|
||||||
|
setTypingActive(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={(el) => registerInputRef(el, { target: 'panel' })}
|
||||||
|
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
||||||
|
disabled={!canChat}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canChat || sending}
|
||||||
|
className="button-dark chat-composer-send disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sending ? '...' : 'Send'}
|
||||||
|
</button>
|
||||||
|
<TtsControls
|
||||||
|
ttsSupported={ttsSupported}
|
||||||
|
speak={effectiveSpeak}
|
||||||
|
onSpeakChange={setSpeak}
|
||||||
|
engine={engine}
|
||||||
|
voice={voice}
|
||||||
|
pitch={pitch}
|
||||||
|
googlePitch={googlePitch}
|
||||||
|
googleSpeed={googleSpeed}
|
||||||
|
saveTtsSettings={saveTtsSettings}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoizedChatMessageList = memo(ChatMessageList);
|
||||||
|
const MemoizedChatComposer = memo(ChatComposer);
|
||||||
|
|
||||||
|
export default function ChatPanel({
|
||||||
|
hideInput = false,
|
||||||
|
hideSpectatorNotice = false,
|
||||||
|
fillHeight = false,
|
||||||
|
allowSpectatorInput = false,
|
||||||
|
title = 'Chat and speech',
|
||||||
|
minimal = false,
|
||||||
|
}) {
|
||||||
|
const effectiveHideInput = minimal || hideInput;
|
||||||
|
const effectiveTitle = minimal ? '' : title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title={effectiveTitle}
|
title={effectiveTitle}
|
||||||
|
|
||||||
hideHeader={minimal || !effectiveTitle}
|
hideHeader={minimal || !effectiveTitle}
|
||||||
fillHeight={fillHeight}
|
fillHeight={fillHeight}
|
||||||
bodyClassName="space-y-0.5 text-base"
|
bodyClassName="space-y-0.5 text-base"
|
||||||
>
|
>
|
||||||
<div className={`overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
<MemoizedChatMessageList fillHeight={fillHeight} />
|
||||||
{sorted.length === 0 && typingRows.length === 0 ? (
|
|
||||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
|
||||||
) : (
|
|
||||||
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
|
|
||||||
)}
|
|
||||||
{typingRows.map((entry) => (
|
|
||||||
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{!effectiveHideInput && (
|
{!effectiveHideInput && (
|
||||||
<form className={composerClass} onSubmit={handleSend}>
|
<MemoizedChatComposer
|
||||||
<div className="chat-composer-nickname">
|
allowSpectatorInput={allowSpectatorInput}
|
||||||
<NicknameForm compact />
|
hideSpectatorNotice={hideSpectatorNotice}
|
||||||
</div>
|
/>
|
||||||
<input
|
|
||||||
className="field-input chat-composer-input"
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value;
|
|
||||||
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 === 'Enter' && !draft.trim()) {
|
|
||||||
event.preventDefault();
|
|
||||||
blurChat();
|
|
||||||
setTypingActive(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
ref={(el) => registerInputRef(el, { target: 'panel' })}
|
|
||||||
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
|
||||||
disabled={!canChat}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canChat || sending}
|
|
||||||
className="button-dark chat-composer-send disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{sending ? '...' : 'Send'}
|
|
||||||
</button>
|
|
||||||
{ttsSupported && (
|
|
||||||
<div className="chat-composer-tts">
|
|
||||||
<label className="flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs text-slate-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={speak}
|
|
||||||
onChange={(e) => setSpeak(e.target.checked)}
|
|
||||||
className="accent-cyan-500"
|
|
||||||
/>
|
|
||||||
<span>Speak</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={engine}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value;
|
|
||||||
const nextVoice =
|
|
||||||
next === 'chromegtts' && !CHROME_TTS_VOICES.includes(voice)
|
|
||||||
? 'tpf'
|
|
||||||
: next === 'flite' && !FLITE_VOICES.includes(voice)
|
|
||||||
? 'rms'
|
|
||||||
: voice;
|
|
||||||
setEngine(next);
|
|
||||||
if (nextVoice !== voice) setVoice(nextVoice);
|
|
||||||
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
|
|
||||||
}}
|
|
||||||
className={`${compactSelectClass} w-[5.5rem] shrink`}
|
|
||||||
>
|
|
||||||
<option value="flite">flite</option>
|
|
||||||
<option value="espeak">espeak</option>
|
|
||||||
<option value="chromegtts">Google TTS</option>
|
|
||||||
</select>
|
|
||||||
{engine === 'flite' || engine === 'chromegtts' ? (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
value={voice}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value;
|
|
||||||
setVoice(next);
|
|
||||||
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
|
|
||||||
}}
|
|
||||||
className={`${compactSelectClass} w-[3.25rem] shrink`}
|
|
||||||
>
|
|
||||||
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
|
|
||||||
<option key={v} value={v}>
|
|
||||||
{v}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{engine === 'chromegtts' && (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
value={googlePitch}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = Number(e.target.value);
|
|
||||||
setGooglePitch(next);
|
|
||||||
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
|
|
||||||
}}
|
|
||||||
className={`${compactSelectClass} w-[5rem] shrink`}
|
|
||||||
>
|
|
||||||
{GOOGLE_TTS_VALUES.map((value) => (
|
|
||||||
<option key={`pitch-${value}`} value={value}>
|
|
||||||
pitch {value.toFixed(2)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={googleSpeed}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = Number(e.target.value);
|
|
||||||
setGoogleSpeed(next);
|
|
||||||
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
|
|
||||||
}}
|
|
||||||
className={`${compactSelectClass} w-[5rem] shrink`}
|
|
||||||
>
|
|
||||||
{GOOGLE_TTS_VALUES.map((value) => (
|
|
||||||
<option key={`speed-${value}`} value={value}>
|
|
||||||
speed {value.toFixed(2)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
value={pitch}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = Number(e.target.value);
|
|
||||||
setPitch(next);
|
|
||||||
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
|
|
||||||
}}
|
|
||||||
className={`${compactSelectClass} w-[4.5rem] shrink`}
|
|
||||||
>
|
|
||||||
{ESPEAK_PITCHES.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
pitch {p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
|
// 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.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
import { useChat } from '../../../context/ChatContext.jsx';
|
import { useChatActions } from '../../../context/ChatContext.jsx';
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
||||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
|
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
|
||||||
const { value: ttsSettings } = useSettingsNamespace('tts', {
|
const { value: ttsSettings } = useSettingsNamespace('tts', {
|
||||||
engine: 'flite',
|
engine: 'flite',
|
||||||
voice: 'rms',
|
voice: 'rms',
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import messageSound from '../assets/message.mp3';
|
|||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
|
|
||||||
const ChatContext = createContext({
|
const CHAT_TIMELINE_DEFAULT = {
|
||||||
messages: [],
|
messages: [],
|
||||||
typing: [],
|
typing: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHAT_ACTIONS_DEFAULT = {
|
||||||
sendMessage: async () => {},
|
sendMessage: async () => {},
|
||||||
focusChat: () => {},
|
focusChat: () => {},
|
||||||
blurChat: () => {},
|
blurChat: () => {},
|
||||||
@@ -19,7 +22,31 @@ const ChatContext = createContext({
|
|||||||
onInputFocus: () => {},
|
onInputFocus: () => {},
|
||||||
onInputBlur: () => {},
|
onInputBlur: () => {},
|
||||||
setTypingActive: () => {},
|
setTypingActive: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHAT_FOCUS_DEFAULT = {
|
||||||
isChatFocused: false,
|
isChatFocused: false,
|
||||||
|
selfSocketId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chat messages and typing indicators are the highest-churn chat data. Keeping
|
||||||
|
// them in their own context lets transcript components update without forcing
|
||||||
|
// controlled composer inputs to re-render and re-commit unchanged attributes.
|
||||||
|
const ChatTimelineContext = createContext(CHAT_TIMELINE_DEFAULT);
|
||||||
|
|
||||||
|
// Actions are intentionally separate from timeline state. Consumers such as the
|
||||||
|
// HUD composer only need stable command functions, so subscribing them to the
|
||||||
|
// message array would recreate the performance issue this provider is avoiding.
|
||||||
|
const ChatActionsContext = createContext(CHAT_ACTIONS_DEFAULT);
|
||||||
|
|
||||||
|
// Focus state is used by keyboard input capture. It changes for input focus and
|
||||||
|
// blur events, but it should not be tied to incoming chat traffic either.
|
||||||
|
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
|
||||||
|
|
||||||
|
const ChatContext = createContext({
|
||||||
|
...CHAT_TIMELINE_DEFAULT,
|
||||||
|
...CHAT_ACTIONS_DEFAULT,
|
||||||
|
...CHAT_FOCUS_DEFAULT,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ChatProvider({ children }) {
|
export function ChatProvider({ children }) {
|
||||||
@@ -91,7 +118,7 @@ export function ChatProvider({ children }) {
|
|||||||
return () => {
|
return () => {
|
||||||
socket.off('chat:message', handleMessage);
|
socket.off('chat:message', handleMessage);
|
||||||
};
|
};
|
||||||
}, [playSound, session?.socketId, socket]);
|
}, [playSound, pushAlert, session?.socketId, socket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleTyping(payload = {}) {
|
function handleTyping(payload = {}) {
|
||||||
@@ -221,9 +248,18 @@ export function ChatProvider({ children }) {
|
|||||||
const onInputFocus = useCallback(() => setIsChatFocused(true), []);
|
const onInputFocus = useCallback(() => setIsChatFocused(true), []);
|
||||||
const onInputBlur = useCallback(() => setIsChatFocused(false), []);
|
const onInputBlur = useCallback(() => setIsChatFocused(false), []);
|
||||||
|
|
||||||
const value = useMemo(
|
// Each published value is memoized independently so React only wakes the
|
||||||
|
// consumers attached to the slice that actually changed.
|
||||||
|
const timelineValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
messages,
|
messages,
|
||||||
|
typing,
|
||||||
|
}),
|
||||||
|
[messages, typing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionsValue = useMemo(
|
||||||
|
() => ({
|
||||||
sendMessage,
|
sendMessage,
|
||||||
focusChat,
|
focusChat,
|
||||||
blurChat,
|
blurChat,
|
||||||
@@ -231,14 +267,38 @@ export function ChatProvider({ children }) {
|
|||||||
onInputFocus,
|
onInputFocus,
|
||||||
onInputBlur,
|
onInputBlur,
|
||||||
setTypingActive,
|
setTypingActive,
|
||||||
typing,
|
}),
|
||||||
|
[blurChat, focusChat, onInputBlur, onInputFocus, registerInputRef, sendMessage, setTypingActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusValue = useMemo(
|
||||||
|
() => ({
|
||||||
isChatFocused,
|
isChatFocused,
|
||||||
selfSocketId: session?.socketId || null,
|
selfSocketId: session?.socketId || null,
|
||||||
}),
|
}),
|
||||||
[blurChat, focusChat, isChatFocused, messages, onInputBlur, onInputFocus, registerInputRef, sendMessage, session?.socketId, setTypingActive, typing],
|
[isChatFocused, session?.socketId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
...timelineValue,
|
||||||
|
...actionsValue,
|
||||||
|
...focusValue,
|
||||||
|
}),
|
||||||
|
[actionsValue, focusValue, timelineValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Nesting the focused providers keeps the public provider API unchanged for
|
||||||
|
// callers while giving individual consumers smaller subscriptions.
|
||||||
|
<ChatTimelineContext.Provider value={timelineValue}>
|
||||||
|
<ChatActionsContext.Provider value={actionsValue}>
|
||||||
|
<ChatFocusContext.Provider value={focusValue}>
|
||||||
|
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||||
|
</ChatFocusContext.Provider>
|
||||||
|
</ChatActionsContext.Provider>
|
||||||
|
</ChatTimelineContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
@@ -248,3 +308,27 @@ export function useChat() {
|
|||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChatTimeline() {
|
||||||
|
const ctx = useContext(ChatTimelineContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useChatTimeline must be used inside ChatProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatActions() {
|
||||||
|
const ctx = useContext(ChatActionsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useChatActions must be used inside ChatProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatFocus() {
|
||||||
|
const ctx = useContext(ChatFocusContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useChatFocus must be used inside ChatProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
|
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
import { useControlActions, useControlSelector } from '../ControlContext.jsx';
|
import { useControlActions, useControlSelector } from '../ControlContext.jsx';
|
||||||
import { useChat } from '../../context/ChatContext.jsx';
|
import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
||||||
import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
|
import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
|
||||||
@@ -105,7 +105,8 @@ export default function KeyboardInputManager() {
|
|||||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||||
const dockAssist = useManualDockAssist();
|
const dockAssist = useManualDockAssist();
|
||||||
const { homeAssistantSetState } = useSessionActions();
|
const { homeAssistantSetState } = useSessionActions();
|
||||||
const { focusChat, isChatFocused } = useChat();
|
const { focusChat } = useChatActions();
|
||||||
|
const { isChatFocused } = useChatFocus();
|
||||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||||
const { save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
|
const { save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
|
||||||
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);
|
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);
|
||||||
|
|||||||
Reference in New Issue
Block a user