mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
chat history and other stuffs, light commands replay fixes
This commit is contained in:
@@ -6,6 +6,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import useChatMessageHistoryNavigation from '../../hooks/useChatMessageHistoryNavigation.js';
|
||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
@@ -260,6 +261,7 @@ function ChatComposer({
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [speak, setSpeak] = useState(true);
|
||||
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||
const effectiveSpeak = ttsSupported && speak;
|
||||
const ttsPayload = useMemo(() => {
|
||||
if (!effectiveSpeak) return null;
|
||||
@@ -291,6 +293,7 @@ function ChatComposer({
|
||||
try {
|
||||
await sendMessage(clean, ttsPayload);
|
||||
setDraft('');
|
||||
resetHistoryNavigation();
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
} catch (err) {
|
||||
@@ -314,6 +317,9 @@ function ChatComposer({
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
// A direct edit starts a fresh history traversal. This prevents an
|
||||
// old ArrowDown position from overwriting text the user just typed.
|
||||
resetHistoryNavigation();
|
||||
setDraft(next);
|
||||
setTypingActive(Boolean(next.trim()));
|
||||
}}
|
||||
@@ -326,6 +332,15 @@ function ChatComposer({
|
||||
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();
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -42,6 +43,7 @@ function HudChatInput({ compact = false }) {
|
||||
});
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||
const canChat = role !== 'spectator';
|
||||
const hideHudChat = role === 'spectator';
|
||||
const chatTargetId = useMemo(() => {
|
||||
@@ -118,6 +120,7 @@ function HudChatInput({ compact = false }) {
|
||||
try {
|
||||
await sendMessage(clean, ttsPayload);
|
||||
setDraft('');
|
||||
resetHistoryNavigation();
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
} catch (err) {
|
||||
@@ -136,6 +139,9 @@ function HudChatInput({ compact = false }) {
|
||||
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()));
|
||||
}}
|
||||
@@ -148,6 +154,15 @@ function HudChatInput({ compact = false }) {
|
||||
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();
|
||||
|
||||
@@ -30,6 +30,12 @@ const CHAT_FOCUS_DEFAULT = {
|
||||
selfSocketId: null,
|
||||
};
|
||||
|
||||
const CHAT_HISTORY_DEFAULT = {
|
||||
messageHistory: [],
|
||||
};
|
||||
const CHAT_HISTORY_LIMIT = 10;
|
||||
const CHAT_HISTORY_ENTRY_LIMIT = 200;
|
||||
|
||||
// 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.
|
||||
@@ -44,6 +50,12 @@ const ChatActionsContext = createContext(CHAT_ACTIONS_DEFAULT);
|
||||
// blur events, but it should not be tied to incoming chat traffic either.
|
||||
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
|
||||
|
||||
// Sent-message history has its own subscription because it changes only when
|
||||
// this browser successfully posts a message. Keeping it separate prevents the
|
||||
// transcript and focus consumers from re-rendering when the persisted history
|
||||
// changes, while still giving every mounted composer one shared history source.
|
||||
const ChatHistoryContext = createContext(CHAT_HISTORY_DEFAULT);
|
||||
|
||||
const ChatContext = createContext({
|
||||
...CHAT_TIMELINE_DEFAULT,
|
||||
...CHAT_ACTIONS_DEFAULT,
|
||||
@@ -56,6 +68,7 @@ export function ChatProvider({ children }) {
|
||||
const { pushAlert } = useSessionActions();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
||||
const { value: chatSettings, save: saveChatSettings } = useSettingsNamespace('chat', CHAT_HISTORY_DEFAULT);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||
@@ -229,11 +242,29 @@ export function ChatProvider({ children }) {
|
||||
hasTts: Boolean(tts),
|
||||
length: typeof text === 'string' ? text.trim().length : 0,
|
||||
});
|
||||
|
||||
/*
|
||||
Record only messages accepted by the server so ArrowUp never
|
||||
recalls a draft that failed to send. The settings subsystem uses
|
||||
one browser cookie for every namespace, so both the entry length
|
||||
and history count are deliberately bounded to leave room for the
|
||||
user's other persisted preferences.
|
||||
*/
|
||||
const historyEntry = typeof text === 'string' ? text.slice(0, CHAT_HISTORY_ENTRY_LIMIT) : '';
|
||||
if (historyEntry) {
|
||||
saveChatSettings((current) => {
|
||||
const currentHistory = Array.isArray(current?.messageHistory) ? current.messageHistory : [];
|
||||
return {
|
||||
...(current || {}),
|
||||
messageHistory: [...currentHistory.slice(-(CHAT_HISTORY_LIMIT - 1)), historyEntry],
|
||||
};
|
||||
});
|
||||
}
|
||||
resolve(resp);
|
||||
}
|
||||
});
|
||||
}),
|
||||
[profileImage, socket],
|
||||
[profileImage, saveChatSettings, socket],
|
||||
);
|
||||
|
||||
const registerInputRef = useCallback((el, options = {}) => {
|
||||
@@ -301,13 +332,23 @@ export function ChatProvider({ children }) {
|
||||
[isChatFocused, session?.socketId],
|
||||
);
|
||||
|
||||
const historyValue = useMemo(
|
||||
() => ({
|
||||
// Treat malformed or hand-edited cookie data as an empty history. This
|
||||
// keeps keyboard navigation safe without mutating unrelated settings.
|
||||
messageHistory: Array.isArray(chatSettings?.messageHistory) ? chatSettings.messageHistory : [],
|
||||
}),
|
||||
[chatSettings],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...timelineValue,
|
||||
...actionsValue,
|
||||
...focusValue,
|
||||
...historyValue,
|
||||
}),
|
||||
[actionsValue, focusValue, timelineValue],
|
||||
[actionsValue, focusValue, historyValue, timelineValue],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -316,7 +357,9 @@ export function ChatProvider({ children }) {
|
||||
<ChatTimelineContext.Provider value={timelineValue}>
|
||||
<ChatActionsContext.Provider value={actionsValue}>
|
||||
<ChatFocusContext.Provider value={focusValue}>
|
||||
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||
<ChatHistoryContext.Provider value={historyValue}>
|
||||
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||
</ChatHistoryContext.Provider>
|
||||
</ChatFocusContext.Provider>
|
||||
</ChatActionsContext.Provider>
|
||||
</ChatTimelineContext.Provider>
|
||||
@@ -354,3 +397,11 @@ export function useChatFocus() {
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useChatHistory() {
|
||||
const ctx = useContext(ChatHistoryContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useChatHistory must be used inside ChatProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Chat Message History Navigation
|
||||
// Purpose: Gives each chat input Bash-style traversal over the shared persisted send history.
|
||||
// Scope: Owns only draft/navigation state; it does not register global keys or interact with rover controls.
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useChatHistory } from '../context/ChatContext.jsx';
|
||||
|
||||
export default function useChatMessageHistoryNavigation() {
|
||||
const { messageHistory } = useChatHistory();
|
||||
const historyIndexRef = useRef(null);
|
||||
const preservedDraftRef = useRef('');
|
||||
|
||||
const resetHistoryNavigation = useCallback(() => {
|
||||
// Manual edits and successful sends begin a new navigation session. The
|
||||
// current input value remains owned by the composer and is not changed here.
|
||||
historyIndexRef.current = null;
|
||||
preservedDraftRef.current = '';
|
||||
}, []);
|
||||
|
||||
const navigateHistory = useCallback(
|
||||
(direction, currentDraft) => {
|
||||
if (!messageHistory.length) return null;
|
||||
|
||||
if (direction === 'previous') {
|
||||
if (historyIndexRef.current === null) {
|
||||
// Save the in-progress draft exactly once so ArrowDown can restore it
|
||||
// after the user reaches the newest edge of history, like a shell.
|
||||
preservedDraftRef.current = currentDraft;
|
||||
historyIndexRef.current = messageHistory.length - 1;
|
||||
} else {
|
||||
historyIndexRef.current = Math.max(0, historyIndexRef.current - 1);
|
||||
}
|
||||
return messageHistory[historyIndexRef.current];
|
||||
}
|
||||
|
||||
if (direction === 'next' && historyIndexRef.current !== null) {
|
||||
if (historyIndexRef.current < messageHistory.length - 1) {
|
||||
historyIndexRef.current += 1;
|
||||
return messageHistory[historyIndexRef.current];
|
||||
}
|
||||
|
||||
const preservedDraft = preservedDraftRef.current;
|
||||
resetHistoryNavigation();
|
||||
return preservedDraft;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[messageHistory, resetHistoryNavigation],
|
||||
);
|
||||
|
||||
return { navigateHistory, resetHistoryNavigation };
|
||||
}
|
||||
Reference in New Issue
Block a user