mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
@@ -48,7 +48,7 @@ import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView
|
||||
import { UserSettingsView } from './user-settings/UserSettingsView';
|
||||
import { WiredView } from './wired/WiredView';
|
||||
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
||||
import { MentionsView } from './mentions';
|
||||
import { MentionsView, MentionToastsView } from './mentions';
|
||||
|
||||
export const MainView: FC<{}> = props =>
|
||||
{
|
||||
@@ -242,6 +242,8 @@ export const MainView: FC<{}> = props =>
|
||||
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
||||
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
|
||||
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
|
||||
{ GetConfigurationValue<boolean>('mentions_ui.enabled', true) &&
|
||||
<MentionToastsView /> }
|
||||
<ExternalPluginLoader />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './MentionMessageView';
|
||||
export * from './MentionRowView';
|
||||
export * from './MentionsView';
|
||||
export * from './MentionToastsView';
|
||||
export * from './mentionsFormat';
|
||||
export * from './useMentionActions';
|
||||
|
||||
+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 />');
|
||||
|
||||
|
||||
@@ -7,13 +7,9 @@ export interface MentionSuggestion
|
||||
{
|
||||
key: string;
|
||||
kind: MentionSuggestionKind;
|
||||
/** Display name shown in the row (e.g. "DuckieTM" or "all"). */
|
||||
name: string;
|
||||
/** Token that's actually inserted into the chat input (without the @). */
|
||||
insertToken: string;
|
||||
/** Figure string for the avatar tile - only set for 'user' rows. */
|
||||
figure?: string;
|
||||
/** Optional sub-label, e.g. for "Staff Chat". */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -25,11 +21,6 @@ interface ChatInputMentionSelectorViewProps
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @-autocomplete popover. Suggestion list comes pre-filtered from the parent:
|
||||
* real users (RoomObjectUserType.USER = 1) only, never pets / bots / rentable
|
||||
* bots / monster plants, plus the configured broadcast aliases.
|
||||
*/
|
||||
export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps> = props =>
|
||||
{
|
||||
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
|
||||
|
||||
@@ -10,15 +10,9 @@ import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||
import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView';
|
||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||
|
||||
// RoomObjectUserType.AVATAR_TYPES: USER = 1, PET = 2, BOT = 3, RENTABLE_BOT = 4.
|
||||
// Only real users can be mentioned, so the @-autocomplete keeps just type 1.
|
||||
const USER_TYPE_REAL_USER = 1;
|
||||
const MAX_MENTION_SUGGESTIONS = 8;
|
||||
|
||||
// Broadcast alias categories. The actual alias strings live in
|
||||
// ui-config.json (mentions_ui.aliases.everyone / .friends / .room) so an
|
||||
// admin can edit them without a rebuild and keep them in sync with the
|
||||
// gameserver's mentions.*.aliases config keys.
|
||||
type MentionAliasScope = 'everyone' | 'friends' | 'room';
|
||||
|
||||
const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
||||
@@ -33,18 +27,12 @@ const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
|
||||
room: [ 'room', 'stanza' ]
|
||||
};
|
||||
|
||||
// Localization keys for the description shown next to each alias in the
|
||||
// picker. The actual translations live in UITexts (see
|
||||
// text/UITexts_*.json5.example) so admins can localize without a rebuild.
|
||||
const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions.alias.description.everyone',
|
||||
friends: 'mentions.alias.description.friends',
|
||||
room: 'mentions.alias.description.room'
|
||||
};
|
||||
|
||||
// Coerces a configured value to a clean string[] - tolerates a missing
|
||||
// config key, a non-array value, and non-string entries (so a typo in
|
||||
// ui-config.json can't crash the picker).
|
||||
const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
|
||||
{
|
||||
if(!Array.isArray(raw)) return fallback;
|
||||
@@ -71,13 +59,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const roomUserList = useRoomUserListSnapshot();
|
||||
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* Detect an open @-mention token at the input caret. Returns the active
|
||||
* query (text after @) plus the offsets we'll replace on selection, or
|
||||
* null when the caret is not inside an @-token. Triggers only when the
|
||||
* @ is at the start of the input or follows whitespace, so an email-like
|
||||
* "foo@bar" doesn't pop the picker.
|
||||
*/
|
||||
const mentionContext = useMemo(() =>
|
||||
{
|
||||
if(!chatValue) return null;
|
||||
@@ -88,19 +69,14 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const at = upToCaret.lastIndexOf('@');
|
||||
if(at < 0) return null;
|
||||
|
||||
// @ must be at start or follow whitespace.
|
||||
if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null;
|
||||
|
||||
const query = upToCaret.slice(at + 1);
|
||||
// Bail if the query already contains whitespace - the token has ended.
|
||||
if(/\s/.test(query)) return null;
|
||||
|
||||
return { atIndex: at, replaceFrom: at, replaceTo: caret, query };
|
||||
}, [ chatValue, commandSelectorVisible ]);
|
||||
|
||||
// Flattened, config-driven alias list. Each scope's aliases are loaded
|
||||
// from ui-config.json (with a typed fallback in case the key is missing
|
||||
// or corrupted) and stitched in with the per-scope description.
|
||||
const mentionAliases = useMemo<ReadonlyArray<{ key: string; scope: MentionAliasScope; description: string }>>(() =>
|
||||
{
|
||||
const out: { key: string; scope: MentionAliasScope; description: string }[] = [];
|
||||
@@ -117,8 +93,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
for(const key of list)
|
||||
{
|
||||
const lower = key.toLowerCase();
|
||||
// First-wins dedupe so an alias accidentally listed in two
|
||||
// scopes shows once - same precedence the gameserver uses.
|
||||
|
||||
if(seen.has(lower)) continue;
|
||||
seen.add(lower);
|
||||
|
||||
@@ -136,8 +111,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const query = mentionContext.query.toLowerCase();
|
||||
const out: MentionSuggestion[] = [];
|
||||
|
||||
// 1. Real users in the room. Pets, bots, rentable bots and monster
|
||||
// plants are filtered out by type.
|
||||
for(const user of roomUserList)
|
||||
{
|
||||
if(!user || user.type !== USER_TYPE_REAL_USER) continue;
|
||||
@@ -155,9 +128,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
// 2. Broadcast aliases. The server permission-gates these (sender needs
|
||||
// acc_mention_everyone / acc_mention_friends to actually fire) - the
|
||||
// picker just surfaces them.
|
||||
for(const alias of mentionAliases)
|
||||
{
|
||||
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
|
||||
@@ -178,8 +148,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
|
||||
const mentionSelectorVisible = mentionSuggestions.length > 0;
|
||||
|
||||
// Reset / clamp the highlighted row whenever the suggestion list changes
|
||||
// so arrow-up/down doesn't keep an index that's now out of range.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(mentionSelectedIndex >= mentionSuggestions.length) setMentionSelectedIndex(0);
|
||||
@@ -196,8 +164,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
|
||||
setChatValue(next);
|
||||
|
||||
// Move the caret to right after the inserted mention so subsequent
|
||||
// typing continues the message instead of editing the mention.
|
||||
requestAnimationFrame(() =>
|
||||
{
|
||||
if(!inputRef.current) return;
|
||||
@@ -341,7 +307,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
return;
|
||||
case 'Tab':
|
||||
event.preventDefault();
|
||||
// fall through
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
const selected = selectCurrent();
|
||||
@@ -389,8 +354,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(0);
|
||||
// Closing without picking: drop the bare "@<query>" so the
|
||||
// picker doesn't immediately reopen on next render.
|
||||
|
||||
if(mentionContext)
|
||||
{
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
|
||||
@@ -1,48 +1,11 @@
|
||||
/**
|
||||
* Cosmetic-only mention highlighting for in-room chat bubbles.
|
||||
*
|
||||
* The bubble text is rendered through {@link RoomChatFormatter}, which emits
|
||||
* an HTML string (wired markup `<strong>`/`<em>`/`<u>`, font-colour
|
||||
* `<span style>`, `<br />`, plus HTML-entity-encoded special characters) and
|
||||
* is injected via `dangerouslySetInnerHTML`. We therefore operate on the
|
||||
* already-formatted HTML string and wrap mention tokens that appear in the
|
||||
* TEXT regions (never inside a `<tag>`), returning a new HTML string. This
|
||||
* keeps every existing formatting behaviour intact and is purely visual — it
|
||||
* does not touch `chat.text`, parsing, chat history, or any wire payload.
|
||||
*
|
||||
* Token detection mirrors the server's `MentionManager.process` exactly:
|
||||
* - split on whitespace
|
||||
* - a candidate token has length >= 2 and starts with `@`
|
||||
* - strip the `@`, remove every char that is not [A-Za-z0-9_], lowercase
|
||||
* - match against the local username or a room-broadcast alias
|
||||
*
|
||||
* This means `@Bob!`, `@bob,` etc. all match the nick `Bob` (case-insensitive)
|
||||
* just like the server, while the highlighted span keeps the original token
|
||||
* text (`@` + original casing + trailing punctuation) verbatim.
|
||||
*/
|
||||
|
||||
// Mirror of the three alias config keys in Arcturus
|
||||
// (com.eu.habbo.habbohotel.mentions.MentionManager).
|
||||
// Highlighting is purely visual - the server still gates @everyone /
|
||||
// @friends on acc_mention_everyone / acc_mention_friends, so a normal
|
||||
// user typing @all just gets a highlighted chat bubble with no actual
|
||||
// notifications fired.
|
||||
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||
// mentions.everyone.aliases default
|
||||
'all', 'everyone', 'tutti',
|
||||
// mentions.friends.aliases default
|
||||
'friends', 'amici',
|
||||
// mentions.room.aliases default
|
||||
'room', 'stanza'
|
||||
];
|
||||
|
||||
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
|
||||
|
||||
/**
|
||||
* Normalise a raw `@token` the same way the server does: drop the leading `@`,
|
||||
* strip any non-nick characters (trailing punctuation, etc.), lowercase.
|
||||
* Returns an empty string when nothing usable remains.
|
||||
*/
|
||||
const normalizeToken = (token: string): string =>
|
||||
{
|
||||
if(!token || token.length < 2 || token.charAt(0) !== '@') return '';
|
||||
@@ -50,10 +13,7 @@ const normalizeToken = (token: string): string =>
|
||||
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the given raw whitespace-delimited token mentions the local user
|
||||
* or a room-broadcast alias.
|
||||
*/
|
||||
|
||||
const isMentionToken = (token: string, ownUsernameLower: string, aliases: ReadonlySet<string>): boolean =>
|
||||
{
|
||||
const nick = normalizeToken(token);
|
||||
@@ -65,11 +25,6 @@ const isMentionToken = (token: string, ownUsernameLower: string, aliases: Readon
|
||||
return aliases.has(nick);
|
||||
};
|
||||
|
||||
/**
|
||||
* Public predicate: does this raw whitespace-delimited token mention the given
|
||||
* user or a room-broadcast alias? Mirrors the server's detection. Reusable by
|
||||
* UI that renders mention previews as React nodes (e.g. the mentions box).
|
||||
*/
|
||||
export const tokenIsMention = (
|
||||
token: string,
|
||||
ownUsername: string,
|
||||
@@ -83,17 +38,10 @@ export const tokenIsMention = (
|
||||
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
||||
const HIGHLIGHT_CLOSE = '</span>';
|
||||
|
||||
/**
|
||||
* Wrap mention tokens in a single text chunk (no HTML tags inside it).
|
||||
* Whitespace runs between tokens are preserved verbatim by re-using the
|
||||
* original substrings around each match.
|
||||
*/
|
||||
const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet<string>): string =>
|
||||
{
|
||||
if(chunk.indexOf('@') < 0) return chunk;
|
||||
|
||||
// Split into alternating [whitespace, token, whitespace, token, ...]
|
||||
// segments so the exact original spacing is rebuilt unchanged.
|
||||
const segments = chunk.split(/(\s+)/);
|
||||
|
||||
let result = '';
|
||||
@@ -102,7 +50,6 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
||||
{
|
||||
if(segment.length === 0) continue;
|
||||
|
||||
// Whitespace runs and non-mention tokens pass through untouched.
|
||||
if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases))
|
||||
{
|
||||
result += segment;
|
||||
@@ -115,15 +62,6 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Take the formatted bubble HTML and return new HTML where every mention
|
||||
* token (own nick or room alias) in the text regions is wrapped in
|
||||
* `<span class="mention-highlight">…</span>`. HTML tags are passed through
|
||||
* untouched so existing markup keeps working.
|
||||
*
|
||||
* Returns the input unchanged when there is no `@`, no own username, and no
|
||||
* possibility of a match (fast path), or when nothing matches.
|
||||
*/
|
||||
export const highlightMentions = (
|
||||
formattedHtml: string,
|
||||
ownUsername: string,
|
||||
@@ -135,10 +73,8 @@ export const highlightMentions = (
|
||||
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
|
||||
|
||||
// Nothing could ever match → return verbatim.
|
||||
if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml;
|
||||
|
||||
// Walk the string, only highlighting inside text regions (outside `<...>`).
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
|
||||
@@ -152,7 +88,6 @@ export const highlightMentions = (
|
||||
break;
|
||||
}
|
||||
|
||||
// Text region before the next tag.
|
||||
if(tagStart > cursor)
|
||||
{
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
|
||||
@@ -162,12 +97,10 @@ export const highlightMentions = (
|
||||
|
||||
if(tagEnd < 0)
|
||||
{
|
||||
// Malformed trailing `<` with no closing `>` — emit the rest verbatim.
|
||||
result += formattedHtml.slice(tagStart);
|
||||
break;
|
||||
}
|
||||
|
||||
// Emit the tag (including the angle brackets) untouched.
|
||||
result += formattedHtml.slice(tagStart, tagEnd + 1);
|
||||
cursor = tagEnd + 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user