mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆙 Updates Mention
This commit is contained in:
@@ -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<ChatInputMentionSelectorViewProps>
|
||||
if(selected) selected.scrollIntoView({ block: 'nearest' });
|
||||
}, [ selectedIndex ]);
|
||||
|
||||
if(suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
|
||||
{ suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={ suggestion.name }
|
||||
className={ `px-2 py-1 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
|
||||
onClick={ () => onSelect(suggestion) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
<div className="mention-suggest-avatar">
|
||||
{ suggestion.isAlias
|
||||
? <span className="mention-suggest-alias">@</span>
|
||||
: <LayoutAvatarImageView headOnly direction={ 2 } figure={ suggestion.figure } /> }
|
||||
{ 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 (
|
||||
<div
|
||||
key={ suggestion.key }
|
||||
className={ rowClass }
|
||||
onClick={ () => onSelect(suggestion) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
{ suggestion.kind === 'user' && suggestion.figure
|
||||
? (
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-black/10">
|
||||
<LayoutAvatarImageView
|
||||
figure={ suggestion.figure }
|
||||
direction={ 2 }
|
||||
headOnly
|
||||
style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } }
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-black/20 text-white text-[14px] font-bold shrink-0">@</div>
|
||||
) }
|
||||
<span className="font-bold">@{ suggestion.name }</span>
|
||||
{ suggestion.description && <span className={ `text-xs ${ isSelected ? 'text-gray-300' : 'text-gray-500' }` }>{ suggestion.description }</span> }
|
||||
</div>
|
||||
<span className="font-bold">@{ suggestion.name }</span>
|
||||
</div>
|
||||
)) }
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string>('');
|
||||
@@ -17,7 +30,98 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const { roomSession = null } = useRoom();
|
||||
const inputRef = useRef<HTMLInputElement>(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<number>(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<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 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>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||
{
|
||||
@@ -322,15 +434,12 @@ export const ChatInputView: FC<{}> = props =>
|
||||
} }
|
||||
onHover={ setSelectedIndex }
|
||||
/> }
|
||||
{ (!commandSelectorVisible && mention.isVisible) &&
|
||||
{ mentionSelectorVisible && !commandSelectorVisible &&
|
||||
<ChatInputMentionSelectorView
|
||||
suggestions={ mention.suggestions }
|
||||
selectedIndex={ mention.selectedIndex }
|
||||
onSelect={ (suggestion) =>
|
||||
{
|
||||
setChatValue(prev => mention.applyTo(prev, suggestion.name)); inputRef.current?.focus();
|
||||
} }
|
||||
onHover={ mention.setSelectedIndex }
|
||||
suggestions={ mentionSuggestions }
|
||||
selectedIndex={ mentionSelectedIndex }
|
||||
onSelect={ applyMentionSuggestion }
|
||||
onHover={ setMentionSelectedIndex }
|
||||
/> }
|
||||
<div className="flex-1 items-center input-sizer">
|
||||
{ !floodBlocked &&
|
||||
|
||||
@@ -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 `<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'
|
||||
'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<string>): 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 = '<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 = '';
|
||||
@@ -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
|
||||
* `<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,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user