mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
feat(chat): improve command autocomplete and command alerts
This commit is contained in:
@@ -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 } from '../../../api';
|
||||
import { createNitroStore } from '../../../state/createNitroStore';
|
||||
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.
|
||||
const CLIENT_COMMANDS: CommandDefinition[] = [
|
||||
@@ -60,7 +63,7 @@ const useChatCommandStore = createNitroStore<ChatCommandStore>()((set) => ({
|
||||
markListenerRegistered: () => set({ isListenerRegistered: true })
|
||||
}));
|
||||
|
||||
const ensureGlobalListener = (): void =>
|
||||
export const ensureChatCommandListener = (): void =>
|
||||
{
|
||||
if(useChatCommandStore.getState().isListenerRegistered) return;
|
||||
|
||||
@@ -84,20 +87,20 @@ const ensureGlobalListener = (): void =>
|
||||
|
||||
// Try once at module load so the server's response landing before any
|
||||
// React mount still hits the cache.
|
||||
ensureGlobalListener();
|
||||
ensureChatCommandListener();
|
||||
|
||||
export const useChatCommandSelector = (chatValue: string) =>
|
||||
{
|
||||
const serverCommands = useChatCommandStore(s => s.serverCommands);
|
||||
const setServerCommands = useChatCommandStore(s => s.setServerCommands);
|
||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||
const [ dismissed, setDismissed ] = useState(false);
|
||||
const [ dismissedQuery, setDismissedQuery ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// Cover the case where the module-level registration failed
|
||||
// because GetCommunication() wasn't ready at import time.
|
||||
ensureGlobalListener();
|
||||
ensureChatCommandListener();
|
||||
}, []);
|
||||
|
||||
// Late updates (rank change, etc.) — go through the store so all
|
||||
@@ -120,61 +123,55 @@ export const useChatCommandSelector = (chatValue: string) =>
|
||||
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 filterText = useMemo(() => getChatCommandQuery(chatValue), [ chatValue ]);
|
||||
|
||||
const filteredCommands = useMemo(() =>
|
||||
{
|
||||
if(!filterText && !chatValue.startsWith(':')) return [];
|
||||
if(filterText === null) return [];
|
||||
|
||||
return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText));
|
||||
}, [ allCommands, filterText, chatValue ]);
|
||||
return getRankedCommandSuggestions(allCommands, filterText, MAX_VISIBLE_COMMANDS);
|
||||
}, [ allCommands, filterText ]);
|
||||
|
||||
const isVisible = useMemo(() =>
|
||||
{
|
||||
return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed;
|
||||
}, [ chatValue, filteredCommands, dismissed ]);
|
||||
return filterText !== null && filteredCommands.length > 0 && dismissedQuery !== filterText;
|
||||
}, [ filterText, filteredCommands, dismissedQuery ]);
|
||||
|
||||
const boundedSelectedIndex = useMemo(() =>
|
||||
{
|
||||
if(!filteredCommands.length) return 0;
|
||||
|
||||
return Math.min(selectedIndex, filteredCommands.length - 1);
|
||||
}, [ filteredCommands.length, selectedIndex ]);
|
||||
|
||||
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 ]);
|
||||
|
||||
const moveDown = useCallback(() =>
|
||||
{
|
||||
if(!filteredCommands.length) return;
|
||||
|
||||
setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1));
|
||||
}, [ filteredCommands.length ]);
|
||||
|
||||
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;
|
||||
}, [ selectedIndex, filteredCommands ]);
|
||||
}, [ boundedSelectedIndex, 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);
|
||||
setDismissedQuery(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