mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
feat(mentions): overhaul, refactor, notification bubble & window update
Chat tagging: - Any @user is a visible tag in chat bubbles (the .mention-tag CSS never existed, so highlighting was invisible); self/alias mentions get a gold emphasis. Fixes cross-room tags not being highlighted. Mentions window: - Redesigned: unread count in the header, restyled filter chips + a refresh button, CSS-driven list/date-groups, adaptive height (compact when few, capped + scroll when many), polished empty state. - Rows: framed avatar (friends-list head crop so the face is never clipped), per-row unread dot, type marker, icon action buttons (goto / remove). - Re-requests from the server each time it opens. Autocomplete: - Never suggests the viewer themselves; suggests room users + online friends + aliases. Notifications: - Mention toast removed; mentions flow through the client's standard notification stream via a dedicated mention bubble (avatar + actions) in the default position. EVERY received mention surfaces (independent of the generic info-feed toggle, gated only by mentions_ui.enabled). Refactor (behaviour-preserving): - Centralised @-token classification in api/mentions/mentionTokens. - Moved mentionsFormat -> api/mentions, useMentionActions -> hooks/mentions. - Extracted ChatInputView @-autocomplete into a tested useChatMentions hook + pure helper; removed the dead duplicate useMentionAutocomplete.
This commit is contained in:
@@ -1,17 +1,6 @@
|
||||
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;
|
||||
}
|
||||
import { MentionSuggestion } from '../../../../hooks/rooms/widgets/useChatMentions.helpers';
|
||||
|
||||
interface ChatInputMentionSelectorViewProps
|
||||
{
|
||||
|
||||
@@ -3,50 +3,12 @@ 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 { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots';
|
||||
import { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useChatMentions, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
|
||||
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||
import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView';
|
||||
import { ChatInputMentionSelectorView } from './ChatInputMentionSelectorView';
|
||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||
|
||||
const USER_TYPE_REAL_USER = 1;
|
||||
const MAX_MENTION_SUGGESTIONS = 8;
|
||||
|
||||
type MentionAliasScope = 'everyone' | 'friends' | 'room';
|
||||
|
||||
const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions_ui.aliases.everyone',
|
||||
friends: 'mentions_ui.aliases.friends',
|
||||
room: 'mentions_ui.aliases.room'
|
||||
};
|
||||
|
||||
const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
|
||||
everyone: [ 'all', 'everyone', 'tutti' ],
|
||||
friends: [ 'friends', 'amici' ],
|
||||
room: [ 'room', 'stanza' ]
|
||||
};
|
||||
|
||||
const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions.alias.description.everyone',
|
||||
friends: 'mentions.alias.description.friends',
|
||||
room: 'mentions.alias.description.room'
|
||||
};
|
||||
|
||||
const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
|
||||
{
|
||||
if(!Array.isArray(raw)) return fallback;
|
||||
const out: string[] = [];
|
||||
for(const entry of raw)
|
||||
{
|
||||
if(typeof entry !== 'string') continue;
|
||||
const trimmed = entry.trim();
|
||||
if(!trimmed) continue;
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
const [ chatValue, setChatValue ] = useState<string>('');
|
||||
@@ -56,129 +18,13 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
|
||||
|
||||
const roomUserList = useRoomUserListSnapshot();
|
||||
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
|
||||
// The "New style" user-setting (memenu.settings.other.catalog.classic.style)
|
||||
// drives BOTH the catalog layout and the mention-picker chrome:
|
||||
// false (default) = Habbo old-school NitroCard cardstock look
|
||||
// true = flat minimalist gray look
|
||||
const [ newStyle ] = useCatalogClassicStyle();
|
||||
|
||||
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 mentionAliases = useMemo<ReadonlyArray<{ key: string; scope: MentionAliasScope; description: string }>>(() =>
|
||||
{
|
||||
const out: { key: string; scope: MentionAliasScope; description: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ];
|
||||
for(const scope of scopes)
|
||||
{
|
||||
const list = sanitizeAliasList(
|
||||
GetConfigurationValue<unknown>(MENTION_ALIAS_CONFIG_KEY[scope], MENTION_ALIAS_DEFAULTS[scope]),
|
||||
MENTION_ALIAS_DEFAULTS[scope]
|
||||
);
|
||||
|
||||
for(const key of list)
|
||||
{
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if(seen.has(lower)) continue;
|
||||
seen.add(lower);
|
||||
|
||||
out.push({ key, scope, description: LocalizeText(MENTION_ALIAS_DESCRIPTION_KEY[scope]) });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
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 mentionAliases)
|
||||
{
|
||||
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
|
||||
|
||||
out.push({
|
||||
key: `alias:${ alias.key }`,
|
||||
kind: 'alias',
|
||||
name: alias.key,
|
||||
insertToken: alias.key,
|
||||
description: alias.description
|
||||
});
|
||||
|
||||
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [ mentionContext, roomUserList, mentionAliases ]);
|
||||
|
||||
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 mention = useChatMentions(chatValue, setChatValue, inputRef, commandSelectorVisible);
|
||||
|
||||
const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
|
||||
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
|
||||
@@ -331,41 +177,30 @@ export const ChatInputView: FC<{}> = props =>
|
||||
}
|
||||
}
|
||||
|
||||
if(mentionSelectorVisible)
|
||||
if(mention.visible)
|
||||
{
|
||||
switch(event.key)
|
||||
{
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(prev => (prev <= 0) ? (mentionSuggestions.length - 1) : (prev - 1));
|
||||
mention.moveUp();
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(prev => (prev >= mentionSuggestions.length - 1) ? 0 : (prev + 1));
|
||||
mention.moveDown();
|
||||
return;
|
||||
case 'Tab':
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
const picked = mentionSuggestions[mentionSelectedIndex] ?? mentionSuggestions[0];
|
||||
|
||||
if(picked)
|
||||
case 'Enter':
|
||||
if(mention.applyCurrent())
|
||||
{
|
||||
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);
|
||||
}
|
||||
mention.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -395,7 +230,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
return;
|
||||
}
|
||||
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mentionSelectorVisible, mentionSuggestions, mentionSelectedIndex, applyMentionSuggestion, mentionContext, chatValue ]);
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mention, chatValue ]);
|
||||
|
||||
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||
{
|
||||
@@ -492,12 +327,12 @@ export const ChatInputView: FC<{}> = props =>
|
||||
onHover={ setSelectedIndex }
|
||||
newStyle={ newStyle }
|
||||
/> }
|
||||
{ mentionSelectorVisible && !commandSelectorVisible &&
|
||||
{ mention.visible && !commandSelectorVisible &&
|
||||
<ChatInputMentionSelectorView
|
||||
suggestions={ mentionSuggestions }
|
||||
selectedIndex={ mentionSelectedIndex }
|
||||
onSelect={ applyMentionSuggestion }
|
||||
onHover={ setMentionSelectedIndex }
|
||||
suggestions={ mention.suggestions }
|
||||
selectedIndex={ mention.selectedIndex }
|
||||
onSelect={ mention.apply }
|
||||
onHover={ mention.setSelectedIndex }
|
||||
newStyle={ newStyle }
|
||||
/> }
|
||||
<div className="flex-1 items-center input-sizer">
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlightMentions, MENTION_ROOM_ALIASES } from './highlightMentions';
|
||||
import { MENTION_ROOM_ALIASES } from '../../../../api/mentions/mentionTokens';
|
||||
import { highlightMentions } from './highlightMentions';
|
||||
|
||||
const OPEN = '<span class="mention-highlight">';
|
||||
const CLOSE = '</span>';
|
||||
// A generic @user tag, and a self/alias mention (strong).
|
||||
const TAG = (s: string) => `<span class="mention-tag">${ s }</span>`;
|
||||
const SELF = (s: string) => `<span class="mention-tag mention-tag--self">${ s }</span>`;
|
||||
|
||||
describe('highlightMentions', () =>
|
||||
{
|
||||
it('highlights the own-nick token', () =>
|
||||
it('marks the own-nick token as a self mention', () =>
|
||||
{
|
||||
const out = highlightMentions('hello @Bob how are you', 'Bob');
|
||||
|
||||
expect(out).toBe(`hello ${ OPEN }@Bob${ CLOSE } how are you`);
|
||||
expect(out).toBe(`hello ${ SELF('@Bob') } how are you`);
|
||||
});
|
||||
|
||||
it('highlights a room-broadcast alias token', () =>
|
||||
it('marks a room-broadcast alias token as a self mention', () =>
|
||||
{
|
||||
const out = highlightMentions('@all party time', 'Bob');
|
||||
|
||||
expect(out).toBe(`${ OPEN }@all${ CLOSE } party time`);
|
||||
expect(out).toBe(`${ SELF('@all') } party time`);
|
||||
});
|
||||
|
||||
it('highlights every configured room alias', () =>
|
||||
it('marks every configured room alias as a self mention', () =>
|
||||
{
|
||||
for(const alias of MENTION_ROOM_ALIASES)
|
||||
{
|
||||
const out = highlightMentions(`hey @${ alias }!`, 'Bob');
|
||||
|
||||
expect(out).toBe(`hey ${ OPEN }@${ alias }!${ CLOSE }`);
|
||||
expect(out).toBe(`hey ${ SELF(`@${ alias }!`) }`);
|
||||
}
|
||||
});
|
||||
|
||||
it('tags other users (not me, not an alias) as generic mentions', () =>
|
||||
{
|
||||
const out = highlightMentions('hi @Charlie and @Dave', 'Bob');
|
||||
|
||||
expect(out).toBe(`hi ${ TAG('@Charlie') } and ${ TAG('@Dave') }`);
|
||||
});
|
||||
|
||||
it('tags a cross-room user even when own username is empty', () =>
|
||||
{
|
||||
const out = highlightMentions('hi @Charlie', '');
|
||||
|
||||
expect(out).toBe(`hi ${ TAG('@Charlie') }`);
|
||||
});
|
||||
|
||||
it('leaves non-mention text untouched', () =>
|
||||
{
|
||||
const text = 'just a normal sentence with no at signs';
|
||||
@@ -37,42 +53,32 @@ describe('highlightMentions', () =>
|
||||
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)', () =>
|
||||
it('keeps trailing punctuation inside the span (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`);
|
||||
expect(out).toBe(`watch out ${ SELF('@Bob!') } 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`);
|
||||
expect(out).toBe(`yo ${ SELF('@bOb') } whatup`);
|
||||
});
|
||||
|
||||
it('preserves the original spacing verbatim', () =>
|
||||
{
|
||||
const out = highlightMentions('a @Bob\tb', 'Bob');
|
||||
|
||||
expect(out).toBe(`a ${ OPEN }@Bob${ CLOSE }\tb`);
|
||||
expect(out).toBe(`a ${ SELF('@Bob') }\tb`);
|
||||
});
|
||||
|
||||
it('does not highlight inside HTML tags produced by the formatter', () =>
|
||||
it('does not tag 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>`);
|
||||
expect(out).toBe(`<strong>hi ${ SELF('@Bob') }</strong>`);
|
||||
});
|
||||
|
||||
it('leaves font-colour spans and line breaks intact', () =>
|
||||
@@ -80,14 +86,14 @@ describe('highlightMentions', () =>
|
||||
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`);
|
||||
expect(out).toBe(`<span style="color:red">hi ${ SELF('@Bob') }</span><br />bye`);
|
||||
});
|
||||
|
||||
it('highlights multiple distinct mentions in one message', () =>
|
||||
it('handles a self mention and a generic tag in one message', () =>
|
||||
{
|
||||
const out = highlightMentions('@Bob and @all listen', 'Bob');
|
||||
const out = highlightMentions('@Bob and @Charlie listen', 'Bob');
|
||||
|
||||
expect(out).toBe(`${ OPEN }@Bob${ CLOSE } and ${ OPEN }@all${ CLOSE } listen`);
|
||||
expect(out).toBe(`${ SELF('@Bob') } and ${ TAG('@Charlie') } listen`);
|
||||
});
|
||||
|
||||
it('ignores a bare @ with no nick', () =>
|
||||
@@ -103,18 +109,4 @@ describe('highlightMentions', () =>
|
||||
|
||||
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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||
'all', 'everyone', 'tutti',
|
||||
'friends', 'amici',
|
||||
'room', 'stanza'
|
||||
];
|
||||
import { classifyMentionToken, MENTION_ROOM_ALIASES } from '../../../../api/mentions/mentionTokens';
|
||||
|
||||
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
|
||||
const TAG_CLASS = 'mention-tag';
|
||||
const SELF_CLASS = 'mention-tag mention-tag--self';
|
||||
|
||||
const normalizeToken = (token: string): string =>
|
||||
{
|
||||
if(!token || token.length < 2 || token.charAt(0) !== '@') return '';
|
||||
|
||||
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
};
|
||||
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const tokenIsMention = (
|
||||
token: string,
|
||||
ownUsername: string,
|
||||
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
|
||||
): boolean =>
|
||||
{
|
||||
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
return isMentionToken(token, ownUsernameLower, new Set(aliases.map(a => a.toLowerCase())));
|
||||
};
|
||||
|
||||
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
||||
const HIGHLIGHT_CLOSE = '</span>';
|
||||
|
||||
const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet<string>): string =>
|
||||
const highlightTextChunk = (chunk: string, ownUsername: string, aliases: ReadonlyArray<string>): string =>
|
||||
{
|
||||
if(chunk.indexOf('@') < 0) return chunk;
|
||||
|
||||
@@ -50,18 +15,26 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
||||
{
|
||||
if(segment.length === 0) continue;
|
||||
|
||||
if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases))
|
||||
const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername, aliases);
|
||||
|
||||
if(!kind)
|
||||
{
|
||||
result += segment;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += `${ HIGHLIGHT_OPEN }${ segment }${ HIGHLIGHT_CLOSE }`;
|
||||
result += `<span class="${ (kind === 'self') ? SELF_CLASS : TAG_CLASS }">${ segment }</span>`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap every @mention token in chat HTML with a `.mention-tag` span. Tokens
|
||||
* that target the viewer or a broadcast alias additionally get the
|
||||
* `.mention-tag--self` modifier so "I was mentioned" stands out. Walks around
|
||||
* formatter HTML (`<...>`) so tags inside markup are never touched.
|
||||
*/
|
||||
export const highlightMentions = (
|
||||
formattedHtml: string,
|
||||
ownUsername: string,
|
||||
@@ -70,11 +43,6 @@ export const highlightMentions = (
|
||||
{
|
||||
if(!formattedHtml || formattedHtml.indexOf('@') < 0) return formattedHtml;
|
||||
|
||||
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
|
||||
|
||||
if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml;
|
||||
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
|
||||
@@ -84,13 +52,13 @@ export const highlightMentions = (
|
||||
|
||||
if(tagStart < 0)
|
||||
{
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor), ownUsernameLower, aliasSet);
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor), ownUsername, aliases);
|
||||
break;
|
||||
}
|
||||
|
||||
if(tagStart > cursor)
|
||||
{
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsername, aliases);
|
||||
}
|
||||
|
||||
const tagEnd = formattedHtml.indexOf('>', tagStart);
|
||||
|
||||
Reference in New Issue
Block a user