mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(chat): improve command autocomplete and command alerts
This commit is contained in:
@@ -7,6 +7,7 @@ import { LoginView } from './components/login/LoginView';
|
|||||||
import { MainView } from './components/MainView';
|
import { MainView } from './components/MainView';
|
||||||
import { ReconnectView } from './components/reconnect/ReconnectView';
|
import { ReconnectView } from './components/reconnect/ReconnectView';
|
||||||
import { useMessageEvent, useNitroEvent } from './hooks';
|
import { useMessageEvent, useNitroEvent } from './hooks';
|
||||||
|
import { ensureChatCommandListener } from './hooks/rooms/widgets/useChatCommandSelector';
|
||||||
|
|
||||||
NitroVersion.UI_VERSION = GetUIVersion();
|
NitroVersion.UI_VERSION = GetUIVersion();
|
||||||
|
|
||||||
@@ -562,7 +563,9 @@ export const App: FC<{}> = props =>
|
|||||||
bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...'));
|
bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...'));
|
||||||
await GetRoomEngine().init();
|
await GetRoomEngine().init();
|
||||||
bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...'));
|
bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...'));
|
||||||
|
ensureChatCommandListener();
|
||||||
await GetCommunication().init();
|
await GetCommunication().init();
|
||||||
|
ensureChatCommandListener();
|
||||||
bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...'));
|
bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...'));
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export class RoomWidgetUpdateChatInputContentEvent extends RoomWidgetUpdateEvent
|
|||||||
public static CHAT_INPUT_CONTENT: string = 'RWUCICE_CHAT_INPUT_CONTENT';
|
public static CHAT_INPUT_CONTENT: string = 'RWUCICE_CHAT_INPUT_CONTENT';
|
||||||
public static WHISPER: string = 'whisper';
|
public static WHISPER: string = 'whisper';
|
||||||
public static SHOUT: string = 'shout';
|
public static SHOUT: string = 'shout';
|
||||||
|
public static TEXT: string = 'text';
|
||||||
|
|
||||||
private _chatMode: string = '';
|
private _chatMode: string = '';
|
||||||
private _userName: string = '';
|
private _userName: string = '';
|
||||||
|
|||||||
+68
-5
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useState } from 'react';
|
import { FC, useMemo, useState } from 'react';
|
||||||
import { LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl } from '../../../../api';
|
import { DispatchUiEvent, LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
||||||
import { Button, Column, Flex, LayoutNotificationAlertView, LayoutNotificationAlertViewProps } from '../../../../common';
|
import { Button, Column, Flex, LayoutNotificationAlertView, LayoutNotificationAlertViewProps } from '../../../../common';
|
||||||
|
|
||||||
interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps
|
interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps
|
||||||
@@ -7,11 +7,57 @@ interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewP
|
|||||||
item: NotificationAlertItem;
|
item: NotificationAlertItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMAND_LINE_PATTERN = /^\s*:[\w.-]+(?:\s.*)?$/;
|
||||||
|
|
||||||
|
interface CommandTemplateEntry
|
||||||
|
{
|
||||||
|
command: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const NotificationDefaultAlertView: FC<NotificationDefaultAlertViewProps> = props =>
|
export const NotificationDefaultAlertView: FC<NotificationDefaultAlertViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { item = null, title = ((props.item && props.item.title) || ''), onClose = null, ...rest } = props;
|
const { item = null, title = ((props.item && props.item.title) || ''), onClose = null, classNames = [], ...rest } = props;
|
||||||
const [ imageFailed, setImageFailed ] = useState<boolean>(false);
|
const [ imageFailed, setImageFailed ] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const alertLines = useMemo(() => item.messages.flatMap(message => message.split(/\r\n|\r|\n/g)), [ item.messages ]);
|
||||||
|
const hasCommandTemplate = useMemo(() =>
|
||||||
|
{
|
||||||
|
const commandLines = alertLines.filter(line => COMMAND_LINE_PATTERN.test(line));
|
||||||
|
|
||||||
|
return commandLines.length >= 4 || alertLines.some(line => /^Your Commands\(\d+\):?/i.test(line.trim()));
|
||||||
|
}, [ alertLines ]);
|
||||||
|
const commandTemplateContent = useMemo(() =>
|
||||||
|
{
|
||||||
|
const intro: string[] = [];
|
||||||
|
const commands: CommandTemplateEntry[] = [];
|
||||||
|
|
||||||
|
for(const rawLine of alertLines)
|
||||||
|
{
|
||||||
|
const text = rawLine.trim();
|
||||||
|
|
||||||
|
if(!text.length) continue;
|
||||||
|
|
||||||
|
if(COMMAND_LINE_PATTERN.test(text))
|
||||||
|
{
|
||||||
|
commands.push({ command: text, description: '' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(commands.length)
|
||||||
|
{
|
||||||
|
const lastCommand = commands[commands.length - 1];
|
||||||
|
|
||||||
|
lastCommand.description = lastCommand.description ? `${ lastCommand.description } ${ text }` : text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
intro.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { intro, commands };
|
||||||
|
}, [ alertLines ]);
|
||||||
|
|
||||||
const visitUrl = () =>
|
const visitUrl = () =>
|
||||||
{
|
{
|
||||||
OpenUrl(item.clickUrl);
|
OpenUrl(item.clickUrl);
|
||||||
@@ -19,10 +65,18 @@ export const NotificationDefaultAlertView: FC<NotificationDefaultAlertViewProps>
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyCommandToChatInput = (command: string) =>
|
||||||
|
{
|
||||||
|
const chatValue = command.endsWith(' ') ? command : `${ command } `;
|
||||||
|
|
||||||
|
DispatchUiEvent(new RoomWidgetUpdateChatInputContentEvent(RoomWidgetUpdateChatInputContentEvent.TEXT, chatValue));
|
||||||
|
};
|
||||||
|
|
||||||
const hasFrank = (item.alertType === NotificationAlertType.DEFAULT);
|
const hasFrank = (item.alertType === NotificationAlertType.DEFAULT);
|
||||||
|
const alertClassNames = hasCommandTemplate ? [ ...classNames, 'nitro-alert-command-list' ] : classNames;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutNotificationAlertView title={ title } onClose={ onClose } { ...rest } type={ hasFrank ? NotificationAlertType.DEFAULT : item.alertType }>
|
<LayoutNotificationAlertView title={ title } onClose={ onClose } classNames={ alertClassNames } { ...rest } type={ hasFrank ? NotificationAlertType.DEFAULT : item.alertType }>
|
||||||
<Flex fullHeight gap={ hasFrank || (item.imageUrl && !imageFailed) ? 2 : 0 } overflow="auto">
|
<Flex fullHeight gap={ hasFrank || (item.imageUrl && !imageFailed) ? 2 : 0 } overflow="auto">
|
||||||
{ hasFrank && !item.imageUrl && <div className="notification-frank shrink-0" /> }
|
{ hasFrank && !item.imageUrl && <div className="notification-frank shrink-0" /> }
|
||||||
{ item.imageUrl && !imageFailed && <img alt={ item.title } className="align-self-baseline" src={ item.imageUrl } onError={ () =>
|
{ item.imageUrl && !imageFailed && <img alt={ item.title } className="align-self-baseline" src={ item.imageUrl } onError={ () =>
|
||||||
@@ -30,7 +84,16 @@ export const NotificationDefaultAlertView: FC<NotificationDefaultAlertViewProps>
|
|||||||
setImageFailed(true);
|
setImageFailed(true);
|
||||||
} } /> }
|
} } /> }
|
||||||
<div className={ [ 'notification-text overflow-y-auto flex flex-col w-full', (item.clickUrl && !hasFrank) ? 'justify-center' : '' ].join(' ') }>
|
<div className={ [ 'notification-text overflow-y-auto flex flex-col w-full', (item.clickUrl && !hasFrank) ? 'justify-center' : '' ].join(' ') }>
|
||||||
{ (item.messages.length > 0) && item.messages.map((message, index) =>
|
{ hasCommandTemplate && <div className="notification-command-template">
|
||||||
|
{ commandTemplateContent.intro.map((text, index) =>
|
||||||
|
<div key={ index } className={ index === 0 ? 'notification-command-heading' : 'notification-command-copy' }>{ text }</div>) }
|
||||||
|
{ commandTemplateContent.commands.map((entry, index) =>
|
||||||
|
<button key={ `${ entry.command }-${ index }` } className="notification-command-row" type="button" onClick={ () => copyCommandToChatInput(entry.command) }>
|
||||||
|
<span className="notification-command-name">{ entry.command }</span>
|
||||||
|
{ entry.description && <span className="notification-command-description">{ entry.description }</span> }
|
||||||
|
</button>) }
|
||||||
|
</div> }
|
||||||
|
{ !hasCommandTemplate && (item.messages.length > 0) && item.messages.map((message, index) =>
|
||||||
{
|
{
|
||||||
const htmlText = message.replace(/\r\n|\r|\n/g, '<br />');
|
const htmlText = message.replace(/\r\n|\r|\n/g, '<br />');
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FC, useEffect, useRef } from 'react';
|
import { FC, useEffect, useRef } from 'react';
|
||||||
import { CommandDefinition } from '../../../../api';
|
import type { CommandDefinition } from '../../../../api';
|
||||||
|
import type { RankedCommandDefinition } from '../../../../hooks/rooms/widgets/useChatCommandSelector.helpers';
|
||||||
|
|
||||||
interface ChatInputCommandSelectorViewProps
|
interface ChatInputCommandSelectorViewProps
|
||||||
{
|
{
|
||||||
commands: CommandDefinition[];
|
commands: RankedCommandDefinition[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (command: CommandDefinition) => void;
|
onSelect: (command: CommandDefinition) => void;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
@@ -24,17 +25,18 @@ export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps>
|
|||||||
}, [ selectedIndex ]);
|
}, [ selectedIndex ]);
|
||||||
|
|
||||||
return (
|
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]">
|
<div ref={ listRef } className="absolute bottom-full left-0 z-[1070] max-h-[238px] w-full overflow-y-auto rounded-t-[8px] border-2 border-b-0 border-black bg-[#f2f2eb] shadow-[0_-4px_14px_rgba(0,0,0,0.22)]">
|
||||||
{ commands.map((cmd, index) => (
|
{ commands.map((cmd, index) => (
|
||||||
<div
|
<button
|
||||||
key={ cmd.key }
|
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' }` }
|
className={ `flex min-h-[34px] w-full cursor-pointer items-center gap-2 border-b border-[#c6c6bd] px-3 py-1.5 text-left last:border-b-0 ${ index === selectedIndex ? 'bg-[#255d72] text-white' : 'text-black hover:bg-[#dceaf0]' }` }
|
||||||
|
type="button"
|
||||||
onClick={ () => onSelect(cmd) }
|
onClick={ () => onSelect(cmd) }
|
||||||
onMouseEnter={ () => onHover(index) }
|
onMouseEnter={ () => onHover(index) }
|
||||||
>
|
>
|
||||||
<span className="font-bold">:{ cmd.key }</span>
|
<span className={ `shrink-0 rounded-[4px] border px-1.5 py-[1px] font-bold ${ index === selectedIndex ? 'border-white/60 bg-white/15' : 'border-[#8ca6b1] bg-white text-[#123b4c]' }` }>:{ cmd.key }</span>
|
||||||
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
|
<span className={ `min-w-0 flex-1 truncate text-[12px] ${ index === selectedIndex ? 'text-white/85' : 'text-[#525252]' }` }>{ cmd.description }</span>
|
||||||
</div>
|
</button>
|
||||||
)) }
|
)) }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
|
|||||||
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||||
|
|
||||||
export const ChatInputView: FC<{}> = props =>
|
export const ChatInputView: FC = () =>
|
||||||
{
|
{
|
||||||
const [ chatValue, setChatValue ] = useState<string>('');
|
const [ chatValue, setChatValue ] = useState<string>('');
|
||||||
const { chatStyleId = 0, updateChatStyleId = null } = useSessionInfo();
|
const { chatStyleId = 0, updateChatStyleId = null } = useSessionInfo();
|
||||||
@@ -42,6 +42,23 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
inputRef.current.setSelectionRange((inputRef.current.value.length * 2), (inputRef.current.value.length * 2));
|
inputRef.current.setSelectionRange((inputRef.current.value.length * 2), (inputRef.current.value.length * 2));
|
||||||
}, [ inputRef ]);
|
}, [ inputRef ]);
|
||||||
|
|
||||||
|
const setChatInputValue = useCallback((value: string, markTyping: boolean = true) =>
|
||||||
|
{
|
||||||
|
setChatValue(value);
|
||||||
|
|
||||||
|
if(markTyping)
|
||||||
|
{
|
||||||
|
setIsTyping(!!value.length);
|
||||||
|
setIsIdle(!!value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
{
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.setSelectionRange(value.length, value.length);
|
||||||
|
});
|
||||||
|
}, [ setIsTyping, setIsIdle ]);
|
||||||
|
|
||||||
const checkSpecialKeywordForInput = useCallback(() =>
|
const checkSpecialKeywordForInput = useCallback(() =>
|
||||||
{
|
{
|
||||||
setChatValue(prevValue =>
|
setChatValue(prevValue =>
|
||||||
@@ -157,7 +174,7 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
if(selected)
|
if(selected)
|
||||||
{
|
{
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setChatValue(':' + selected.key + ' ');
|
setChatInputValue(':' + selected.key + ' ');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -194,12 +211,15 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector ]);
|
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, setChatInputValue, closeCommandSelector ]);
|
||||||
|
|
||||||
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||||
{
|
{
|
||||||
switch(event.chatMode)
|
switch(event.chatMode)
|
||||||
{
|
{
|
||||||
|
case RoomWidgetUpdateChatInputContentEvent.TEXT:
|
||||||
|
setChatInputValue(event.userName);
|
||||||
|
return;
|
||||||
case RoomWidgetUpdateChatInputContentEvent.WHISPER: {
|
case RoomWidgetUpdateChatInputContentEvent.WHISPER: {
|
||||||
setChatValue(`${ chatModeIdWhisper } ${ event.userName } `);
|
setChatValue(`${ chatModeIdWhisper } ${ event.userName } `);
|
||||||
return;
|
return;
|
||||||
@@ -286,7 +306,7 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
selectedIndex={ selectedIndex }
|
selectedIndex={ selectedIndex }
|
||||||
onSelect={ (cmd) =>
|
onSelect={ (cmd) =>
|
||||||
{
|
{
|
||||||
setChatValue(':' + cmd.key + ' '); inputRef.current?.focus();
|
setChatInputValue(':' + cmd.key + ' ');
|
||||||
} }
|
} }
|
||||||
onHover={ setSelectedIndex }
|
onHover={ setSelectedIndex }
|
||||||
/> }
|
/> }
|
||||||
|
|||||||
@@ -20,6 +20,77 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.nitro-alert-command-list {
|
||||||
|
width: min(430px, calc(100vw - 18px));
|
||||||
|
min-height: 210px;
|
||||||
|
max-height: min(520px, calc(100vh - 24px));
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 9px 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 3px;
|
||||||
|
font-family: Ubuntu, sans-serif;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-template {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-heading {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101010;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-copy {
|
||||||
|
color: #262626;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
color: #123b4c;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #dceaf0 100%);
|
||||||
|
border: 1px solid #8ca6b1;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-row:hover {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #cfe2eb 100%);
|
||||||
|
border-color: #4f879b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #123b4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-description {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #3d4a50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-command-spacer {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.nitro-alert-credits {
|
&.nitro-alert-credits {
|
||||||
width: 370px;
|
width: 370px;
|
||||||
.notification-text {
|
.notification-text {
|
||||||
@@ -390,4 +461,4 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
background-image: url("@/assets/images/notifications/nitro_v3.png");
|
background-image: url("@/assets/images/notifications/nitro_v3.png");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CommandDefinition } from '../../../api';
|
||||||
|
import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers';
|
||||||
|
|
||||||
|
const commands: CommandDefinition[] = [
|
||||||
|
{ key: 'commands', description: 'Mostra tutti i comandi' },
|
||||||
|
{ key: 'empty', description: 'Svuota la stanza' },
|
||||||
|
{ key: 'emptybots', description: 'Svuota inventario bot' },
|
||||||
|
{ key: 'xempty', description: 'Comando di test' },
|
||||||
|
{ key: 'ejectall', description: 'Espelli tutti i furni' },
|
||||||
|
{ key: 'togglefps', description: 'Mostra o nasconde FPS' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('getChatCommandQuery', () =>
|
||||||
|
{
|
||||||
|
it('returns null when the input is not a command prefix', () =>
|
||||||
|
{
|
||||||
|
expect(getChatCommandQuery('ciao')).toBeNull();
|
||||||
|
expect(getChatCommandQuery(':empty ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the normalized command query', () =>
|
||||||
|
{
|
||||||
|
expect(getChatCommandQuery(':Em')).toBe('em');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRankedCommandSuggestions', () =>
|
||||||
|
{
|
||||||
|
it('ranks prefix matches before contains and description matches', () =>
|
||||||
|
{
|
||||||
|
const result = getRankedCommandSuggestions(commands, 'em', 10);
|
||||||
|
|
||||||
|
expect(result.map(command => command.key)).toEqual([ 'empty', 'emptybots', 'xempty' ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches command descriptions when the key does not match', () =>
|
||||||
|
{
|
||||||
|
const result = getRankedCommandSuggestions(commands, 'furni', 10);
|
||||||
|
|
||||||
|
expect(result.map(command => command.key)).toEqual([ 'ejectall' ]);
|
||||||
|
expect(result[0].matchType).toBe('description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the visible suggestions', () =>
|
||||||
|
{
|
||||||
|
const result = getRankedCommandSuggestions(commands, '', 2);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { CommandDefinition } from '../../../api';
|
||||||
|
|
||||||
|
export interface RankedCommandDefinition extends CommandDefinition
|
||||||
|
{
|
||||||
|
matchType: 'prefix' | 'contains' | 'description' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const getChatCommandQuery = (chatValue: string): string | null =>
|
||||||
|
{
|
||||||
|
if(!chatValue.startsWith(':') || chatValue.includes(' ')) return null;
|
||||||
|
|
||||||
|
return normalize(chatValue.slice(1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommandScore = (command: CommandDefinition, query: string): { score: number; matchType: RankedCommandDefinition['matchType'] } | null =>
|
||||||
|
{
|
||||||
|
const key = normalize(command.key);
|
||||||
|
const description = normalize(command.description || '');
|
||||||
|
|
||||||
|
if(!query) return { score: 100 + key.length, matchType: 'all' };
|
||||||
|
if(key === query) return { score: 0, matchType: 'prefix' };
|
||||||
|
if(key.startsWith(query)) return { score: 10 + (key.length - query.length), matchType: 'prefix' };
|
||||||
|
if(key.includes(query)) return { score: 40 + key.indexOf(query), matchType: 'contains' };
|
||||||
|
if(description.includes(query)) return { score: 70 + description.indexOf(query), matchType: 'description' };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRankedCommandSuggestions = (commands: CommandDefinition[], query: string, limit: number): RankedCommandDefinition[] =>
|
||||||
|
{
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return commands
|
||||||
|
.map(command =>
|
||||||
|
{
|
||||||
|
const match = getCommandScore(command, query);
|
||||||
|
|
||||||
|
if(!match) return null;
|
||||||
|
|
||||||
|
return { command, score: match.score, matchType: match.matchType };
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { command: CommandDefinition; score: number; matchType: RankedCommandDefinition['matchType'] } => !!entry)
|
||||||
|
.sort((a, b) => (a.score - b.score) || a.command.key.localeCompare(b.command.key))
|
||||||
|
.filter(entry =>
|
||||||
|
{
|
||||||
|
const key = normalize(entry.command.key);
|
||||||
|
|
||||||
|
if(seen.has(key)) return false;
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(entry => ({ ...entry.command, matchType: entry.matchType }));
|
||||||
|
};
|
||||||
@@ -3,6 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { CommandDefinition, LocalizeText } from '../../../api';
|
import { CommandDefinition, LocalizeText } from '../../../api';
|
||||||
import { createNitroStore } from '../../../state/createNitroStore';
|
import { createNitroStore } from '../../../state/createNitroStore';
|
||||||
import { useMessageEvent } from '../../events';
|
import { useMessageEvent } from '../../events';
|
||||||
|
import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers';
|
||||||
|
|
||||||
|
const MAX_VISIBLE_COMMANDS = 8;
|
||||||
|
|
||||||
// Client-only commands are static; safe to keep at module scope. The
|
// Client-only commands are static; safe to keep at module scope. The
|
||||||
// `descriptionKey` is a LocalizeText slot resolved at merge time so
|
// `descriptionKey` is a LocalizeText slot resolved at merge time so
|
||||||
@@ -62,7 +65,7 @@ const useChatCommandStore = createNitroStore<ChatCommandStore>()((set) => ({
|
|||||||
markListenerRegistered: () => set({ isListenerRegistered: true })
|
markListenerRegistered: () => set({ isListenerRegistered: true })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ensureGlobalListener = (): void =>
|
export const ensureChatCommandListener = (): void =>
|
||||||
{
|
{
|
||||||
if(useChatCommandStore.getState().isListenerRegistered) return;
|
if(useChatCommandStore.getState().isListenerRegistered) return;
|
||||||
|
|
||||||
@@ -86,20 +89,20 @@ const ensureGlobalListener = (): void =>
|
|||||||
|
|
||||||
// Try once at module load so the server's response landing before any
|
// Try once at module load so the server's response landing before any
|
||||||
// React mount still hits the cache.
|
// React mount still hits the cache.
|
||||||
ensureGlobalListener();
|
ensureChatCommandListener();
|
||||||
|
|
||||||
export const useChatCommandSelector = (chatValue: string) =>
|
export const useChatCommandSelector = (chatValue: string) =>
|
||||||
{
|
{
|
||||||
const serverCommands = useChatCommandStore(s => s.serverCommands);
|
const serverCommands = useChatCommandStore(s => s.serverCommands);
|
||||||
const setServerCommands = useChatCommandStore(s => s.setServerCommands);
|
const setServerCommands = useChatCommandStore(s => s.setServerCommands);
|
||||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||||
const [ dismissed, setDismissed ] = useState(false);
|
const [ dismissedQuery, setDismissedQuery ] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
// Cover the case where the module-level registration failed
|
// Cover the case where the module-level registration failed
|
||||||
// because GetCommunication() wasn't ready at import time.
|
// because GetCommunication() wasn't ready at import time.
|
||||||
ensureGlobalListener();
|
ensureChatCommandListener();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Late updates (rank change, etc.) — go through the store so all
|
// Late updates (rank change, etc.) — go through the store so all
|
||||||
@@ -123,61 +126,55 @@ export const useChatCommandSelector = (chatValue: string) =>
|
|||||||
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
}, [ serverCommands ]);
|
}, [ serverCommands ]);
|
||||||
|
|
||||||
const filterText = useMemo(() =>
|
const filterText = useMemo(() => getChatCommandQuery(chatValue), [ chatValue ]);
|
||||||
{
|
|
||||||
if(!chatValue.startsWith(':') || chatValue.includes(' ')) return '';
|
|
||||||
|
|
||||||
return chatValue.slice(1).toLowerCase();
|
|
||||||
}, [ chatValue ]);
|
|
||||||
|
|
||||||
const filteredCommands = useMemo(() =>
|
const filteredCommands = useMemo(() =>
|
||||||
{
|
{
|
||||||
if(!filterText && !chatValue.startsWith(':')) return [];
|
if(filterText === null) return [];
|
||||||
|
|
||||||
return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText));
|
return getRankedCommandSuggestions(allCommands, filterText, MAX_VISIBLE_COMMANDS);
|
||||||
}, [ allCommands, filterText, chatValue ]);
|
}, [ allCommands, filterText ]);
|
||||||
|
|
||||||
const isVisible = useMemo(() =>
|
const isVisible = useMemo(() =>
|
||||||
{
|
{
|
||||||
return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed;
|
return filterText !== null && filteredCommands.length > 0 && dismissedQuery !== filterText;
|
||||||
}, [ chatValue, filteredCommands, dismissed ]);
|
}, [ filterText, filteredCommands, dismissedQuery ]);
|
||||||
|
|
||||||
|
const boundedSelectedIndex = useMemo(() =>
|
||||||
|
{
|
||||||
|
if(!filteredCommands.length) return 0;
|
||||||
|
|
||||||
|
return Math.min(selectedIndex, filteredCommands.length - 1);
|
||||||
|
}, [ filteredCommands.length, selectedIndex ]);
|
||||||
|
|
||||||
const moveUp = useCallback(() =>
|
const moveUp = useCallback(() =>
|
||||||
{
|
{
|
||||||
setSelectedIndex(prev => (prev <= 0 ? filteredCommands.length - 1 : prev - 1));
|
if(!filteredCommands.length) return;
|
||||||
|
|
||||||
|
setSelectedIndex(prev => ((prev <= 0 || prev >= filteredCommands.length) ? filteredCommands.length - 1 : prev - 1));
|
||||||
}, [ filteredCommands.length ]);
|
}, [ filteredCommands.length ]);
|
||||||
|
|
||||||
const moveDown = useCallback(() =>
|
const moveDown = useCallback(() =>
|
||||||
{
|
{
|
||||||
|
if(!filteredCommands.length) return;
|
||||||
|
|
||||||
setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1));
|
setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1));
|
||||||
}, [ filteredCommands.length ]);
|
}, [ filteredCommands.length ]);
|
||||||
|
|
||||||
const selectCurrent = useCallback((): CommandDefinition | null =>
|
const selectCurrent = useCallback((): CommandDefinition | null =>
|
||||||
{
|
{
|
||||||
if(selectedIndex >= 0 && selectedIndex < filteredCommands.length)
|
if(boundedSelectedIndex >= 0 && boundedSelectedIndex < filteredCommands.length)
|
||||||
{
|
{
|
||||||
return filteredCommands[selectedIndex];
|
return filteredCommands[boundedSelectedIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [ selectedIndex, filteredCommands ]);
|
}, [ boundedSelectedIndex, filteredCommands ]);
|
||||||
|
|
||||||
const close = useCallback(() =>
|
const close = useCallback(() =>
|
||||||
{
|
{
|
||||||
setDismissed(true);
|
setDismissedQuery(filterText);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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 ]);
|
}, [ filterText ]);
|
||||||
|
|
||||||
return { isVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
|
return { isVisible, filteredCommands, selectedIndex: boundedSelectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user