feat(mentions): @ autocomplete, blue @nick, avatar notification toast

- Chat input @ autocomplete: typing @ shows online users (room users +
  online friends + room aliases) with avatars; arrows/Tab/Enter to pick.
- Any valid @nick token is highlighted blue in chat bubbles (like @all),
  giving visual feedback that it is a recognised mention.
- Side notification toast on a received mention: sender avatar (from the
  new senderFigure wire field) + message + dismiss; dismiss marks it read
  so the toolbar unread badge updates. Auto-hides after 8s.
- IMentionEntry/parsers carry senderFigure end to end.
This commit is contained in:
medievalshell
2026-06-04 01:18:26 +02:00
parent f8e943d262
commit 0df810c556
14 changed files with 452 additions and 17 deletions
@@ -0,0 +1,46 @@
import { FC, useEffect, useRef } from 'react';
import { LayoutAvatarImageView } from '../../../../common';
import { MentionSuggestion } from '../../../../hooks/mentions/useMentionAutocomplete';
interface ChatInputMentionSelectorViewProps
{
suggestions: MentionSuggestion[];
selectedIndex: number;
onSelect: (suggestion: MentionSuggestion) => void;
onHover: (index: number) => void;
}
export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps> = props =>
{
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
const listRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
if(!listRef.current) return;
const selected = listRef.current.children[selectedIndex] as HTMLElement;
if(selected) selected.scrollIntoView({ block: 'nearest' });
}, [ selectedIndex ]);
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 } /> }
</div>
<span className="font-bold">@{ suggestion.name }</span>
</div>
)) }
</div>
);
};
@@ -3,9 +3,10 @@ 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, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { useChatCommandSelector, useChatInputWidget, useMentionAutocomplete, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
import { ChatInputMentionSelectorView } from './ChatInputMentionSelectorView';
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
export const ChatInputView: FC<{}> = props =>
@@ -16,6 +17,7 @@ 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 chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
@@ -171,6 +173,36 @@ export const ChatInputView: FC<{}> = props =>
const value = (event.target as HTMLInputElement).value;
if(mention.isVisible)
{
switch(event.key)
{
case 'ArrowUp':
event.preventDefault();
mention.moveUp();
return;
case 'ArrowDown':
event.preventDefault();
mention.moveDown();
return;
case 'Tab':
event.preventDefault();
// fall through
case 'NumpadEnter':
case 'Enter': {
const current = mention.current();
if(current)
{
event.preventDefault();
setChatValue(prev => mention.applyTo(prev, current.name));
return;
}
break;
}
}
}
switch(event.key)
{
case ' ':
@@ -194,7 +226,7 @@ export const ChatInputView: FC<{}> = props =>
return;
}
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector ]);
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mention ]);
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
{
@@ -290,6 +322,16 @@ export const ChatInputView: FC<{}> = props =>
} }
onHover={ setSelectedIndex }
/> }
{ (!commandSelectorVisible && mention.isVisible) &&
<ChatInputMentionSelectorView
suggestions={ mention.suggestions }
selectedIndex={ mention.selectedIndex }
onSelect={ (suggestion) =>
{
setChatValue(prev => mention.applyTo(prev, suggestion.name)); inputRef.current?.focus();
} }
onHover={ mention.setSelectedIndex }
/> }
<div className="flex-1 items-center input-sizer">
{ !floodBlocked &&
<input ref={ inputRef } className="w-full border-none bg-transparent px-[10px] text-[0.86rem] text-black placeholder:text-[#6c757d] focus:border-current focus:shadow-none focus:ring-0" maxLength={ maxChatLength } placeholder={ LocalizeText('widgets.chatinput.default') } type="text" value={ chatValue } onChange={ event => updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> }
@@ -51,9 +51,14 @@ const isMentionToken = (token: string, ownUsernameLower: string, aliases: Readon
if(!nick) return false;
// Own nick and room-broadcast aliases always count.
if(ownUsernameLower && nick === ownUsernameLower) return true;
if(aliases.has(nick)) return true;
return aliases.has(nick);
// 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;
};
/**