🆙 Bug fixed in localstorage

This commit is contained in:
duckietm
2026-06-04 13:43:04 +02:00
parent 7007752e91
commit 47453db5ee
17 changed files with 368 additions and 210 deletions
@@ -7,9 +7,13 @@ 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;
}
@@ -21,6 +25,11 @@ 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,9 +10,15 @@ 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> = {
@@ -27,12 +33,18 @@ 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;
@@ -59,6 +71,13 @@ 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;
@@ -69,14 +88,19 @@ 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 }[] = [];
@@ -93,7 +117,8 @@ 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);
@@ -111,6 +136,8 @@ 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;
@@ -128,6 +155,9 @@ 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;
@@ -148,6 +178,8 @@ 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);
@@ -164,6 +196,8 @@ 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;
@@ -307,6 +341,7 @@ export const ChatInputView: FC<{}> = props =>
return;
case 'Tab':
event.preventDefault();
// fall through
case 'NumpadEnter':
case 'Enter': {
const selected = selectCurrent();
@@ -354,7 +389,8 @@ 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,11 +1,48 @@
/**
* 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 '';
@@ -13,7 +50,10 @@ 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);
@@ -25,6 +65,11 @@ 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,
@@ -38,10 +83,17 @@ 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 = '';
@@ -50,6 +102,7 @@ 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;
@@ -62,6 +115,15 @@ 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,
@@ -73,8 +135,10 @@ 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;
@@ -88,6 +152,7 @@ export const highlightMentions = (
break;
}
// Text region before the next tag.
if(tagStart > cursor)
{
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
@@ -97,10 +162,12 @@ 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;
}