Merge branch 'main' into Dev

This commit is contained in:
DuckieTM
2026-06-04 10:45:59 +02:00
committed by GitHub
11 changed files with 409 additions and 97 deletions
+28 -48
View File
@@ -72,61 +72,41 @@ jobs:
VAR_REPO: ${{ vars.RENDERER_REPO }} VAR_REPO: ${{ vars.RENDERER_REPO }}
VAR_REF: ${{ vars.RENDERER_REF }} VAR_REF: ${{ vars.RENDERER_REF }}
run: | run: |
REPO="${{ github.event.inputs.renderer_repo }}" # Branch-aware auto pairing — the default when neither a
REF="${{ github.event.inputs.renderer_ref }}" # 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 AUTO_REPO="${UPSTREAM_RENDERER_REPO}"
# For PRs we usually pair against the base ref, but the HK case "$CTX" in
# PR specifically needs to pair against its OWN head ref — main) AUTO_REF="main" ;;
# the renderer companion PR is named identically *) AUTO_REF="Dev" ;;
# (`feat/housekeeping-packets`) and lives on the same fork. esac
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
case "$CTX" in # Precedence (most specific wins): dispatch input → repo
main) # variable → branch-aware auto default. The auto default is
AUTO_REPO="duckietm/Nitro_Render_V3" # the final fallback so a Dev/feat build never silently pairs
AUTO_REF="main" # against a renderer that's missing its companion exports.
;;
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.
REPO="$IN_REPO" REPO="$IN_REPO"
[ -z "$REPO" ] && REPO="$VAR_REPO" [ -z "$REPO" ] && REPO="$VAR_REPO"
[ -z "$REPO" ] && REPO="$UPSTREAM_RENDERER_REPO" [ -z "$REPO" ] && REPO="$AUTO_REPO"
REF="$IN_REF" REF="$IN_REF"
[ -z "$REF" ] && REF="$VAR_REF" [ -z "$REF" ] && REF="$VAR_REF"
[ -z "$REF" ] && REF="$DEFAULT_REF" [ -z "$REF" ] && REF="$AUTO_REF"
echo "repo=$REPO" >> "$GITHUB_OUTPUT" echo "repo=$REPO" >> "$GITHUB_OUTPUT"
echo "ref=$REF" >> "$GITHUB_OUTPUT" echo "ref=$REF" >> "$GITHUB_OUTPUT"
+67
View File
@@ -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
+3
View File
@@ -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 = '';
@@ -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>
); );
@@ -148,6 +148,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 =>
@@ -262,7 +279,7 @@ export const ChatInputView: FC<{}> = props =>
if(selected) if(selected)
{ {
event.preventDefault(); event.preventDefault();
setChatValue(':' + selected.key + ' '); setChatInputValue(':' + selected.key + ' ');
return; return;
} }
break; break;
@@ -344,6 +361,9 @@ export const ChatInputView: FC<{}> = props =>
{ {
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;
@@ -430,7 +450,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 };
}; };