From 5a330be30e47e920be997fd667acac8d53486148 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 4 Jun 2026 10:41:36 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Updates=20Mention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatInputMentionSelectorView.tsx | 59 +++++-- .../room/widgets/chat-input/ChatInputView.tsx | 155 +++++++++++++++--- .../room/widgets/chat/highlightMentions.tsx | 73 +-------- 3 files changed, 181 insertions(+), 106 deletions(-) diff --git a/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx index f59c040..370786f 100644 --- a/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx @@ -1,6 +1,17 @@ import { FC, useEffect, useRef } from 'react'; import { LayoutAvatarImageView } from '../../../../common'; -import { MentionSuggestion } from '../../../../hooks/mentions/useMentionAutocomplete'; + +export type MentionSuggestionKind = 'user' | 'alias'; + +export interface MentionSuggestion +{ + key: string; + kind: MentionSuggestionKind; + name: string; + insertToken: string; + figure?: string; + description?: string; +} interface ChatInputMentionSelectorViewProps { @@ -24,23 +35,41 @@ export const ChatInputMentionSelectorView: FC if(selected) selected.scrollIntoView({ block: 'nearest' }); }, [ selectedIndex ]); + if(suggestions.length === 0) return null; + return (
- { suggestions.map((suggestion, index) => ( -
onSelect(suggestion) } - onMouseEnter={ () => onHover(index) } - > -
- { suggestion.isAlias - ? @ - : } + { 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 } }
- @{ suggestion.name } -
- )) } + ); + }) }
); }; diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index dcb368a..dd54940 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -3,12 +3,25 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api'; import { Text } from '../../../../common'; -import { useChatCommandSelector, useChatInputWidget, useMentionAutocomplete, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks'; +import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks'; +import { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots'; import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView'; import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView'; -import { ChatInputMentionSelectorView } from './ChatInputMentionSelectorView'; +import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView'; import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView'; +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(''); @@ -17,7 +30,98 @@ export const ChatInputView: FC<{}> = props => const { roomSession = null } = useRoom(); const inputRef = useRef(null); const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue); - const mention = useMentionAutocomplete(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'), []); @@ -151,7 +255,6 @@ export const ChatInputView: FC<{}> = props => return; case 'Tab': event.preventDefault(); - // fall through case 'NumpadEnter': case 'Enter': { const selected = selectCurrent(); @@ -171,38 +274,47 @@ export const ChatInputView: FC<{}> = props => } } - const value = (event.target as HTMLInputElement).value; - - if(mention.isVisible) + if(mentionSelectorVisible) { switch(event.key) { case 'ArrowUp': event.preventDefault(); - mention.moveUp(); + setMentionSelectedIndex(prev => (prev <= 0) ? (mentionSuggestions.length - 1) : (prev - 1)); return; case 'ArrowDown': event.preventDefault(); - mention.moveDown(); + setMentionSelectedIndex(prev => (prev >= mentionSuggestions.length - 1) ? 0 : (prev + 1)); return; case 'Tab': - event.preventDefault(); - // fall through case 'NumpadEnter': case 'Enter': { - const current = mention.current(); + const picked = mentionSuggestions[mentionSelectedIndex] ?? mentionSuggestions[0]; - if(current) + if(picked) { event.preventDefault(); - setChatValue(prev => mention.applyTo(prev, current.name)); + 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) { case ' ': @@ -226,7 +338,7 @@ export const ChatInputView: FC<{}> = props => return; } - }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mention ]); + }, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mentionSelectorVisible, mentionSuggestions, mentionSelectedIndex, applyMentionSuggestion, mentionContext, chatValue ]); useUiEvent(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event => { @@ -322,15 +434,12 @@ export const ChatInputView: FC<{}> = props => } } onHover={ setSelectedIndex } /> } - { (!commandSelectorVisible && mention.isVisible) && + { mentionSelectorVisible && !commandSelectorVisible && - { - setChatValue(prev => mention.applyTo(prev, suggestion.name)); inputRef.current?.focus(); - } } - onHover={ mention.setSelectedIndex } + suggestions={ mentionSuggestions } + selectedIndex={ mentionSelectedIndex } + onSelect={ applyMentionSuggestion } + onHover={ setMentionSelectedIndex } /> }
{ !floodBlocked && diff --git a/src/components/room/widgets/chat/highlightMentions.tsx b/src/components/room/widgets/chat/highlightMentions.tsx index 14f0cc0..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,31 +13,18 @@ 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); if(!nick) return false; - // Own nick and room-broadcast aliases always count. if(ownUsernameLower && nick === ownUsernameLower) return true; - if(aliases.has(nick)) return true; - // Any other valid @nick token is also highlighted (blue), so a direct - // @username mention reads the same as @all — visual feedback that it is a - // recognised mention. (Cosmetic only; the server decides actual delivery.) - return true; + 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, @@ -79,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 = ''; @@ -98,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; @@ -111,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, @@ -131,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; @@ -148,7 +88,6 @@ export const highlightMentions = ( break; } - // Text region before the next tag. if(tagStart > cursor) { result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet); @@ -158,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; }