From 119d12a5ea96f66c47ca6dda6dc52e2bbc610f07 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 16 Mar 2026 22:41:35 +0100 Subject: [PATCH] Add quick commands autocomplete dropdown in chat input Server-authoritative command list via packet 4050, merged with client-only commands. Supports keyboard navigation, filtering, and module-level caching to handle login-time packet timing. Co-Authored-By: medievalshell --- src/api/room/widgets/CommandDefinition.ts | 5 + src/api/room/widgets/index.ts | 1 + .../ChatInputCommandSelectorView.tsx | 41 +++++ .../room/widgets/chat-input/ChatInputView.tsx | 49 +++++- src/hooks/rooms/widgets/index.ts | 1 + .../rooms/widgets/useChatCommandSelector.ts | 162 ++++++++++++++++++ 6 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 src/api/room/widgets/CommandDefinition.ts create mode 100644 src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx create mode 100644 src/hooks/rooms/widgets/useChatCommandSelector.ts diff --git a/src/api/room/widgets/CommandDefinition.ts b/src/api/room/widgets/CommandDefinition.ts new file mode 100644 index 0000000..d1e0508 --- /dev/null +++ b/src/api/room/widgets/CommandDefinition.ts @@ -0,0 +1,5 @@ +export interface CommandDefinition +{ + key: string; + description: string; +} diff --git a/src/api/room/widgets/index.ts b/src/api/room/widgets/index.ts index 5cef378..4892937 100644 --- a/src/api/room/widgets/index.ts +++ b/src/api/room/widgets/index.ts @@ -7,6 +7,7 @@ export * from './AvatarInfoUser'; export * from './AvatarInfoUtilities'; export * from './BotSkillsEnum'; export * from './ChatBubbleMessage'; +export * from './CommandDefinition'; export * from './ChatBubbleUtilities'; export * from './ChatMessageTypeEnum'; export * from './DimmerFurnitureWidgetPresetItem'; diff --git a/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx new file mode 100644 index 0000000..5eae3d2 --- /dev/null +++ b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx @@ -0,0 +1,41 @@ +import { FC, useEffect, useRef } from 'react'; +import { CommandDefinition } from '../../../../api'; + +interface ChatInputCommandSelectorViewProps +{ + commands: CommandDefinition[]; + selectedIndex: number; + onSelect: (command: CommandDefinition) => void; + onHover: (index: number) => void; +} + +export const ChatInputCommandSelectorView: FC = props => +{ + const { commands = [], selectedIndex = 0, onSelect = null, onHover = null } = props; + const listRef = useRef(null); + + useEffect(() => + { + if(!listRef.current) return; + + const selected = listRef.current.children[selectedIndex] as HTMLElement; + + if(selected) selected.scrollIntoView({ block: 'nearest' }); + }, [ selectedIndex ]); + + return ( +
+ { commands.map((cmd, index) => ( +
onSelect(cmd) } + onMouseEnter={ () => onHover(index) } + > + :{ cmd.key } + { cmd.description } +
+ )) } +
+ ); +}; diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index 3abe197..4c22e85 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -3,7 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api'; import { Text } from '../../../../common'; -import { useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks'; +import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks'; +import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView'; import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView'; import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView'; @@ -14,6 +15,7 @@ export const ChatInputView: FC<{}> = props => const { selectedUsername = '', floodBlocked = false, floodBlockedSeconds = 0, setIsTyping = null, setIsIdle = null, sendChat = null } = useChatInputWidget(); const { roomSession = null } = useRoom(); const inputRef = useRef(); + const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue); const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []); const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []); @@ -133,6 +135,40 @@ export const ChatInputView: FC<{}> = props => if(document.activeElement !== inputRef.current) setInputFocus(); + if(commandSelectorVisible) + { + switch(event.key) + { + case 'ArrowUp': + event.preventDefault(); + moveUp(); + return; + case 'ArrowDown': + event.preventDefault(); + moveDown(); + return; + case 'Tab': + event.preventDefault(); + // fall through + case 'NumpadEnter': + case 'Enter': { + const selected = selectCurrent(); + + if(selected) + { + event.preventDefault(); + setChatValue(':' + selected.key + ' '); + return; + } + break; + } + case 'Escape': + event.preventDefault(); + closeCommandSelector(); + return; + } + } + const value = (event.target as HTMLInputElement).value; switch(event.key) @@ -158,7 +194,7 @@ export const ChatInputView: FC<{}> = props => return; } - }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue ]); + }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector ]); useUiEvent(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event => { @@ -243,7 +279,14 @@ export const ChatInputView: FC<{}> = props => return ( createPortal( -
+
+ { commandSelectorVisible && + { setChatValue(':' + cmd.key + ' '); inputRef.current?.focus(); } } + onHover={ setSelectedIndex } + /> }
{ !floodBlocked && updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> } diff --git a/src/hooks/rooms/widgets/index.ts b/src/hooks/rooms/widgets/index.ts index 9984450..ea35008 100644 --- a/src/hooks/rooms/widgets/index.ts +++ b/src/hooks/rooms/widgets/index.ts @@ -1,5 +1,6 @@ export * from './furniture'; export * from './useAvatarInfoWidget'; +export * from './useChatCommandSelector'; export * from './useChatInputWidget'; export * from './useChatWidget'; export * from './useDoorbellWidget'; diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.ts b/src/hooks/rooms/widgets/useChatCommandSelector.ts new file mode 100644 index 0000000..eb31b68 --- /dev/null +++ b/src/hooks/rooms/widgets/useChatCommandSelector.ts @@ -0,0 +1,162 @@ +import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CommandDefinition } from '../../../api'; +import { useMessageEvent } from '../../events'; + +const CLIENT_COMMANDS: CommandDefinition[] = [ + // Effetti stanza + { key: 'shake', description: 'Scuoti la stanza' }, + { key: 'rotate', description: 'Ruota la stanza' }, + { key: 'zoom', description: 'Zoom stanza' }, + { key: 'flip', description: 'Reset zoom' }, + { key: 'iddqd', description: 'Reset zoom' }, + { key: 'screenshot', description: 'Screenshot stanza' }, + { key: 'togglefps', description: 'Toggle FPS' }, + // Espressioni + { key: 'd', description: 'Ridi (VIP)' }, + { key: 'kiss', description: 'Manda un bacio (VIP)' }, + { key: 'jump', description: 'Salta (VIP)' }, + { key: 'idle', description: 'Vai in idle' }, + { key: 'sign', description: 'Mostra cartello' }, + // Gestione stanza + { key: 'furni', description: 'Furni chooser' }, + { key: 'chooser', description: 'User chooser' }, + { key: 'floor', description: 'Floor editor' }, + { key: 'bcfloor', description: 'Floor editor' }, + { key: 'pickall', description: 'Raccogli tutti i furni' }, + { key: 'ejectall', description: 'Espelli tutti i furni' }, + { key: 'settings', description: 'Impostazioni stanza' }, + // Info + { key: 'client', description: 'Info client' }, + { key: 'nitro', description: 'Info client' }, +]; + +// Module-level cache: cattura i comandi dal server anche prima che React monti +let cachedServerCommands: CommandDefinition[] = []; +let globalListenerRegistered = false; + +function ensureGlobalListener(): void +{ + if(globalListenerRegistered) return; + globalListenerRegistered = true; + + try + { + const event = new AvailableCommandsEvent((event: AvailableCommandsEvent) => + { + const parser = event.getParser(); + cachedServerCommands = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description })); + }); + + GetCommunication().registerMessageEvent(event); + } + catch(e) + { + // Communication not ready yet, will retry on hook mount + globalListenerRegistered = false; + } +} + +// Try to register immediately at module load +ensureGlobalListener(); + +export const useChatCommandSelector = (chatValue: string) => +{ + const [ serverCommands, setServerCommands ] = useState(cachedServerCommands); + const [ selectedIndex, setSelectedIndex ] = useState(0); + const [ dismissed, setDismissed ] = useState(false); + + // Ensure global listener is registered + useEffect(() => + { + ensureGlobalListener(); + + // If cache already has data (from login), use it + if(cachedServerCommands.length > 0 && serverCommands.length === 0) + { + setServerCommands(cachedServerCommands); + } + }, []); + + // Also listen via React hook for any future updates (e.g. rank change) + useMessageEvent(AvailableCommandsEvent, event => + { + const parser = event.getParser(); + const cmds = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description })); + cachedServerCommands = cmds; + setServerCommands(cmds); + }); + + const allCommands = useMemo(() => + { + const merged = [ ...serverCommands ]; + + for(const clientCmd of CLIENT_COMMANDS) + { + if(!merged.some(cmd => cmd.key === clientCmd.key)) + { + merged.push(clientCmd); + } + } + + return merged.sort((a, b) => a.key.localeCompare(b.key)); + }, [ serverCommands ]); + + const filterText = useMemo(() => + { + if(!chatValue.startsWith(':') || chatValue.includes(' ')) return ''; + + return chatValue.slice(1).toLowerCase(); + }, [ chatValue ]); + + const filteredCommands = useMemo(() => + { + if(!filterText && !chatValue.startsWith(':')) return []; + + return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText)); + }, [ allCommands, filterText, chatValue ]); + + const isVisible = useMemo(() => + { + return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed; + }, [ chatValue, filteredCommands, dismissed ]); + + const moveUp = useCallback(() => + { + setSelectedIndex(prev => (prev <= 0 ? filteredCommands.length - 1 : prev - 1)); + }, [ filteredCommands.length ]); + + const moveDown = useCallback(() => + { + setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1)); + }, [ filteredCommands.length ]); + + const selectCurrent = useCallback((): CommandDefinition | null => + { + if(selectedIndex >= 0 && selectedIndex < filteredCommands.length) + { + return filteredCommands[selectedIndex]; + } + + return null; + }, [ selectedIndex, filteredCommands ]); + + const close = useCallback(() => + { + setDismissed(true); + }, []); + + // Reset dismissed when chatValue changes to a new command start + useEffect(() => + { + if(chatValue === ':' || chatValue === '') setDismissed(false); + }, [ chatValue ]); + + // Reset selectedIndex when filtered list changes + useEffect(() => + { + setSelectedIndex(0); + }, [ filterText ]); + + return { isVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close }; +};