mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(mentions): highlight own mentions inside room chat bubbles
This commit is contained in:
@@ -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<ChatWidgetMessageViewProps> = ({
|
||||
const [ isReady, setIsReady ] = useState(false);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const { onClickChat } = useOnClickChat();
|
||||
const { userName: ownUsername = '' } = useUserDataSnapshot();
|
||||
|
||||
const mentionsHighlightOn = GetConfigurationValue<boolean>('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<ChatWidgetMessageViewProps> = ({
|
||||
showColon={ true }
|
||||
username={ chat.username } />
|
||||
{ !chat.showTranslation &&
|
||||
<span className={ `${ messageClassName } align-middle` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } /> }
|
||||
<span className={ `${ messageClassName } align-middle` } dangerouslySetInnerHTML={ { __html: formattedText } } onClick={ onClickChat } /> }
|
||||
{ chat.showTranslation &&
|
||||
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
|
||||
<div className="flex items-start gap-1 leading-[1.1]">
|
||||
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalFormattedText || chat.formattedText }` } } />
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: originalFormattedText } } />
|
||||
</div>
|
||||
<div className="flex items-start gap-1 leading-[1.1]">
|
||||
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedFormattedText || chat.formattedText }` } } />
|
||||
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: translatedFormattedText } } />
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlightMentions, MENTION_ROOM_ALIASES } from './highlightMentions';
|
||||
|
||||
const OPEN = '<span class="mention-highlight">';
|
||||
const CLOSE = '</span>';
|
||||
|
||||
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('<strong>hi @Bob</strong>', 'Bob');
|
||||
|
||||
expect(out).toBe(`<strong>hi ${ OPEN }@Bob${ CLOSE }</strong>`);
|
||||
});
|
||||
|
||||
it('leaves font-colour spans and line breaks intact', () =>
|
||||
{
|
||||
const html = '<span style="color:red">hi @Bob</span><br />bye';
|
||||
const out = highlightMentions(html, 'Bob');
|
||||
|
||||
expect(out).toBe(`<span style="color:red">hi ${ OPEN }@Bob${ CLOSE }</span><br />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`);
|
||||
});
|
||||
});
|
||||
@@ -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 `<strong>`/`<em>`/`<u>`, font-colour
|
||||
* `<span style>`, `<br />`, plus HTML-entity-encoded special characters) and
|
||||
* is injected via `dangerouslySetInnerHTML`. We therefore operate on the
|
||||
* already-formatted HTML string and wrap mention tokens that appear in the
|
||||
* TEXT regions (never inside a `<tag>`), returning a new HTML string. This
|
||||
* keeps every existing formatting behaviour intact and is purely visual — it
|
||||
* does not touch `chat.text`, parsing, chat history, or any wire payload.
|
||||
*
|
||||
* Token detection mirrors the server's `MentionManager.process` exactly:
|
||||
* - split on whitespace
|
||||
* - a candidate token has length >= 2 and starts with `@`
|
||||
* - strip the `@`, remove every char that is not [A-Za-z0-9_], lowercase
|
||||
* - match against the local username or a room-broadcast alias
|
||||
*
|
||||
* This means `@Bob!`, `@bob,` etc. all match the nick `Bob` (case-insensitive)
|
||||
* just like the server, while the highlighted span keeps the original token
|
||||
* text (`@` + original casing + trailing punctuation) verbatim.
|
||||
*/
|
||||
|
||||
// Mirror of `mentions.room.aliases` default in Arcturus
|
||||
// (com.eu.habbo.habbohotel.mentions.MentionManager#roomAliases).
|
||||
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||
'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<string>): boolean =>
|
||||
{
|
||||
const nick = normalizeToken(token);
|
||||
|
||||
if(!nick) return false;
|
||||
|
||||
if(ownUsernameLower && nick === ownUsernameLower) return true;
|
||||
|
||||
return aliases.has(nick);
|
||||
};
|
||||
|
||||
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
||||
const HIGHLIGHT_CLOSE = '</span>';
|
||||
|
||||
/**
|
||||
* Wrap mention tokens in a single text chunk (no HTML tags inside it).
|
||||
* Whitespace runs between tokens are preserved verbatim by re-using the
|
||||
* original substrings around each match.
|
||||
*/
|
||||
const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet<string>): string =>
|
||||
{
|
||||
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
|
||||
* `<span class="mention-highlight">…</span>`. HTML tags are passed through
|
||||
* untouched so existing markup keeps working.
|
||||
*
|
||||
* Returns the input unchanged when there is no `@`, no own username, and no
|
||||
* possibility of a match (fast path), or when nothing matches.
|
||||
*/
|
||||
export const highlightMentions = (
|
||||
formattedHtml: string,
|
||||
ownUsername: string,
|
||||
aliases: ReadonlyArray<string> = 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user