diff --git a/src/api/mentions/IMentionEntry.ts b/src/api/mentions/IMentionEntry.ts index c8b89b3..99eebdf 100644 --- a/src/api/mentions/IMentionEntry.ts +++ b/src/api/mentions/IMentionEntry.ts @@ -3,6 +3,7 @@ export interface IMentionEntry mentionId: number; senderId: number; senderUsername: string; + senderFigure: string; roomId: number; roomName: string; message: string; 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/MentionToastsView.tsx b/src/components/mentions/MentionToastsView.tsx new file mode 100644 index 0000000..6943986 --- /dev/null +++ b/src/components/mentions/MentionToastsView.tsx @@ -0,0 +1,63 @@ +import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; +import { FC, MouseEvent, useEffect } from 'react'; +import { FaTimes } from 'react-icons/fa'; +import { LocalizeText, SendMessageComposer } from '../../api'; +import { LayoutAvatarImageView } from '../../common'; +import { useExternalSnapshot } from '../../hooks/events/useExternalSnapshot'; +import { markRead } from '../../hooks/mentions/mentionsStore'; +import { dismissMentionToast, getMentionToasts, MentionToast, subscribeMentionToasts } from '../../hooks/mentions/mentionToastsStore'; + +// Quanto resta visibile un toast prima di nascondersi da solo (resta non-letto). +const AUTO_DISMISS_MS = 8000; + +const MentionToastItemView: FC<{ toast: MentionToast }> = ({ toast }) => +{ + useEffect(() => + { + const timer = window.setTimeout(() => dismissMentionToast(toast.mentionId), AUTO_DISMISS_MS); + return () => window.clearTimeout(timer); + }, [ toast.mentionId ]); + + // Dismiss esplicito: segna letta (badge toolbar si aggiorna) + persiste sul server + chiude. + const onDismiss = (event: MouseEvent) => + { + event.stopPropagation(); + markRead(toast.mentionId); + SendMessageComposer(new MarkMentionsReadComposer(1, toast.mentionId)); + dismissMentionToast(toast.mentionId); + }; + + const onOpen = () => + { + CreateLinkEvent('mentions/toggle'); + dismissMentionToast(toast.mentionId); + }; + + return ( +
+
+ +
+
+
{ toast.senderUsername }
+
{ toast.message }
+
+ +
+ ); +}; + +export const MentionToastsView: FC = () => +{ + const toasts = useExternalSnapshot(subscribeMentionToasts, getMentionToasts); + + if(!toasts || !toasts.length) return null; + + return ( +
+ { toasts.map(toast => ) } +
+ ); +}; 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/room/widgets/chat-input/ChatInputMentionSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx new file mode 100644 index 0000000..370786f --- /dev/null +++ b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx @@ -0,0 +1,75 @@ +import { FC, useEffect, useRef } from 'react'; +import { LayoutAvatarImageView } from '../../../../common'; + +export type MentionSuggestionKind = 'user' | 'alias'; + +export interface MentionSuggestion +{ + key: string; + kind: MentionSuggestionKind; + name: string; + insertToken: string; + figure?: string; + description?: string; +} + +interface ChatInputMentionSelectorViewProps +{ + suggestions: MentionSuggestion[]; + selectedIndex: number; + onSelect: (suggestion: MentionSuggestion) => void; + onHover: (index: number) => void; +} + +export const ChatInputMentionSelectorView: FC = props => +{ + const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props; + const listRef = useRef(null); + + useEffect(() => + { + if(!listRef.current) return; + + const selected = listRef.current.children[selectedIndex] as HTMLElement; + + if(selected) selected.scrollIntoView({ block: 'nearest' }); + }, [ selectedIndex ]); + + if(suggestions.length === 0) return null; + + return ( +
+ { suggestions.map((suggestion, index) => + { + const isSelected = (index === selectedIndex); + const rowClass = `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ isSelected ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }`; + + return ( +
onSelect(suggestion) } + onMouseEnter={ () => onHover(index) } + > + { suggestion.kind === 'user' && suggestion.figure + ? ( +
+ +
+ ) + : ( +
@
+ ) } + @{ suggestion.name } + { suggestion.description && { suggestion.description } } +
+ ); + }) } +
+ ); +}; diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index 9bfc9b6..08ffe8e 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -4,11 +4,25 @@ import { createPortal } from 'react-dom'; import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api'; import { Text } from '../../../../common'; import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks'; +import { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots'; import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView'; import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView'; +import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView'; import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView'; -export const ChatInputView: FC = () => +const USER_TYPE_REAL_USER = 1; +const MAX_MENTION_SUGGESTIONS = 8; +const MENTION_ALIASES: ReadonlyArray<{ key: string; label: string; description?: string }> = [ + { key: 'all', label: 'all', description: 'Everyone in the hotel' }, + { key: 'everyone', label: 'everyone', description: 'Everyone in the hotel' }, + { key: 'tutti', label: 'tutti', description: 'Everyone in the hotel' }, + { key: 'friends', label: 'friends', description: 'Your online friends' }, + { key: 'amici', label: 'amici', description: 'Your online friends' }, + { key: 'room', label: 'room', description: 'Everyone in this room' }, + { key: 'stanza', label: 'stanza', description: 'Everyone in this room' } +]; + +export const ChatInputView: FC<{}> = props => { const [ chatValue, setChatValue ] = useState(''); const { chatStyleId = 0, updateChatStyleId = null } = useSessionInfo(); @@ -16,6 +30,98 @@ export const ChatInputView: FC = () => const { roomSession = null } = useRoom(); const inputRef = useRef(null); const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue); + const roomUserList = useRoomUserListSnapshot(); + const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState(0); + + const mentionContext = useMemo(() => + { + if(!chatValue) return null; + if(commandSelectorVisible) return null; + + const caret = inputRef.current?.selectionStart ?? chatValue.length; + const upToCaret = chatValue.slice(0, caret); + const at = upToCaret.lastIndexOf('@'); + if(at < 0) return null; + + if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null; + + const query = upToCaret.slice(at + 1); + + if(/\s/.test(query)) return null; + + return { atIndex: at, replaceFrom: at, replaceTo: caret, query }; + }, [ chatValue, commandSelectorVisible ]); + + const mentionSuggestions = useMemo(() => + { + if(!mentionContext) return []; + + const query = mentionContext.query.toLowerCase(); + const out: MentionSuggestion[] = []; + + for(const user of roomUserList) + { + if(!user || user.type !== USER_TYPE_REAL_USER) continue; + if(!user.name) continue; + if(query.length > 0 && !user.name.toLowerCase().startsWith(query)) continue; + + out.push({ + key: `user:${ user.webID }`, + kind: 'user', + name: user.name, + insertToken: user.name, + figure: user.figure || '' + }); + + if(out.length >= MAX_MENTION_SUGGESTIONS) break; + } + + for(const alias of MENTION_ALIASES) + { + if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue; + + out.push({ + key: `alias:${ alias.key }`, + kind: 'alias', + name: alias.label, + insertToken: alias.key, + description: alias.description + }); + + if(out.length >= MAX_MENTION_SUGGESTIONS) break; + } + + return out; + }, [ mentionContext, roomUserList ]); + + const mentionSelectorVisible = mentionSuggestions.length > 0; + + useEffect(() => + { + if(mentionSelectedIndex >= mentionSuggestions.length) setMentionSelectedIndex(0); + }, [ mentionSuggestions.length, mentionSelectedIndex ]); + + const applyMentionSuggestion = useCallback((suggestion: MentionSuggestion) => + { + if(!suggestion || !mentionContext) return; + + const before = chatValue.slice(0, mentionContext.replaceFrom); + const after = chatValue.slice(mentionContext.replaceTo); + const inserted = `@${ suggestion.insertToken } `; + const next = `${ before }${ inserted }${ after }`; + + setChatValue(next); + + requestAnimationFrame(() => + { + if(!inputRef.current) return; + const caret = before.length + inserted.length; + inputRef.current.focus(); + inputRef.current.setSelectionRange(caret, caret); + }); + + setMentionSelectedIndex(0); + }, [ chatValue, mentionContext ]); const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []); const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []); @@ -166,7 +272,6 @@ export const ChatInputView: FC = () => return; case 'Tab': event.preventDefault(); - // fall through case 'NumpadEnter': case 'Enter': { const selected = selectCurrent(); @@ -186,6 +291,45 @@ export const ChatInputView: FC = () => } } + if(mentionSelectorVisible) + { + switch(event.key) + { + case 'ArrowUp': + event.preventDefault(); + setMentionSelectedIndex(prev => (prev <= 0) ? (mentionSuggestions.length - 1) : (prev - 1)); + return; + case 'ArrowDown': + event.preventDefault(); + setMentionSelectedIndex(prev => (prev >= mentionSuggestions.length - 1) ? 0 : (prev + 1)); + return; + case 'Tab': + case 'NumpadEnter': + case 'Enter': { + const picked = mentionSuggestions[mentionSelectedIndex] ?? mentionSuggestions[0]; + + if(picked) + { + event.preventDefault(); + applyMentionSuggestion(picked); + return; + } + break; + } + case 'Escape': + event.preventDefault(); + setMentionSelectedIndex(0); + + if(mentionContext) + { + const before = chatValue.slice(0, mentionContext.replaceFrom); + const after = chatValue.slice(mentionContext.replaceTo); + setChatValue(before + after); + } + return; + } + } + const value = (event.target as HTMLInputElement).value; switch(event.key) @@ -211,7 +355,7 @@ export const ChatInputView: FC = () => return; } - }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, setChatInputValue, closeCommandSelector ]); + }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mentionSelectorVisible, mentionSuggestions, mentionSelectedIndex, applyMentionSuggestion, mentionContext, chatValue ]); useUiEvent(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event => { @@ -310,6 +454,13 @@ export const ChatInputView: FC = () => } } onHover={ setSelectedIndex } /> } + { mentionSelectorVisible && !commandSelectorVisible && + }
{ !floodBlocked && updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> } diff --git a/src/components/room/widgets/chat/highlightMentions.tsx b/src/components/room/widgets/chat/highlightMentions.tsx index 15d32dd..6c8620e 100644 --- a/src/components/room/widgets/chat/highlightMentions.tsx +++ b/src/components/room/widgets/chat/highlightMentions.tsx @@ -1,39 +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 `mentions.room.aliases` default in Arcturus -// (com.eu.habbo.habbohotel.mentions.MentionManager#roomAliases). export const MENTION_ROOM_ALIASES: ReadonlyArray = [ - 'amici', 'friends', 'all', 'everyone', 'tutti', 'room', 'stanza' + 'all', 'everyone', 'tutti', + 'friends', 'amici', + '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 ''; @@ -41,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); @@ -56,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, @@ -74,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 = ''; @@ -93,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; @@ -106,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, @@ -126,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; @@ -143,7 +88,6 @@ export const highlightMentions = ( break; } - // Text region before the next tag. if(tagStart > cursor) { result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet); @@ -153,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/mentions/MentionToasts.css b/src/css/mentions/MentionToasts.css new file mode 100644 index 0000000..6895c89 --- /dev/null +++ b/src/css/mentions/MentionToasts.css @@ -0,0 +1,131 @@ +.mention-toasts { + position: fixed; + top: 130px; + right: 12px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + pointer-events: none; +} + +.mention-toast { + pointer-events: auto; + display: flex; + align-items: center; + gap: 8px; + width: 300px; + padding: 8px 10px; + background: #2b2f3a; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.12); + border-left: 3px solid #1e7295; + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.42); + cursor: pointer; + animation: mention-toast-in 0.22s ease; +} + +@keyframes mention-toast-in { + from { opacity: 0; transform: translateX(22px); } + to { opacity: 1; transform: translateX(0); } +} + +.mention-toast-avatar { + position: relative; + width: 44px; + height: 44px; + flex-shrink: 0; + overflow: hidden; + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); +} + +/* ricetta testa headOnly: l'avatar-image riempie il box (inset-0) e si croppa + sulla testa via background-position (come l'avatar-testa della toolbar), + invece di scalare il corpo. */ +.mention-toast-avatar .avatar-image { + position: absolute !important; + inset: 0 !important; + width: 100% !important; + height: 100% !important; + background-size: auto !important; + background-position: -23px -32px !important; +} + +.mention-toast-body { + flex: 1 1 auto; + min-width: 0; +} + +.mention-toast-title { + font-weight: 700; + font-size: 12px; + line-height: 1.2; + color: #6cb6e0; +} + +.mention-toast-message { + font-size: 12px; + line-height: 1.3; + color: #e6e8ec; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-word; +} + +.mention-toast-dismiss { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + color: #c9ccd3; + cursor: pointer; + font-size: 10px; + line-height: 1; +} + +.mention-toast-dismiss:hover { + background: rgba(255, 255, 255, 0.18); + color: #fff; +} + +/* dropdown autocomplete @ : testine + alias */ +.mention-suggest-avatar { + position: relative; + width: 32px; + height: 32px; + flex-shrink: 0; + overflow: hidden; + border-radius: 4px; +} + +.mention-suggest-avatar .avatar-image { + position: absolute !important; + inset: 0 !important; + width: 100% !important; + height: 100% !important; + background-size: auto !important; + background-position: -27px -34px !important; +} + +.mention-suggest-alias { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + font-weight: 700; + color: #1e7295; + background: rgba(30, 114, 149, 0.12); + border-radius: 4px; +} 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/mentions/__tests__/mentionsStore.test.ts b/src/hooks/mentions/__tests__/mentionsStore.test.ts index cc3557d..e85b5fb 100644 --- a/src/hooks/mentions/__tests__/mentionsStore.test.ts +++ b/src/hooks/mentions/__tests__/mentionsStore.test.ts @@ -3,7 +3,7 @@ import { addMention, setMentions, markAllRead, markRead, getMentionsSnapshot, ge import { IMentionEntry } from '../../../api/mentions'; const make = (id: number, read = false): IMentionEntry => ({ - mentionId: id, senderId: 1, senderUsername: 'Bob', roomId: 9, roomName: 'R', + mentionId: id, senderId: 1, senderUsername: 'Bob', senderFigure: '', roomId: 9, roomName: 'R', message: '@me hi', mentionType: 0, timestamp: 0, read }); 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/mentionToastsStore.ts b/src/hooks/mentions/mentionToastsStore.ts new file mode 100644 index 0000000..07c5fec --- /dev/null +++ b/src/hooks/mentions/mentionToastsStore.ts @@ -0,0 +1,58 @@ +import { IMentionEntry } from '../../api'; + +// Toast laterali per le menzioni appena ricevute (avatar + messaggio + dismiss). +// Separato da mentionsStore: i toast sono effimeri, le menzioni persistono nel pannello. +export interface MentionToast +{ + mentionId: number; + senderId: number; + senderUsername: string; + senderFigure: string; + message: string; + roomName: string; +} + +const MAX_TOASTS = 4; + +let toasts: MentionToast[] = []; +const listeners = new Set<() => void>(); + +const emit = (): void => +{ + for(const listener of listeners) listener(); +}; + +export const subscribeMentionToasts = (callback: () => void): (() => void) => +{ + listeners.add(callback); + return () => { listeners.delete(callback); }; +}; + +export const getMentionToasts = (): ReadonlyArray => toasts; + +export const pushMentionToast = (entry: IMentionEntry): void => +{ + toasts = [ + { + mentionId: entry.mentionId, + senderId: entry.senderId, + senderUsername: entry.senderUsername, + senderFigure: entry.senderFigure, + message: entry.message, + roomName: entry.roomName + }, + ...toasts.filter(toast => toast.mentionId !== entry.mentionId) + ].slice(0, MAX_TOASTS); + + emit(); +}; + +export const dismissMentionToast = (mentionId: number): void => +{ + const next = toasts.filter(toast => toast.mentionId !== mentionId); + + if(next.length === toasts.length) return; + + toasts = next; + emit(); +}; diff --git a/src/hooks/mentions/useMentionAutocomplete.ts b/src/hooks/mentions/useMentionAutocomplete.ts new file mode 100644 index 0000000..6b2bbc1 --- /dev/null +++ b/src/hooks/mentions/useMentionAutocomplete.ts @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from 'react'; +import { MENTION_ROOM_ALIASES } from '../../components/room/widgets/chat/highlightMentions'; +import { useFriendsState } from '../friends/useFriends'; +import { useRoomUserListSnapshot } from '../session/useSessionSnapshots'; + +export interface MentionSuggestion +{ + name: string; + figure: string; + isAlias: boolean; +} + +const MAX_SUGGESTIONS = 8; + +// Trova il token @ che si sta digitando alla FINE del valore. +// Restituisce il parziale (anche '' subito dopo @) oppure null se non si è in un @mention. +const activeMentionPartial = (value: string): string | null => +{ + if(!value || value.indexOf('@') < 0) return null; + + const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value); + + return match ? match[1] : null; +}; + +export interface MentionAutocompleteState +{ + isVisible: boolean; + suggestions: MentionSuggestion[]; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + moveUp: () => void; + moveDown: () => void; + current: () => MentionSuggestion | null; + // Inserisce il nome scelto sostituendo il parziale @... alla fine del valore. + applyTo: (value: string, name: string) => string; +} + +export const useMentionAutocomplete = (chatValue: string): MentionAutocompleteState => +{ + const roomUsers = useRoomUserListSnapshot(); + const { onlineFriends } = useFriendsState(); + const [ selectedIndex, setSelectedIndex ] = useState(0); + + const partial = useMemo(() => activeMentionPartial(chatValue), [ chatValue ]); + + const suggestions = useMemo(() => + { + if(partial === null) return []; + + const query = partial.toLowerCase(); + const seen = new Set(); + const out: MentionSuggestion[] = []; + + const add = (name: string, figure: string, isAlias: boolean) => + { + if(!name || out.length >= MAX_SUGGESTIONS) return; + + const key = name.toLowerCase(); + + if(seen.has(key)) return; + if(query && !key.startsWith(query)) return; + + seen.add(key); + out.push({ name, figure: figure || '', isAlias }); + }; + + for(const user of (roomUsers || [])) add(user?.name, (user as any)?.figure, false); + for(const friend of (onlineFriends || [])) add(friend?.name, friend?.figure, false); + for(const alias of MENTION_ROOM_ALIASES) add(alias, '', true); + + return out; + }, [ partial, roomUsers, onlineFriends ]); + + useEffect(() => { setSelectedIndex(0); }, [ partial ]); + + const isVisible = (partial !== null) && (suggestions.length > 0); + + return { + isVisible, + suggestions, + selectedIndex, + setSelectedIndex, + moveUp: () => setSelectedIndex(index => (index <= 0 ? suggestions.length - 1 : index - 1)), + moveDown: () => setSelectedIndex(index => (index >= suggestions.length - 1 ? 0 : index + 1)), + current: () => suggestions[selectedIndex] ?? null, + applyTo: (value: string, name: string) => value.replace(/@([A-Za-z0-9_]*)$/, '@' + name + ' ') + }; +}; diff --git a/src/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts index 192b828..09cf0dd 100644 --- a/src/hooks/mentions/useMentionMessages.ts +++ b/src/hooks/mentions/useMentionMessages.ts @@ -1,17 +1,15 @@ import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; import { useCallback, useEffect } from 'react'; -import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api'; +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'; export const useMentionMessages = (): void => { - const { showSingleBubble } = useNotificationActions(); - const onMentionsList = useCallback((event: MentionsListEvent) => { const list = event.getParser().mentions; @@ -20,6 +18,7 @@ export const useMentionMessages = (): void => mentionId: m.mentionId, senderId: m.senderId, senderUsername: m.senderUsername, + senderFigure: m.senderFigure, roomId: m.roomId, roomName: m.roomName, message: m.message, @@ -39,6 +38,7 @@ export const useMentionMessages = (): void => mentionId: m.mentionId, senderId: m.senderId, senderUsername: m.senderUsername, + senderFigure: m.senderFigure, roomId: m.roomId, roomName: m.roomName, message: m.message, @@ -51,14 +51,9 @@ export const useMentionMessages = (): void => 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/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';