mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge branch 'main' into Dev
This commit is contained in:
+18
-38
@@ -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 }}"
|
||||
|
||||
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.
|
||||
# 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)
|
||||
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_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"
|
||||
;;
|
||||
main) AUTO_REF="main" ;;
|
||||
*) 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"
|
||||
|
||||
@@ -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
|
||||
@@ -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...'));
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
+68
-5
@@ -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<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 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<NotificationDefaultAlertViewProps>
|
||||
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 (
|
||||
<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">
|
||||
{ hasFrank && !item.imageUrl && <div className="notification-frank shrink-0" /> }
|
||||
{ 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);
|
||||
} } /> }
|
||||
<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 />');
|
||||
|
||||
|
||||
@@ -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<ChatInputCommandSelectorViewProps>
|
||||
}, [ selectedIndex ]);
|
||||
|
||||
return (
|
||||
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
|
||||
<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) => (
|
||||
<div
|
||||
<button
|
||||
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) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
<span className="font-bold">:{ cmd.key }</span>
|
||||
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
|
||||
</div>
|
||||
<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={ `min-w-0 flex-1 truncate text-[12px] ${ index === selectedIndex ? 'text-white/85' : 'text-[#525252]' }` }>{ cmd.description }</span>
|
||||
</button>
|
||||
)) }
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -148,6 +148,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 =>
|
||||
@@ -262,7 +279,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
if(selected)
|
||||
{
|
||||
event.preventDefault();
|
||||
setChatValue(':' + selected.key + ' ');
|
||||
setChatInputValue(':' + selected.key + ' ');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -344,6 +361,9 @@ export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
switch(event.chatMode)
|
||||
{
|
||||
case RoomWidgetUpdateChatInputContentEvent.TEXT:
|
||||
setChatInputValue(event.userName);
|
||||
return;
|
||||
case RoomWidgetUpdateChatInputContentEvent.WHISPER: {
|
||||
setChatValue(`${ chatModeIdWhisper } ${ event.userName } `);
|
||||
return;
|
||||
@@ -430,7 +450,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
selectedIndex={ selectedIndex }
|
||||
onSelect={ (cmd) =>
|
||||
{
|
||||
setChatValue(':' + cmd.key + ' '); inputRef.current?.focus();
|
||||
setChatInputValue(':' + cmd.key + ' ');
|
||||
} }
|
||||
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 {
|
||||
width: 370px;
|
||||
.notification-text {
|
||||
|
||||
@@ -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 { 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<ChatCommandStore>()((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<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
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user