From 59ed27b7274c8f4d9da5d6b80512f317daec2fc2 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 4 Jun 2026 13:43:29 +0200 Subject: [PATCH] =?UTF-8?q?Revert=20"=F0=9F=86=99=20Bug=20fixed=20in=20loc?= =?UTF-8?q?alstorage"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 47453db5ee819705c14314d173edcca54a3a910b. --- src/components/MainView.tsx | 4 +- src/components/mentions/index.ts | 1 + .../NotificationDefaultAlertView.tsx | 73 ++++++++++- .../ChatInputMentionSelectorView.tsx | 9 -- .../room/widgets/chat-input/ChatInputView.tsx | 40 +----- .../room/widgets/chat/highlightMentions.tsx | 69 +--------- .../notification/NotificationCenterView.css | 73 ++++++++++- src/hooks/catalog/useCatalog.ts | 31 ++++- src/hooks/chat-history/useChatHistory.ts | 20 +-- .../mentions/__tests__/mentionsStore.test.ts | 15 --- src/hooks/mentions/index.ts | 1 + src/hooks/mentions/mentionsStore.ts | 24 +--- src/hooks/mentions/useMentionMessages.ts | 50 ++----- src/hooks/navigator/navigatorUiStore.ts | 2 +- src/hooks/rooms/widgets/useChatWidget.ts | 41 +++--- src/hooks/useLocalStorage.ts | 124 +----------------- src/index.tsx | 1 + 17 files changed, 210 insertions(+), 368 deletions(-) diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index caa04ab..32eb535 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -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('radio_ui.enabled', false) && } { (GetConfigurationValue('mentions_ui.enabled', true) && mentionsVisible) && setMentionsVisible(false) } /> } + { GetConfigurationValue('mentions_ui.enabled', true) && + } ); diff --git a/src/components/mentions/index.ts b/src/components/mentions/index.ts index 5eaab12..8c125c1 100644 --- a/src/components/mentions/index.ts +++ b/src/components/mentions/index.ts @@ -1,5 +1,6 @@ export * from './MentionMessageView'; export * from './MentionRowView'; export * from './MentionsView'; +export * from './MentionToastsView'; export * from './mentionsFormat'; export * from './useMentionActions'; diff --git a/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx b/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx index d9905e6..14c289d 100644 --- a/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx +++ b/src/components/notification-center/views/alert-layouts/NotificationDefaultAlertView.tsx @@ -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 = 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(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 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 ( - + { hasFrank && !item.imageUrl &&
} { item.imageUrl && !imageFailed && { @@ -30,7 +84,16 @@ export const NotificationDefaultAlertView: FC setImageFailed(true); } } /> }
- { (item.messages.length > 0) && item.messages.map((message, index) => + { hasCommandTemplate &&
+ { commandTemplateContent.intro.map((text, index) => +
{ text }
) } + { commandTemplateContent.commands.map((entry, index) => + ) } +
} + { !hasCommandTemplate && (item.messages.length > 0) && item.messages.map((message, index) => { const htmlText = message.replace(/\r\n|\r|\n/g, '
'); diff --git a/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx index 3bf5913..370786f 100644 --- a/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx @@ -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 = props => { const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props; diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index 41f14f6..85d3198 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -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 = { @@ -33,18 +27,12 @@ const MENTION_ALIAS_DEFAULTS: Record = { 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 = { 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(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>(() => { 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 "@" so the - // picker doesn't immediately reopen on next render. + if(mentionContext) { const before = chatValue.slice(0, mentionContext.replaceFrom); diff --git a/src/components/room/widgets/chat/highlightMentions.tsx b/src/components/room/widgets/chat/highlightMentions.tsx index f418fab..6c8620e 100644 --- a/src/components/room/widgets/chat/highlightMentions.tsx +++ b/src/components/room/widgets/chat/highlightMentions.tsx @@ -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 ``/``/``, font-colour - * ``, `
`, 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 ``), 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 = [ - // 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): 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 = ''; const HIGHLIGHT_CLOSE = ''; -/** - * 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 => { 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 - * ``. 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; } diff --git a/src/css/notification/NotificationCenterView.css b/src/css/notification/NotificationCenterView.css index c24cb67..2d43f52 100644 --- a/src/css/notification/NotificationCenterView.css +++ b/src/css/notification/NotificationCenterView.css @@ -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 { @@ -390,4 +461,4 @@ position: relative; background-image: url("@/assets/images/notifications/nitro_v3.png"); background-repeat: no-repeat; -} \ No newline at end of file +} diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 807b98e..807ef66 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -1,4 +1,4 @@ -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 { 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 { useCallback, useEffect, useRef, useState } from 'react'; 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'; @@ -89,6 +89,27 @@ const useCatalogStore = () => 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('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 catalogType = normalizeCatalogType(type); @@ -98,8 +119,10 @@ const useCatalogStore = () => resetVisibleCatalogState(catalogType); } + refreshImportedFurnidata(); + setIsVisible(true); - }, [ currentType, resetVisibleCatalogState ]); + }, [ currentType, resetVisibleCatalogState, refreshImportedFurnidata ]); const toggleCatalogByType = useCallback((type?: string) => { @@ -117,8 +140,10 @@ const useCatalogStore = () => resetVisibleCatalogState(catalogType); } + refreshImportedFurnidata(); + setIsVisible(true); - }, [ isVisible, currentType, resetVisibleCatalogState ]); + }, [ isVisible, currentType, resetVisibleCatalogState, refreshImportedFurnidata ]); const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) => { diff --git a/src/hooks/chat-history/useChatHistory.ts b/src/hooks/chat-history/useChatHistory.ts index c26057a..2cd021f 100644 --- a/src/hooks/chat-history/useChatHistory.ts +++ b/src/hooks/chat-history/useChatHistory.ts @@ -12,27 +12,11 @@ const MESSENGER_HISTORY_MAX = 1000; let CHAT_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 [ chatHistory, setChatHistory ] = useLocalStorage('chatHistory', [], { toStorage: slimChatEntriesForStorage }); + const [ chatHistory, setChatHistory ] = useLocalStorage('chatHistory', []); const [ roomHistory, setRoomHistory ] = useLocalStorage('roomHistory', []); - const [ messengerHistory, setMessengerHistory ] = useLocalStorage('messengerHistory', [], { toStorage: slimChatEntriesForStorage }); + const [ messengerHistory, setMessengerHistory ] = useLocalStorage('messengerHistory', []); const [ needsRoomInsert, setNeedsRoomInsert ] = useLocalStorage('needsRoomInsert', false); const addChatEntry = (entry: IChatEntry) => diff --git a/src/hooks/mentions/__tests__/mentionsStore.test.ts b/src/hooks/mentions/__tests__/mentionsStore.test.ts index 2ddf3a8..e85b5fb 100644 --- a/src/hooks/mentions/__tests__/mentionsStore.test.ts +++ b/src/hooks/mentions/__tests__/mentionsStore.test.ts @@ -40,19 +40,4 @@ describe('mentionsStore', () => expect(getUnreadCount()).toBe(1); 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); - }); }); diff --git a/src/hooks/mentions/index.ts b/src/hooks/mentions/index.ts index 3486da8..f8b9e5f 100644 --- a/src/hooks/mentions/index.ts +++ b/src/hooks/mentions/index.ts @@ -1,2 +1,3 @@ export * from './useMentionsSnapshot'; export * from './useMentionMessages'; +export * from './useMentionAutocomplete'; diff --git a/src/hooks/mentions/mentionsStore.ts b/src/hooks/mentions/mentionsStore.ts index d2614e9..23d2b17 100644 --- a/src/hooks/mentions/mentionsStore.ts +++ b/src/hooks/mentions/mentionsStore.ts @@ -1,21 +1,10 @@ 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[] = []; const listeners = new Set<() => void>(); 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) => { listeners.add(onChange); @@ -28,21 +17,14 @@ export const getUnreadCount = (): number => mentions.reduce((n, m) => n + (m.rea export const setMentions = (list: IMentionEntry[]): void => { - mentions = cap([...list].sort((a, b) => b.mentionId - a.mentionId)); + mentions = [...list].sort((a, b) => b.mentionId - a.mentionId); emit(); }; export const addMention = (entry: IMentionEntry): void => { - // Drop entries the server failed to persist (generatedId 0 / negative). - // 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]); + if(mentions.some(m => m.mentionId === entry.mentionId && entry.mentionId !== 0)) return; + mentions = [entry, ...mentions]; emit(); }; diff --git a/src/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts index 6e4796b..09cf0dd 100644 --- a/src/hooks/mentions/useMentionMessages.ts +++ b/src/hooks/mentions/useMentionMessages.ts @@ -1,29 +1,15 @@ import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; -import { useCallback, useEffect, useRef } from 'react'; -import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api'; +import { useCallback, useEffect } from 'react'; +import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api'; import { useMessageEvent } from '../events'; -import { useNotificationActions } from '../notification'; import { addMention, setMentions } from './mentionsStore'; +import { pushMentionToast } from './mentionToastsStore'; // Dedicated mention chime served from nitro-assets/sounds/.mp3. 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 => { - const { showSingleBubble } = useNotificationActions(); - const lastNotificationRef = useRef(0); - const seenIdsRef = useRef>(new Set()); - const onMentionsList = useCallback((event: MentionsListEvent) => { const list = event.getParser().mentions; @@ -32,7 +18,7 @@ export const useMentionMessages = (): void => mentionId: m.mentionId, senderId: m.senderId, senderUsername: m.senderUsername, - senderFigure: m.senderFigure ?? '', + senderFigure: m.senderFigure, roomId: m.roomId, roomName: m.roomName, message: m.message, @@ -48,22 +34,11 @@ export const useMentionMessages = (): void => 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 = { mentionId: m.mentionId, senderId: m.senderId, senderUsername: m.senderUsername, - senderFigure: m.senderFigure ?? '', + senderFigure: m.senderFigure, roomId: m.roomId, roomName: m.roomName, message: m.message, @@ -74,20 +49,11 @@ export const useMentionMessages = (): void => addMention(entry); - const now = Date.now(); - if((now - lastNotificationRef.current) < NOTIFICATION_THROTTLE_MS) return; - lastNotificationRef.current = now; - if(GetConfigurationValue('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE); - showSingleBubble( - LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]), - NotificationBubbleType.INFO, - null, - 'mentions/toggle', - entry.senderUsername - ); - }, [ showSingleBubble ]); + // Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico. + pushMentionToast(entry); + }, []); useMessageEvent(MentionsListEvent, onMentionsList); useMessageEvent(MentionReceivedEvent, onMentionReceived); diff --git a/src/hooks/navigator/navigatorUiStore.ts b/src/hooks/navigator/navigatorUiStore.ts index 709207a..c4899d7 100644 --- a/src/hooks/navigator/navigatorUiStore.ts +++ b/src/hooks/navigator/navigatorUiStore.ts @@ -64,6 +64,6 @@ export const useNavigatorUiStore = createNitroStore set({ needsInit: false }), requestSearch: () => set({ needsSearch: true }), consumeSearchRequest: () => set({ needsSearch: false }), - setTab: (code) => set({ currentTabCode: code, currentFilter: '' }), + setTab: (code) => set({ currentTabCode: code, currentFilter: '', isCreatorOpen: false }), setFilter: (value) => set({ currentFilter: value }) })); diff --git a/src/hooks/rooms/widgets/useChatWidget.ts b/src/hooks/rooms/widgets/useChatWidget.ts index ed0b9ed..e05fd36 100644 --- a/src/hooks/rooms/widgets/useChatWidget.ts +++ b/src/hooks/rooms/widgets/useChatWidget.ts @@ -230,31 +230,22 @@ const useChatWidgetState = () => return newValue; }); - - // Pet, Bot and Rentable Bot chat is fire-and-forget ("UDP-style"): - // the live bubble already rendered above, but we deliberately skip - // addChatEntry so the entry never lands in localStorage. A pet-heavy - // room used to push 30+ KB per message (base64 head data URL) into - // the chat history, exhausting the localStorage quota in minutes. - // Real users still go through the full persisted path. - const chatEntryId = (userType === RoomObjectType.USER) - ? addChatEntry({ - id: -1, - webId: userData.webID, - entityId: userData.roomIndex, - name: username, - imageUrl, - 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; + const chatEntryId = addChatEntry({ + id: -1, + webId: userData.webID, + entityId: userData.roomIndex, + name: username, + imageUrl, + 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) : {}) + }); if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 13c00cc..cd73ced 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -1,43 +1,10 @@ import { NitroLogger } from '@nitrots/nitro-renderer'; -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; import { GetLocalStorage, SetLocalStorage } from '../api'; const userId = new URLSearchParams(window.location.search).get('userid') || 0; -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 = (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 -{ - /** - * 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 = (key: string, initialValue: T, options: UseLocalStorageOptions = {}): [ T, Dispatch>] => +const useLocalStorageState = (key: string, initialValue: T): [ T, Dispatch>] => { key = userId ? `${ key }.${ userId }` : key; @@ -55,91 +22,6 @@ const useLocalStorageState = (key: string, initialValue: T, options: UseLocal } }); - const pendingWriteRef = useRef(null); - const writeTimerRef = useRef | 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) => { try @@ -148,7 +30,7 @@ const useLocalStorageState = (key: string, initialValue: T, options: UseLocal setStoredValue(valueToStore); - scheduleWrite(valueToStore); + if(typeof window !== 'undefined') SetLocalStorage(key, valueToStore); } catch(error) diff --git a/src/index.tsx b/src/index.tsx index 3825168..d1f1b0f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -23,6 +23,7 @@ import './css/catalog/CatalogClassicView.css'; import './css/emustats/EmuStatsView.css'; import './css/chat/Chats.css'; +import './css/mentions/MentionToasts.css'; import './css/common/Buttons.css';