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 <medievalshell@users.noreply.github.com>
This commit is contained in:
simoleo89
2026-03-16 22:41:35 +01:00
parent 23e6b08e06
commit 119d12a5ea
6 changed files with 256 additions and 3 deletions
@@ -0,0 +1,5 @@
export interface CommandDefinition
{
key: string;
description: string;
}
+1
View File
@@ -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';
@@ -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<ChatInputCommandSelectorViewProps> = props =>
{
const { commands = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
const listRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
if(!listRef.current) return;
const selected = listRef.current.children[selectedIndex] as HTMLElement;
if(selected) selected.scrollIntoView({ block: 'nearest' });
}, [ selectedIndex ]);
return (
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
{ commands.map((cmd, index) => (
<div
key={ cmd.key }
className={ `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
onClick={ () => onSelect(cmd) }
onMouseEnter={ () => onHover(index) }
>
<span className="font-bold">:{ cmd.key }</span>
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
</div>
)) }
</div>
);
};
@@ -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<HTMLInputElement>();
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>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
{
@@ -243,7 +279,14 @@ export const ChatInputView: FC<{}> = props =>
return (
createPortal(
<div className="nitro-chat-input-container flex justify-between items-center relative h-10 border-2 border-black bg-gray-200 pr-2.5 w-full overflow-hidden rounded-lg">
<div className="nitro-chat-input-container flex justify-between items-center relative h-10 border-2 border-black bg-gray-200 pr-2.5 w-full overflow-visible rounded-lg">
{ commandSelectorVisible &&
<ChatInputCommandSelectorView
commands={ filteredCommands }
selectedIndex={ selectedIndex }
onSelect={ (cmd) => { setChatValue(':' + cmd.key + ' '); inputRef.current?.focus(); } }
onHover={ setSelectedIndex }
/> }
<div className="flex-1 items-center input-sizer">
{ !floodBlocked &&
<input ref={ inputRef } className="[font-size:inherit] placeholder-[#6c757d] bg-transparent border-none focus:border-current focus:shadow-none focus:ring-0 " maxLength={ maxChatLength } placeholder={ LocalizeText('widgets.chatinput.default') } type="text" value={ chatValue } onChange={ event => updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> }
+1
View File
@@ -1,5 +1,6 @@
export * from './furniture';
export * from './useAvatarInfoWidget';
export * from './useChatCommandSelector';
export * from './useChatInputWidget';
export * from './useChatWidget';
export * from './useDoorbellWidget';
@@ -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<CommandDefinition[]>(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>(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 };
};