mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆙 Bug fixed in localstorage
This commit is contained in:
@@ -48,7 +48,7 @@ import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView
|
|||||||
import { UserSettingsView } from './user-settings/UserSettingsView';
|
import { UserSettingsView } from './user-settings/UserSettingsView';
|
||||||
import { WiredView } from './wired/WiredView';
|
import { WiredView } from './wired/WiredView';
|
||||||
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
||||||
import { MentionsView, MentionToastsView } from './mentions';
|
import { MentionsView } from './mentions';
|
||||||
|
|
||||||
export const MainView: FC<{}> = props =>
|
export const MainView: FC<{}> = props =>
|
||||||
{
|
{
|
||||||
@@ -242,8 +242,6 @@ export const MainView: FC<{}> = props =>
|
|||||||
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
||||||
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
|
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
|
||||||
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
|
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
|
||||||
{ GetConfigurationValue<boolean>('mentions_ui.enabled', true) &&
|
|
||||||
<MentionToastsView /> }
|
|
||||||
<ExternalPluginLoader />
|
<ExternalPluginLoader />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './MentionMessageView';
|
export * from './MentionMessageView';
|
||||||
export * from './MentionRowView';
|
export * from './MentionRowView';
|
||||||
export * from './MentionsView';
|
export * from './MentionsView';
|
||||||
export * from './MentionToastsView';
|
|
||||||
export * from './mentionsFormat';
|
export * from './mentionsFormat';
|
||||||
export * from './useMentionActions';
|
export * from './useMentionActions';
|
||||||
|
|||||||
+5
-68
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useMemo, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { DispatchUiEvent, LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
import { LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl } 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,57 +7,11 @@ 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, classNames = [], ...rest } = props;
|
const { item = null, title = ((props.item && props.item.title) || ''), onClose = null, ...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);
|
||||||
@@ -65,18 +19,10 @@ 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 } classNames={ alertClassNames } { ...rest } type={ hasFrank ? NotificationAlertType.DEFAULT : item.alertType }>
|
<LayoutNotificationAlertView title={ title } onClose={ onClose } { ...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={ () =>
|
||||||
@@ -84,16 +30,7 @@ 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(' ') }>
|
||||||
{ hasCommandTemplate && <div className="notification-command-template">
|
{ (item.messages.length > 0) && item.messages.map((message, index) =>
|
||||||
{ 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 />');
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ export interface MentionSuggestion
|
|||||||
{
|
{
|
||||||
key: string;
|
key: string;
|
||||||
kind: MentionSuggestionKind;
|
kind: MentionSuggestionKind;
|
||||||
|
/** Display name shown in the row (e.g. "DuckieTM" or "all"). */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Token that's actually inserted into the chat input (without the @). */
|
||||||
insertToken: string;
|
insertToken: string;
|
||||||
|
/** Figure string for the avatar tile - only set for 'user' rows. */
|
||||||
figure?: string;
|
figure?: string;
|
||||||
|
/** Optional sub-label, e.g. for "Staff Chat". */
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +25,11 @@ interface ChatInputMentionSelectorViewProps
|
|||||||
onHover: (index: number) => void;
|
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 =>
|
export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
|
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
|||||||
import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView';
|
import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView';
|
||||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
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 USER_TYPE_REAL_USER = 1;
|
||||||
const MAX_MENTION_SUGGESTIONS = 8;
|
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';
|
type MentionAliasScope = 'everyone' | 'friends' | 'room';
|
||||||
|
|
||||||
const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
||||||
@@ -27,12 +33,18 @@ const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
|
|||||||
room: [ 'room', 'stanza' ]
|
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> = {
|
const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
|
||||||
everyone: 'mentions.alias.description.everyone',
|
everyone: 'mentions.alias.description.everyone',
|
||||||
friends: 'mentions.alias.description.friends',
|
friends: 'mentions.alias.description.friends',
|
||||||
room: 'mentions.alias.description.room'
|
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[] =>
|
const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
|
||||||
{
|
{
|
||||||
if(!Array.isArray(raw)) return fallback;
|
if(!Array.isArray(raw)) return fallback;
|
||||||
@@ -59,6 +71,13 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
const roomUserList = useRoomUserListSnapshot();
|
const roomUserList = useRoomUserListSnapshot();
|
||||||
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
|
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(() =>
|
const mentionContext = useMemo(() =>
|
||||||
{
|
{
|
||||||
if(!chatValue) return null;
|
if(!chatValue) return null;
|
||||||
@@ -69,14 +88,19 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
const at = upToCaret.lastIndexOf('@');
|
const at = upToCaret.lastIndexOf('@');
|
||||||
if(at < 0) return null;
|
if(at < 0) return null;
|
||||||
|
|
||||||
|
// @ must be at start or follow whitespace.
|
||||||
if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null;
|
if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null;
|
||||||
|
|
||||||
const query = upToCaret.slice(at + 1);
|
const query = upToCaret.slice(at + 1);
|
||||||
|
// Bail if the query already contains whitespace - the token has ended.
|
||||||
if(/\s/.test(query)) return null;
|
if(/\s/.test(query)) return null;
|
||||||
|
|
||||||
return { atIndex: at, replaceFrom: at, replaceTo: caret, query };
|
return { atIndex: at, replaceFrom: at, replaceTo: caret, query };
|
||||||
}, [ chatValue, commandSelectorVisible ]);
|
}, [ 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 mentionAliases = useMemo<ReadonlyArray<{ key: string; scope: MentionAliasScope; description: string }>>(() =>
|
||||||
{
|
{
|
||||||
const out: { 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)
|
for(const key of list)
|
||||||
{
|
{
|
||||||
const lower = key.toLowerCase();
|
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;
|
if(seen.has(lower)) continue;
|
||||||
seen.add(lower);
|
seen.add(lower);
|
||||||
|
|
||||||
@@ -111,6 +136,8 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
const query = mentionContext.query.toLowerCase();
|
const query = mentionContext.query.toLowerCase();
|
||||||
const out: MentionSuggestion[] = [];
|
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)
|
for(const user of roomUserList)
|
||||||
{
|
{
|
||||||
if(!user || user.type !== USER_TYPE_REAL_USER) continue;
|
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;
|
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)
|
for(const alias of mentionAliases)
|
||||||
{
|
{
|
||||||
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
|
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
|
||||||
@@ -148,6 +178,8 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
|
|
||||||
const mentionSelectorVisible = mentionSuggestions.length > 0;
|
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(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if(mentionSelectedIndex >= mentionSuggestions.length) setMentionSelectedIndex(0);
|
if(mentionSelectedIndex >= mentionSuggestions.length) setMentionSelectedIndex(0);
|
||||||
@@ -164,6 +196,8 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
|
|
||||||
setChatValue(next);
|
setChatValue(next);
|
||||||
|
|
||||||
|
// Move the caret to right after the inserted mention so subsequent
|
||||||
|
// typing continues the message instead of editing the mention.
|
||||||
requestAnimationFrame(() =>
|
requestAnimationFrame(() =>
|
||||||
{
|
{
|
||||||
if(!inputRef.current) return;
|
if(!inputRef.current) return;
|
||||||
@@ -307,6 +341,7 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
return;
|
return;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// fall through
|
||||||
case 'NumpadEnter':
|
case 'NumpadEnter':
|
||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
const selected = selectCurrent();
|
const selected = selectCurrent();
|
||||||
@@ -354,7 +389,8 @@ export const ChatInputView: FC<{}> = props =>
|
|||||||
case 'Escape':
|
case 'Escape':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setMentionSelectedIndex(0);
|
setMentionSelectedIndex(0);
|
||||||
|
// Closing without picking: drop the bare "@<query>" so the
|
||||||
|
// picker doesn't immediately reopen on next render.
|
||||||
if(mentionContext)
|
if(mentionContext)
|
||||||
{
|
{
|
||||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
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> = [
|
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||||
|
// mentions.everyone.aliases default
|
||||||
'all', 'everyone', 'tutti',
|
'all', 'everyone', 'tutti',
|
||||||
|
// mentions.friends.aliases default
|
||||||
'friends', 'amici',
|
'friends', 'amici',
|
||||||
|
// mentions.room.aliases default
|
||||||
'room', 'stanza'
|
'room', 'stanza'
|
||||||
];
|
];
|
||||||
|
|
||||||
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
|
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 =>
|
const normalizeToken = (token: string): string =>
|
||||||
{
|
{
|
||||||
if(!token || token.length < 2 || token.charAt(0) !== '@') return '';
|
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();
|
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 isMentionToken = (token: string, ownUsernameLower: string, aliases: ReadonlySet<string>): boolean =>
|
||||||
{
|
{
|
||||||
const nick = normalizeToken(token);
|
const nick = normalizeToken(token);
|
||||||
@@ -25,6 +65,11 @@ const isMentionToken = (token: string, ownUsernameLower: string, aliases: Readon
|
|||||||
return aliases.has(nick);
|
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 = (
|
export const tokenIsMention = (
|
||||||
token: string,
|
token: string,
|
||||||
ownUsername: string,
|
ownUsername: string,
|
||||||
@@ -38,10 +83,17 @@ export const tokenIsMention = (
|
|||||||
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
||||||
const HIGHLIGHT_CLOSE = '</span>';
|
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 =>
|
const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet<string>): string =>
|
||||||
{
|
{
|
||||||
if(chunk.indexOf('@') < 0) return chunk;
|
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+)/);
|
const segments = chunk.split(/(\s+)/);
|
||||||
|
|
||||||
let result = '';
|
let result = '';
|
||||||
@@ -50,6 +102,7 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
|||||||
{
|
{
|
||||||
if(segment.length === 0) continue;
|
if(segment.length === 0) continue;
|
||||||
|
|
||||||
|
// Whitespace runs and non-mention tokens pass through untouched.
|
||||||
if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases))
|
if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases))
|
||||||
{
|
{
|
||||||
result += segment;
|
result += segment;
|
||||||
@@ -62,6 +115,15 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
|||||||
return result;
|
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 = (
|
export const highlightMentions = (
|
||||||
formattedHtml: string,
|
formattedHtml: string,
|
||||||
ownUsername: string,
|
ownUsername: string,
|
||||||
@@ -73,8 +135,10 @@ export const highlightMentions = (
|
|||||||
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||||
const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
|
const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
|
||||||
|
|
||||||
|
// Nothing could ever match → return verbatim.
|
||||||
if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml;
|
if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml;
|
||||||
|
|
||||||
|
// Walk the string, only highlighting inside text regions (outside `<...>`).
|
||||||
let result = '';
|
let result = '';
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
@@ -88,6 +152,7 @@ export const highlightMentions = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text region before the next tag.
|
||||||
if(tagStart > cursor)
|
if(tagStart > cursor)
|
||||||
{
|
{
|
||||||
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
|
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
|
||||||
@@ -97,10 +162,12 @@ export const highlightMentions = (
|
|||||||
|
|
||||||
if(tagEnd < 0)
|
if(tagEnd < 0)
|
||||||
{
|
{
|
||||||
|
// Malformed trailing `<` with no closing `>` — emit the rest verbatim.
|
||||||
result += formattedHtml.slice(tagStart);
|
result += formattedHtml.slice(tagStart);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit the tag (including the angle brackets) untouched.
|
||||||
result += formattedHtml.slice(tagStart, tagEnd + 1);
|
result += formattedHtml.slice(tagStart, tagEnd + 1);
|
||||||
cursor = tagEnd + 1;
|
cursor = tagEnd + 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,77 +20,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.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 {
|
||||||
@@ -461,4 +390,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;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetConfiguration, GetRoomContentLoader, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
|
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useBetween } from 'use-between';
|
import { useBetween } from 'use-between';
|
||||||
import { BuilderFurniPlaceableStatus, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api';
|
import { BuilderFurniPlaceableStatus, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api';
|
||||||
@@ -89,27 +89,6 @@ const useCatalogStore = () =>
|
|||||||
setCurrentType(normalizeCatalogType(type));
|
setCurrentType(normalizeCatalogType(type));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Real-time furni importati: ri-mergia il chunk custom/imported.json5 nelle Map
|
|
||||||
// furnidata + RoomContentLoader all'apertura del catalogo, SENZA reload del client.
|
|
||||||
const refreshImportedFurnidata = useCallback(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const base = GetConfiguration().getValue<string>('furnidata.url');
|
|
||||||
|
|
||||||
if(!base || !base.length) return;
|
|
||||||
|
|
||||||
const importedUrl = base.replace(/\/+$/, '') + '/custom/imported.json5';
|
|
||||||
|
|
||||||
GetSessionDataManager().mergeFurnitureDataFromUrl(importedUrl).then(added =>
|
|
||||||
{
|
|
||||||
if(added && added.length) GetRoomContentLoader().processFurnitureData(added);
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openCatalogByType = useCallback((type?: string) =>
|
const openCatalogByType = useCallback((type?: string) =>
|
||||||
{
|
{
|
||||||
const catalogType = normalizeCatalogType(type);
|
const catalogType = normalizeCatalogType(type);
|
||||||
@@ -119,10 +98,8 @@ const useCatalogStore = () =>
|
|||||||
resetVisibleCatalogState(catalogType);
|
resetVisibleCatalogState(catalogType);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshImportedFurnidata();
|
|
||||||
|
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
}, [ currentType, resetVisibleCatalogState, refreshImportedFurnidata ]);
|
}, [ currentType, resetVisibleCatalogState ]);
|
||||||
|
|
||||||
const toggleCatalogByType = useCallback((type?: string) =>
|
const toggleCatalogByType = useCallback((type?: string) =>
|
||||||
{
|
{
|
||||||
@@ -140,10 +117,8 @@ const useCatalogStore = () =>
|
|||||||
resetVisibleCatalogState(catalogType);
|
resetVisibleCatalogState(catalogType);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshImportedFurnidata();
|
|
||||||
|
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
}, [ isVisible, currentType, resetVisibleCatalogState, refreshImportedFurnidata ]);
|
}, [ isVisible, currentType, resetVisibleCatalogState ]);
|
||||||
|
|
||||||
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
|
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,11 +12,27 @@ const MESSENGER_HISTORY_MAX = 1000;
|
|||||||
let CHAT_HISTORY_COUNTER: number = 0;
|
let CHAT_HISTORY_COUNTER: number = 0;
|
||||||
let MESSENGER_HISTORY_COUNTER: number = 0;
|
let MESSENGER_HISTORY_COUNTER: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a list of chat entries to the slim shape we want to persist in
|
||||||
|
* localStorage. `imageUrl` is a base64 data URL of the avatar / pet head
|
||||||
|
* (10-50 KB each) - keeping it in storage blows past the browser quota
|
||||||
|
* inside minutes in a pet-heavy room. The avatar can always be re-rendered
|
||||||
|
* from `look` via ChatBubbleUtilities.getUserImage(), and pet images are
|
||||||
|
* regenerated from the bubble flow when needed; we just don't restore
|
||||||
|
* head thumbnails for entries loaded from a previous session.
|
||||||
|
*
|
||||||
|
* `style` / `chatType` / `color` are kept because they're tiny but
|
||||||
|
* meaningful for re-rendering the bubble. Translation fields are kept
|
||||||
|
* because they're already text.
|
||||||
|
*/
|
||||||
|
const slimChatEntriesForStorage = (entries: IChatEntry[]): IChatEntry[] =>
|
||||||
|
entries.map(entry => entry.imageUrl ? { ...entry, imageUrl: undefined } : entry);
|
||||||
|
|
||||||
const useChatHistoryState = () =>
|
const useChatHistoryState = () =>
|
||||||
{
|
{
|
||||||
const [ chatHistory, setChatHistory ] = useLocalStorage<IChatEntry[]>('chatHistory', []);
|
const [ chatHistory, setChatHistory ] = useLocalStorage<IChatEntry[]>('chatHistory', [], { toStorage: slimChatEntriesForStorage });
|
||||||
const [ roomHistory, setRoomHistory ] = useLocalStorage<IRoomHistoryEntry[]>('roomHistory', []);
|
const [ roomHistory, setRoomHistory ] = useLocalStorage<IRoomHistoryEntry[]>('roomHistory', []);
|
||||||
const [ messengerHistory, setMessengerHistory ] = useLocalStorage<IChatEntry[]>('messengerHistory', []);
|
const [ messengerHistory, setMessengerHistory ] = useLocalStorage<IChatEntry[]>('messengerHistory', [], { toStorage: slimChatEntriesForStorage });
|
||||||
const [ needsRoomInsert, setNeedsRoomInsert ] = useLocalStorage('needsRoomInsert', false);
|
const [ needsRoomInsert, setNeedsRoomInsert ] = useLocalStorage('needsRoomInsert', false);
|
||||||
|
|
||||||
const addChatEntry = (entry: IChatEntry) =>
|
const addChatEntry = (entry: IChatEntry) =>
|
||||||
|
|||||||
@@ -40,4 +40,19 @@ describe('mentionsStore', () =>
|
|||||||
expect(getUnreadCount()).toBe(1);
|
expect(getUnreadCount()).toBe(1);
|
||||||
expect(getMentionsSnapshot().find(m => m.mentionId === 1)!.read).toBe(true);
|
expect(getMentionsSnapshot().find(m => m.mentionId === 1)!.read).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops mentions with non-positive id (defensive against id=0 spam)', () =>
|
||||||
|
{
|
||||||
|
addMention(make(0));
|
||||||
|
addMention(make(-1));
|
||||||
|
expect(getMentionsSnapshot()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes duplicate ids even after the legacy id !== 0 carve-out is gone', () =>
|
||||||
|
{
|
||||||
|
addMention(make(7));
|
||||||
|
addMention(make(7));
|
||||||
|
addMention(make(7));
|
||||||
|
expect(getMentionsSnapshot()).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from './useMentionsSnapshot';
|
export * from './useMentionsSnapshot';
|
||||||
export * from './useMentionMessages';
|
export * from './useMentionMessages';
|
||||||
export * from './useMentionAutocomplete';
|
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { IMentionEntry } from '../../api/mentions';
|
import { IMentionEntry } from '../../api/mentions';
|
||||||
|
|
||||||
|
// Hard cap on how many mentions we hold in memory at once. The server's
|
||||||
|
// initial list is capped (mentions.store.limit, default 50) but live
|
||||||
|
// MentionReceived packets feed into addMention unbounded - so a server bug
|
||||||
|
// or a hostile/injected stream could otherwise grow the array and the DOM
|
||||||
|
// forever. 200 is comfortably more than any realistic active user has and
|
||||||
|
// well below anything that would inflate memory.
|
||||||
|
const MAX_MENTIONS = 200;
|
||||||
|
|
||||||
let mentions: IMentionEntry[] = [];
|
let mentions: IMentionEntry[] = [];
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
const emit = () => { for(const l of listeners) l(); };
|
const emit = () => { for(const l of listeners) l(); };
|
||||||
|
|
||||||
|
const cap = (list: IMentionEntry[]): IMentionEntry[] =>
|
||||||
|
(list.length > MAX_MENTIONS) ? list.slice(0, MAX_MENTIONS) : list;
|
||||||
|
|
||||||
export const subscribeMentions = (onChange: () => void): (() => void) =>
|
export const subscribeMentions = (onChange: () => void): (() => void) =>
|
||||||
{
|
{
|
||||||
listeners.add(onChange);
|
listeners.add(onChange);
|
||||||
@@ -17,14 +28,21 @@ export const getUnreadCount = (): number => mentions.reduce((n, m) => n + (m.rea
|
|||||||
|
|
||||||
export const setMentions = (list: IMentionEntry[]): void =>
|
export const setMentions = (list: IMentionEntry[]): void =>
|
||||||
{
|
{
|
||||||
mentions = [...list].sort((a, b) => b.mentionId - a.mentionId);
|
mentions = cap([...list].sort((a, b) => b.mentionId - a.mentionId));
|
||||||
emit();
|
emit();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addMention = (entry: IMentionEntry): void =>
|
export const addMention = (entry: IMentionEntry): void =>
|
||||||
{
|
{
|
||||||
if(mentions.some(m => m.mentionId === entry.mentionId && entry.mentionId !== 0)) return;
|
// Drop entries the server failed to persist (generatedId 0 / negative).
|
||||||
mentions = [entry, ...mentions];
|
// The server hardening already refuses to push these, but the client
|
||||||
|
// stays defensive in case a stale gameserver or an injected packet sends
|
||||||
|
// one - without this guard, the old "id !== 0" dedup carve-out let
|
||||||
|
// every duplicate through.
|
||||||
|
if(!entry || !Number.isFinite(entry.mentionId) || entry.mentionId <= 0) return;
|
||||||
|
if(mentions.some(m => m.mentionId === entry.mentionId)) return;
|
||||||
|
|
||||||
|
mentions = cap([entry, ...mentions]);
|
||||||
emit();
|
emit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer';
|
import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
|
import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api';
|
||||||
import { useMessageEvent } from '../events';
|
import { useMessageEvent } from '../events';
|
||||||
|
import { useNotificationActions } from '../notification';
|
||||||
import { addMention, setMentions } from './mentionsStore';
|
import { addMention, setMentions } from './mentionsStore';
|
||||||
import { pushMentionToast } from './mentionToastsStore';
|
|
||||||
|
|
||||||
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
|
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
|
||||||
const MENTION_SOUND_SAMPLE = 'mentions_notification';
|
const MENTION_SOUND_SAMPLE = 'mentions_notification';
|
||||||
|
|
||||||
|
// Floor on the gap between bubble/chime notifications. Even if the server
|
||||||
|
// (or an injected packet stream) pushes mentions faster than this, the user
|
||||||
|
// gets at most one chime + bubble per window. The mentions list itself
|
||||||
|
// still updates in real time - this only throttles the in-screen feedback.
|
||||||
|
const NOTIFICATION_THROTTLE_MS = 1500;
|
||||||
|
// Drop any single mention packet whose mention id we've already seen this
|
||||||
|
// session, so a replay attack can't re-trigger the bubble + sound even if
|
||||||
|
// the client store dropped the entry already.
|
||||||
|
const SEEN_IDS_MAX = 500;
|
||||||
|
|
||||||
export const useMentionMessages = (): void =>
|
export const useMentionMessages = (): void =>
|
||||||
{
|
{
|
||||||
|
const { showSingleBubble } = useNotificationActions();
|
||||||
|
const lastNotificationRef = useRef<number>(0);
|
||||||
|
const seenIdsRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
const onMentionsList = useCallback((event: MentionsListEvent) =>
|
const onMentionsList = useCallback((event: MentionsListEvent) =>
|
||||||
{
|
{
|
||||||
const list = event.getParser().mentions;
|
const list = event.getParser().mentions;
|
||||||
@@ -18,7 +32,7 @@ export const useMentionMessages = (): void =>
|
|||||||
mentionId: m.mentionId,
|
mentionId: m.mentionId,
|
||||||
senderId: m.senderId,
|
senderId: m.senderId,
|
||||||
senderUsername: m.senderUsername,
|
senderUsername: m.senderUsername,
|
||||||
senderFigure: m.senderFigure,
|
senderFigure: m.senderFigure ?? '',
|
||||||
roomId: m.roomId,
|
roomId: m.roomId,
|
||||||
roomName: m.roomName,
|
roomName: m.roomName,
|
||||||
message: m.message,
|
message: m.message,
|
||||||
@@ -34,11 +48,22 @@ export const useMentionMessages = (): void =>
|
|||||||
|
|
||||||
const m = event.getParser().mention;
|
const m = event.getParser().mention;
|
||||||
|
|
||||||
|
if(!m || !Number.isFinite(m.mentionId) || m.mentionId <= 0) return;
|
||||||
|
|
||||||
|
const seen = seenIdsRef.current;
|
||||||
|
if(seen.has(m.mentionId)) return;
|
||||||
|
seen.add(m.mentionId);
|
||||||
|
if(seen.size > SEEN_IDS_MAX)
|
||||||
|
{
|
||||||
|
const first = seen.values().next().value as number | undefined;
|
||||||
|
if(first !== undefined) seen.delete(first);
|
||||||
|
}
|
||||||
|
|
||||||
const entry: IMentionEntry = {
|
const entry: IMentionEntry = {
|
||||||
mentionId: m.mentionId,
|
mentionId: m.mentionId,
|
||||||
senderId: m.senderId,
|
senderId: m.senderId,
|
||||||
senderUsername: m.senderUsername,
|
senderUsername: m.senderUsername,
|
||||||
senderFigure: m.senderFigure,
|
senderFigure: m.senderFigure ?? '',
|
||||||
roomId: m.roomId,
|
roomId: m.roomId,
|
||||||
roomName: m.roomName,
|
roomName: m.roomName,
|
||||||
message: m.message,
|
message: m.message,
|
||||||
@@ -49,11 +74,20 @@ export const useMentionMessages = (): void =>
|
|||||||
|
|
||||||
addMention(entry);
|
addMention(entry);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if((now - lastNotificationRef.current) < NOTIFICATION_THROTTLE_MS) return;
|
||||||
|
lastNotificationRef.current = now;
|
||||||
|
|
||||||
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
|
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
|
||||||
|
|
||||||
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
|
showSingleBubble(
|
||||||
pushMentionToast(entry);
|
LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]),
|
||||||
}, []);
|
NotificationBubbleType.INFO,
|
||||||
|
null,
|
||||||
|
'mentions/toggle',
|
||||||
|
entry.senderUsername
|
||||||
|
);
|
||||||
|
}, [ showSingleBubble ]);
|
||||||
|
|
||||||
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
|
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
|
||||||
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
|
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
|
||||||
|
|||||||
@@ -64,6 +64,6 @@ export const useNavigatorUiStore = createNitroStore<NavigatorUiState & Navigator
|
|||||||
markInitDone: () => set({ needsInit: false }),
|
markInitDone: () => set({ needsInit: false }),
|
||||||
requestSearch: () => set({ needsSearch: true }),
|
requestSearch: () => set({ needsSearch: true }),
|
||||||
consumeSearchRequest: () => set({ needsSearch: false }),
|
consumeSearchRequest: () => set({ needsSearch: false }),
|
||||||
setTab: (code) => set({ currentTabCode: code, currentFilter: '', isCreatorOpen: false }),
|
setTab: (code) => set({ currentTabCode: code, currentFilter: '' }),
|
||||||
setFilter: (value) => set({ currentFilter: value })
|
setFilter: (value) => set({ currentFilter: value })
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -230,22 +230,31 @@ const useChatWidgetState = () =>
|
|||||||
|
|
||||||
return newValue;
|
return newValue;
|
||||||
});
|
});
|
||||||
const chatEntryId = addChatEntry({
|
|
||||||
id: -1,
|
// Pet, Bot and Rentable Bot chat is fire-and-forget ("UDP-style"):
|
||||||
webId: userData.webID,
|
// the live bubble already rendered above, but we deliberately skip
|
||||||
entityId: userData.roomIndex,
|
// addChatEntry so the entry never lands in localStorage. A pet-heavy
|
||||||
name: username,
|
// room used to push 30+ KB per message (base64 head data URL) into
|
||||||
imageUrl,
|
// the chat history, exhausting the localStorage quota in minutes.
|
||||||
style: styleId,
|
// Real users still go through the full persisted path.
|
||||||
chatType: chatType,
|
const chatEntryId = (userType === RoomObjectType.USER)
|
||||||
entityType: userData.type,
|
? addChatEntry({
|
||||||
message: formattedText,
|
id: -1,
|
||||||
timestamp: ChatHistoryCurrentDate(),
|
webId: userData.webID,
|
||||||
type: ChatEntryType.TYPE_CHAT,
|
entityId: userData.roomIndex,
|
||||||
roomId: roomSession.roomId,
|
name: username,
|
||||||
color,
|
imageUrl,
|
||||||
...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {})
|
style: styleId,
|
||||||
});
|
chatType: chatType,
|
||||||
|
entityType: userData.type,
|
||||||
|
message: formattedText,
|
||||||
|
timestamp: ChatHistoryCurrentDate(),
|
||||||
|
type: ChatEntryType.TYPE_CHAT,
|
||||||
|
roomId: roomSession.roomId,
|
||||||
|
color,
|
||||||
|
...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {})
|
||||||
|
})
|
||||||
|
: -1;
|
||||||
|
|
||||||
if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return;
|
if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,43 @@
|
|||||||
import { NitroLogger } from '@nitrots/nitro-renderer';
|
import { NitroLogger } from '@nitrots/nitro-renderer';
|
||||||
import { Dispatch, SetStateAction, useState } from 'react';
|
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
|
||||||
import { GetLocalStorage, SetLocalStorage } from '../api';
|
import { GetLocalStorage, SetLocalStorage } from '../api';
|
||||||
|
|
||||||
const userId = new URLSearchParams(window.location.search).get('userid') || 0;
|
const userId = new URLSearchParams(window.location.search).get('userid') || 0;
|
||||||
|
|
||||||
const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<SetStateAction<T>>] =>
|
const STORAGE_WRITE_DEBOUNCE_MS = 250;
|
||||||
|
const QUOTA_TRIM_FACTOR = 0.5; // on quota error, keep the newest 50%.
|
||||||
|
const MIN_RETAINED_ENTRIES = 50;
|
||||||
|
|
||||||
|
const isQuotaError = (error: unknown): boolean =>
|
||||||
|
{
|
||||||
|
if(!error || typeof error !== 'object') return false;
|
||||||
|
const name = (error as { name?: string }).name;
|
||||||
|
if(name === 'QuotaExceededError') return true;
|
||||||
|
// Firefox legacy:
|
||||||
|
if(name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimArrayForQuota = <T>(value: T): T =>
|
||||||
|
{
|
||||||
|
if(!Array.isArray(value)) return value;
|
||||||
|
if(value.length <= MIN_RETAINED_ENTRIES) return [] as unknown as T;
|
||||||
|
const keep = Math.max(MIN_RETAINED_ENTRIES, Math.floor(value.length * QUOTA_TRIM_FACTOR));
|
||||||
|
return value.slice(value.length - keep) as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseLocalStorageOptions<T>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Optional projection applied right before the value is written to
|
||||||
|
* localStorage. The in-memory React state is unaffected. Use this to
|
||||||
|
* strip heavy ephemeral fields (e.g. base64 image URLs) that would
|
||||||
|
* otherwise blow past the storage quota.
|
||||||
|
*/
|
||||||
|
toStorage?: (value: T) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useLocalStorageState = <T>(key: string, initialValue: T, options: UseLocalStorageOptions<T> = {}): [ T, Dispatch<SetStateAction<T>>] =>
|
||||||
{
|
{
|
||||||
key = userId ? `${ key }.${ userId }` : key;
|
key = userId ? `${ key }.${ userId }` : key;
|
||||||
|
|
||||||
@@ -22,6 +55,91 @@ const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<Se
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pendingWriteRef = useRef<T | null>(null);
|
||||||
|
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const optionsRef = useRef(options);
|
||||||
|
|
||||||
|
// Keep the latest toStorage projection without re-running effects.
|
||||||
|
optionsRef.current = options;
|
||||||
|
|
||||||
|
const flushWrite = (value: T) =>
|
||||||
|
{
|
||||||
|
if(typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const project = optionsRef.current.toStorage;
|
||||||
|
const projected = project ? project(value) : value;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SetLocalStorage(key, projected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch(error)
|
||||||
|
{
|
||||||
|
if(!isQuotaError(error))
|
||||||
|
{
|
||||||
|
NitroLogger.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota exceeded - trim and retry once. Anything that isn't an
|
||||||
|
// array gets cleared, since we have no generic trimming rule.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const trimmed = trimArrayForQuota(projected as T);
|
||||||
|
SetLocalStorage(key, trimmed);
|
||||||
|
NitroLogger.warn(`[useLocalStorage] quota exceeded for ${ key }, trimmed payload`);
|
||||||
|
}
|
||||||
|
catch(retryError)
|
||||||
|
{
|
||||||
|
NitroLogger.error(retryError);
|
||||||
|
// Last resort: drop the key entirely so future writes have room.
|
||||||
|
try { window.localStorage.removeItem(key); } catch(_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce: high-frequency chat would otherwise trigger one full
|
||||||
|
// JSON.stringify + setItem per message. We coalesce bursts into one
|
||||||
|
// write per STORAGE_WRITE_DEBOUNCE_MS window with the latest value.
|
||||||
|
const scheduleWrite = (value: T) =>
|
||||||
|
{
|
||||||
|
pendingWriteRef.current = value;
|
||||||
|
if(writeTimerRef.current) clearTimeout(writeTimerRef.current);
|
||||||
|
writeTimerRef.current = setTimeout(() =>
|
||||||
|
{
|
||||||
|
writeTimerRef.current = null;
|
||||||
|
if(pendingWriteRef.current !== null)
|
||||||
|
{
|
||||||
|
flushWrite(pendingWriteRef.current);
|
||||||
|
pendingWriteRef.current = null;
|
||||||
|
}
|
||||||
|
}, STORAGE_WRITE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flush a pending write on tab close / hide so we don't lose the last
|
||||||
|
// burst of activity.
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
const flushOnLeave = () =>
|
||||||
|
{
|
||||||
|
if(pendingWriteRef.current === null) return;
|
||||||
|
if(writeTimerRef.current) clearTimeout(writeTimerRef.current);
|
||||||
|
writeTimerRef.current = null;
|
||||||
|
flushWrite(pendingWriteRef.current);
|
||||||
|
pendingWriteRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', flushOnLeave);
|
||||||
|
window.addEventListener('beforeunload', flushOnLeave);
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
window.removeEventListener('pagehide', flushOnLeave);
|
||||||
|
window.removeEventListener('beforeunload', flushOnLeave);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setValue = (value: T) =>
|
const setValue = (value: T) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -30,7 +148,7 @@ const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<Se
|
|||||||
|
|
||||||
setStoredValue(valueToStore);
|
setStoredValue(valueToStore);
|
||||||
|
|
||||||
if(typeof window !== 'undefined') SetLocalStorage(key, valueToStore);
|
scheduleWrite(valueToStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
catch(error)
|
catch(error)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import './css/catalog/CatalogClassicView.css';
|
|||||||
import './css/emustats/EmuStatsView.css';
|
import './css/emustats/EmuStatsView.css';
|
||||||
|
|
||||||
import './css/chat/Chats.css';
|
import './css/chat/Chats.css';
|
||||||
import './css/mentions/MentionToasts.css';
|
|
||||||
|
|
||||||
import './css/common/Buttons.css';
|
import './css/common/Buttons.css';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user