diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3a8cd0..fd8069b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,61 +72,41 @@ jobs: VAR_REPO: ${{ vars.RENDERER_REPO }} VAR_REF: ${{ vars.RENDERER_REF }} run: | - REPO="${{ github.event.inputs.renderer_repo }}" - REF="${{ github.event.inputs.renderer_ref }}" + # Branch-aware auto pairing — the default when neither a + # dispatch input nor a repo variable is supplied. + # + # Everything (including the custom features — rare values, + # fortune wheel, soundboard) now lives on duckietm's own + # `main` / `Dev` branches, so the renderer always pairs + # against UPSTREAM_RENDERER_REPO: `main` when the client build + # context is `main`, otherwise `Dev`. For PRs the context is + # the base ref. + case "${GITHUB_EVENT_NAME}" in + pull_request) + CTX="${GITHUB_BASE_REF}" + ;; + *) + CTX="${GITHUB_REF_NAME}" + ;; + esac - if [ -z "$REPO" ] || [ -z "$REF" ]; then - # For PRs we usually pair against the base ref, but the HK - # PR specifically needs to pair against its OWN head ref — - # the renderer companion PR is named identically - # (`feat/housekeeping-packets`) and lives on the same fork. - case "${GITHUB_EVENT_NAME}" in - pull_request) - if [ "${GITHUB_HEAD_REF}" = "feat/housekeeping-panel" ]; then - CTX="${GITHUB_HEAD_REF}" - else - CTX="${GITHUB_BASE_REF}" - fi - ;; - *) - CTX="${GITHUB_REF_NAME}" - ;; - esac + AUTO_REPO="${UPSTREAM_RENDERER_REPO}" + case "$CTX" in + main) AUTO_REF="main" ;; + *) AUTO_REF="Dev" ;; + esac - case "$CTX" in - main) - AUTO_REPO="duckietm/Nitro_Render_V3" - AUTO_REF="main" - ;; - Dev) - # The client `Dev` branch carries the custom features - # (rare values, fortune wheel, soundboard); they live on - # the matching renderer fork branch, not upstream. - AUTO_REPO="medievalshell/Nitro_Render_V3" - AUTO_REF="dev" - ;; - feat/housekeeping-panel) - AUTO_REPO="simoleo89/Nitro_Render_V3" - AUTO_REF="feat/housekeeping-packets" - ;; - *) - AUTO_REPO="duckietm/Nitro_Render_V3" - AUTO_REF="Dev" - ;; - esac - - [ -z "$REPO" ] && REPO="$AUTO_REPO" - [ -z "$REF" ] && REF="$AUTO_REF" - fi - - # Precedence: dispatch input → repo variable → upstream default. + # Precedence (most specific wins): dispatch input → repo + # variable → branch-aware auto default. The auto default is + # the final fallback so a Dev/feat build never silently pairs + # against a renderer that's missing its companion exports. REPO="$IN_REPO" [ -z "$REPO" ] && REPO="$VAR_REPO" - [ -z "$REPO" ] && REPO="$UPSTREAM_RENDERER_REPO" + [ -z "$REPO" ] && REPO="$AUTO_REPO" REF="$IN_REF" [ -z "$REF" ] && REF="$VAR_REF" - [ -z "$REF" ] && REF="$DEFAULT_REF" + [ -z "$REF" ] && REF="$AUTO_REF" echo "repo=$REPO" >> "$GITHUB_OUTPUT" echo "ref=$REF" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/sync-fork.yml b/.github/workflows/sync-fork.yml new file mode 100644 index 0000000..83d1ea1 --- /dev/null +++ b/.github/workflows/sync-fork.yml @@ -0,0 +1,67 @@ +name: Safe Sync - Nitro-V3 + +on: + schedule: + # GitHub non offre trigger cross-repo: non possiamo "ascoltare" i push + # sull'upstream senza esserne collaboratori. Per avvicinarci a un + # "automatico quando cambia" facciamo polling frequente: ogni run fa + # fetch dell'upstream e lavora SOLO sui branch effettivamente avanzati + # (guard "skip se invariato" sotto). Alza/abbassa la frequenza qui. + # NB: i cron possono partire con qualche minuto di ritardo e GitHub li + # disabilita dopo 60 giorni di inattività del repo. + - cron: '*/30 * * * *' # ogni 30 minuti + workflow_dispatch: # avvio manuale dalla scheda Actions + +concurrency: + group: safe-sync + cancel-in-progress: false + +jobs: + sync-safe: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout Fork + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git Credentials + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch and Merge Upstream + run: | + set -euo pipefail + + git remote add upstream https://github.com/duckietm/Nitro-V3.git 2>/dev/null || \ + git remote set-url upstream https://github.com/duckietm/Nitro-V3.git + git fetch upstream --prune + + for branch in $(git branch -r | grep 'upstream/' | grep -v 'HEAD'); do + local_branch=${branch#upstream/} + echo "::group::$local_branch" + + git checkout "$local_branch" 2>/dev/null || \ + git checkout -b "$local_branch" "upstream/$local_branch" + + upstream_sha=$(git rev-parse "upstream/$local_branch") + if git merge-base --is-ancestor "$upstream_sha" HEAD; then + echo "Nessun cambiamento per $local_branch, salto." + echo "::endgroup::" + continue + fi + + echo "Novità rilevate su $local_branch, provo il merge..." + if git merge "upstream/$local_branch" --no-edit; then + echo "Merge ok per $local_branch. Invio gli aggiornamenti..." + git push origin "$local_branch" + else + echo "Conflitto di merge su $local_branch! Il tuo lavoro è al sicuro: salto il push e annullo il merge." + git merge --abort + fi + echo "::endgroup::" + done diff --git a/src/App.tsx b/src/App.tsx index 4b799ef..babffe1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { LoginView } from './components/login/LoginView'; import { MainView } from './components/MainView'; import { ReconnectView } from './components/reconnect/ReconnectView'; import { useMessageEvent, useNitroEvent } from './hooks'; +import { ensureChatCommandListener } from './hooks/rooms/widgets/useChatCommandSelector'; NitroVersion.UI_VERSION = GetUIVersion(); @@ -562,7 +563,9 @@ export const App: FC<{}> = props => bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...')); await GetRoomEngine().init(); bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...')); + ensureChatCommandListener(); await GetCommunication().init(); + ensureChatCommandListener(); bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...')); })(); } diff --git a/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts b/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts index 9352372..aea7193 100644 --- a/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts +++ b/src/api/room/events/RoomWidgetUpdateChatInputContentEvent.ts @@ -5,6 +5,7 @@ export class RoomWidgetUpdateChatInputContentEvent extends RoomWidgetUpdateEvent public static CHAT_INPUT_CONTENT: string = 'RWUCICE_CHAT_INPUT_CONTENT'; public static WHISPER: string = 'whisper'; public static SHOUT: string = 'shout'; + public static TEXT: string = 'text'; private _chatMode: string = ''; private _userName: string = ''; diff --git a/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx b/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx index d9905e6..14c289d 100644 --- a/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx +++ b/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx @@ -1,5 +1,5 @@ -import { FC, useState } from 'react'; -import { LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl } from '../../../../api'; +import { FC, useMemo, useState } from 'react'; +import { DispatchUiEvent, LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl, RoomWidgetUpdateChatInputContentEvent } from '../../../../api'; import { Button, Column, Flex, LayoutNotificationAlertView, LayoutNotificationAlertViewProps } from '../../../../common'; interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps @@ -7,11 +7,57 @@ interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewP item: NotificationAlertItem; } +const COMMAND_LINE_PATTERN = /^\s*:[\w.-]+(?:\s.*)?$/; + +interface CommandTemplateEntry +{ + command: string; + description: string; +} + export const NotificationDefaultAlertView: FC = 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(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 = () => { OpenUrl(item.clickUrl); @@ -19,10 +65,18 @@ export const NotificationDefaultAlertView: FC onClose(); }; + const copyCommandToChatInput = (command: string) => + { + const chatValue = command.endsWith(' ') ? command : `${ command } `; + + DispatchUiEvent(new RoomWidgetUpdateChatInputContentEvent(RoomWidgetUpdateChatInputContentEvent.TEXT, chatValue)); + }; + const hasFrank = (item.alertType === NotificationAlertType.DEFAULT); + const alertClassNames = hasCommandTemplate ? [ ...classNames, 'nitro-alert-command-list' ] : classNames; return ( - + { hasFrank && !item.imageUrl &&
} { item.imageUrl && !imageFailed && { @@ -30,7 +84,16 @@ export const NotificationDefaultAlertView: FC setImageFailed(true); } } /> }
- { (item.messages.length > 0) && item.messages.map((message, index) => + { hasCommandTemplate &&
+ { commandTemplateContent.intro.map((text, index) => +
{ text }
) } + { commandTemplateContent.commands.map((entry, index) => + ) } +
} + { !hasCommandTemplate && (item.messages.length > 0) && item.messages.map((message, index) => { const htmlText = message.replace(/\r\n|\r|\n/g, '
'); diff --git a/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx index 5eae3d2..d3e0eae 100644 --- a/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx @@ -1,9 +1,10 @@ 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 { - commands: CommandDefinition[]; + commands: RankedCommandDefinition[]; selectedIndex: number; onSelect: (command: CommandDefinition) => void; onHover: (index: number) => void; @@ -24,17 +25,18 @@ export const ChatInputCommandSelectorView: FC }, [ selectedIndex ]); return ( -
+
{ commands.map((cmd, index) => ( -
onSelect(cmd) } onMouseEnter={ () => onHover(index) } > - :{ cmd.key } - { cmd.description } -
+ :{ 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 9aa9491..9bfc9b6 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -8,7 +8,7 @@ import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView'; import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView'; import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView'; -export const ChatInputView: FC<{}> = props => +export const ChatInputView: FC = () => { const [ chatValue, setChatValue ] = useState(''); 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 ]); + 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(() => { setChatValue(prevValue => @@ -157,7 +174,7 @@ export const ChatInputView: FC<{}> = props => if(selected) { event.preventDefault(); - setChatValue(':' + selected.key + ' '); + setChatInputValue(':' + selected.key + ' '); return; } break; @@ -194,12 +211,15 @@ export const ChatInputView: FC<{}> = props => 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.CHAT_INPUT_CONTENT, event => { switch(event.chatMode) { + case RoomWidgetUpdateChatInputContentEvent.TEXT: + setChatInputValue(event.userName); + return; case RoomWidgetUpdateChatInputContentEvent.WHISPER: { setChatValue(`${ chatModeIdWhisper } ${ event.userName } `); return; @@ -286,7 +306,7 @@ export const ChatInputView: FC<{}> = props => selectedIndex={ selectedIndex } onSelect={ (cmd) => { - setChatValue(':' + cmd.key + ' '); inputRef.current?.focus(); + setChatInputValue(':' + cmd.key + ' '); } } onHover={ setSelectedIndex } /> } diff --git a/src/css/notification/NotificationCenterView.css b/src/css/notification/NotificationCenterView.css index c24cb67..2d43f52 100644 --- a/src/css/notification/NotificationCenterView.css +++ b/src/css/notification/NotificationCenterView.css @@ -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 { width: 370px; .notification-text { @@ -390,4 +461,4 @@ position: relative; background-image: url("@/assets/images/notifications/nitro_v3.png"); background-repeat: no-repeat; -} \ No newline at end of file +} diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.helpers.test.ts b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.test.ts new file mode 100644 index 0000000..9daa747 --- /dev/null +++ b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.test.ts @@ -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); + }); +}); diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.helpers.ts b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.ts new file mode 100644 index 0000000..b9bc98f --- /dev/null +++ b/src/hooks/rooms/widgets/useChatCommandSelector.helpers.ts @@ -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(); + + 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 })); +}; diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.ts b/src/hooks/rooms/widgets/useChatCommandSelector.ts index c999dec..55785a2 100644 --- a/src/hooks/rooms/widgets/useChatCommandSelector.ts +++ b/src/hooks/rooms/widgets/useChatCommandSelector.ts @@ -3,6 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { CommandDefinition, LocalizeText } 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. The // `descriptionKey` is a LocalizeText slot resolved at merge time so @@ -62,7 +65,7 @@ const useChatCommandStore = createNitroStore()((set) => ({ markListenerRegistered: () => set({ isListenerRegistered: true }) })); -const ensureGlobalListener = (): void => +export const ensureChatCommandListener = (): void => { 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 // 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(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 @@ -123,61 +126,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 }; };