mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
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:
@@ -0,0 +1,5 @@
|
|||||||
|
export interface CommandDefinition
|
||||||
|
{
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export * from './AvatarInfoUser';
|
|||||||
export * from './AvatarInfoUtilities';
|
export * from './AvatarInfoUtilities';
|
||||||
export * from './BotSkillsEnum';
|
export * from './BotSkillsEnum';
|
||||||
export * from './ChatBubbleMessage';
|
export * from './ChatBubbleMessage';
|
||||||
|
export * from './CommandDefinition';
|
||||||
export * from './ChatBubbleUtilities';
|
export * from './ChatBubbleUtilities';
|
||||||
export * from './ChatMessageTypeEnum';
|
export * from './ChatMessageTypeEnum';
|
||||||
export * from './DimmerFurnitureWidgetPresetItem';
|
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 { createPortal } from 'react-dom';
|
||||||
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
||||||
import { Text } from '../../../../common';
|
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 { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
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 { selectedUsername = '', floodBlocked = false, floodBlockedSeconds = 0, setIsTyping = null, setIsIdle = null, sendChat = null } = useChatInputWidget();
|
||||||
const { roomSession = null } = useRoom();
|
const { roomSession = null } = useRoom();
|
||||||
const inputRef = useRef<HTMLInputElement>();
|
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 chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
|
||||||
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
|
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
|
||||||
@@ -133,6 +135,40 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
|
|
||||||
if(document.activeElement !== inputRef.current) setInputFocus();
|
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;
|
const value = (event.target as HTMLInputElement).value;
|
||||||
|
|
||||||
switch(event.key)
|
switch(event.key)
|
||||||
@@ -158,7 +194,7 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
return;
|
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 =>
|
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||||
{
|
{
|
||||||
@@ -243,7 +279,14 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
createPortal(
|
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">
|
<div className="flex-1 items-center input-sizer">
|
||||||
{ !floodBlocked &&
|
{ !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() } /> }
|
<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,5 +1,6 @@
|
|||||||
export * from './furniture';
|
export * from './furniture';
|
||||||
export * from './useAvatarInfoWidget';
|
export * from './useAvatarInfoWidget';
|
||||||
|
export * from './useChatCommandSelector';
|
||||||
export * from './useChatInputWidget';
|
export * from './useChatInputWidget';
|
||||||
export * from './useChatWidget';
|
export * from './useChatWidget';
|
||||||
export * from './useDoorbellWidget';
|
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 };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user