diff --git a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx index c791599..39093fc 100644 --- a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx @@ -1,8 +1,10 @@ import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { ChatBubbleMessage } from '../../../../api'; +import { ChatBubbleMessage, GetConfigurationValue } from '../../../../api'; import { UserIdentityView } from '../../../../common'; import { useOnClickChat } from '../../../../hooks'; +import { useUserDataSnapshot } from '../../../../hooks/session/useSessionSnapshots'; +import { highlightMentions } from './highlightMentions'; interface ChatWidgetMessageViewProps { @@ -21,6 +23,15 @@ export const ChatWidgetMessageView: FC = ({ const [ isReady, setIsReady ] = useState(false); const elementRef = useRef(null); const { onClickChat } = useOnClickChat(); + const { userName: ownUsername = '' } = useUserDataSnapshot(); + + const mentionsHighlightOn = GetConfigurationValue('mentions_ui.enabled', true); + + const highlight = (html: string): string => (mentionsHighlightOn ? highlightMentions(html, ownUsername) : html); + + const formattedText = useMemo(() => highlight(`${ chat.formattedText }`), [ chat.formattedText, ownUsername, mentionsHighlightOn ]); + const originalFormattedText = useMemo(() => highlight(`${ chat.originalFormattedText || chat.formattedText }`), [ chat.originalFormattedText, chat.formattedText, ownUsername, mentionsHighlightOn ]); + const translatedFormattedText = useMemo(() => highlight(`${ chat.translatedFormattedText || chat.formattedText }`), [ chat.translatedFormattedText, chat.formattedText, ownUsername, mentionsHighlightOn ]); const getBubbleWidth = useMemo(() => { @@ -112,16 +123,16 @@ export const ChatWidgetMessageView: FC = ({ showColon={ true } username={ chat.username } /> { !chat.showTranslation && - } + } { chat.showTranslation &&
original: - +
translate: - +
} diff --git a/src/components/room/widgets/chat/highlightMentions.test.ts b/src/components/room/widgets/chat/highlightMentions.test.ts new file mode 100644 index 0000000..8c21557 --- /dev/null +++ b/src/components/room/widgets/chat/highlightMentions.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { highlightMentions, MENTION_ROOM_ALIASES } from './highlightMentions'; + +const OPEN = ''; +const CLOSE = ''; + +describe('highlightMentions', () => +{ + it('highlights the own-nick token', () => + { + const out = highlightMentions('hello @Bob how are you', 'Bob'); + + expect(out).toBe(`hello ${ OPEN }@Bob${ CLOSE } how are you`); + }); + + it('highlights a room-broadcast alias token', () => + { + const out = highlightMentions('@all party time', 'Bob'); + + expect(out).toBe(`${ OPEN }@all${ CLOSE } party time`); + }); + + it('highlights every configured room alias', () => + { + for(const alias of MENTION_ROOM_ALIASES) + { + const out = highlightMentions(`hey @${ alias }!`, 'Bob'); + + expect(out).toBe(`hey ${ OPEN }@${ alias }!${ CLOSE }`); + } + }); + + it('leaves non-mention text untouched', () => + { + const text = 'just a normal sentence with no at signs'; + + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('returns the message unchanged when there is no mention of me or an alias', () => + { + const text = 'hi @Charlie and @Dave'; + + // Neither @Charlie nor @Dave is the local user or a room alias. + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('matches a token with trailing punctuation (mirrors server stripping)', () => + { + const out = highlightMentions('watch out @Bob! seriously', 'Bob'); + + // The original token text (including the `!`) is kept inside the span. + expect(out).toBe(`watch out ${ OPEN }@Bob!${ CLOSE } seriously`); + }); + + it('matches case-insensitively but preserves the original casing', () => + { + const out = highlightMentions('yo @bOb whatup', 'BOB'); + + expect(out).toBe(`yo ${ OPEN }@bOb${ CLOSE } whatup`); + }); + + it('preserves the original spacing verbatim', () => + { + const out = highlightMentions('a @Bob\tb', 'Bob'); + + expect(out).toBe(`a ${ OPEN }@Bob${ CLOSE }\tb`); + }); + + it('does not highlight inside HTML tags produced by the formatter', () => + { + // Formatter output: wired bold markup around a mention. + const out = highlightMentions('hi @Bob', 'Bob'); + + expect(out).toBe(`hi ${ OPEN }@Bob${ CLOSE }`); + }); + + it('leaves font-colour spans and line breaks intact', () => + { + const html = 'hi @Bob
bye'; + const out = highlightMentions(html, 'Bob'); + + expect(out).toBe(`hi ${ OPEN }@Bob${ CLOSE }
bye`); + }); + + it('highlights multiple distinct mentions in one message', () => + { + const out = highlightMentions('@Bob and @all listen', 'Bob'); + + expect(out).toBe(`${ OPEN }@Bob${ CLOSE } and ${ OPEN }@all${ CLOSE } listen`); + }); + + it('ignores a bare @ with no nick', () => + { + const text = 'email me @ home'; + + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('returns input verbatim when there is no @ at all (fast path)', () => + { + const text = 'plain message'; + + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('returns input verbatim when own username is empty and no alias matches', () => + { + const text = 'hi @Charlie'; + + expect(highlightMentions(text, '')).toBe(text); + }); + + it('still highlights aliases when own username is empty', () => + { + const out = highlightMentions('@everyone hi', ''); + + expect(out).toBe(`${ OPEN }@everyone${ CLOSE } hi`); + }); +}); diff --git a/src/components/room/widgets/chat/highlightMentions.tsx b/src/components/room/widgets/chat/highlightMentions.tsx new file mode 100644 index 0000000..c61f9cc --- /dev/null +++ b/src/components/room/widgets/chat/highlightMentions.tsx @@ -0,0 +1,152 @@ +/** + * 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' +]; + +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 ''; + + 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; + + if(ownUsernameLower && nick === ownUsernameLower) return true; + + return aliases.has(nick); +}; + +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 = ''; + + for(const segment of segments) + { + if(segment.length === 0) continue; + + // Whitespace runs and non-mention tokens pass through untouched. + if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases)) + { + result += segment; + continue; + } + + result += `${ HIGHLIGHT_OPEN }${ segment }${ HIGHLIGHT_CLOSE }`; + } + + 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, + aliases: ReadonlyArray = MENTION_ROOM_ALIASES +): string => +{ + if(!formattedHtml || formattedHtml.indexOf('@') < 0) return formattedHtml; + + 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; + + while(cursor < formattedHtml.length) + { + const tagStart = formattedHtml.indexOf('<', cursor); + + if(tagStart < 0) + { + result += highlightTextChunk(formattedHtml.slice(cursor), ownUsernameLower, aliasSet); + break; + } + + // Text region before the next tag. + if(tagStart > cursor) + { + result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet); + } + + const tagEnd = formattedHtml.indexOf('>', tagStart); + + 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; + } + + return result; +}; diff --git a/src/css/chat/Chats.css b/src/css/chat/Chats.css index 13187d2..8dd0096 100644 --- a/src/css/chat/Chats.css +++ b/src/css/chat/Chats.css @@ -2396,3 +2396,12 @@ } } + +/* Mention highlight inside chat bubbles (cosmetic) */ +.mention-highlight { + font-weight: 700; + color: #1e7295; + background-color: rgba(30, 114, 149, 0.16); + border-radius: 3px; + padding: 0 2px; +}