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
+1
View File
@@ -3,6 +3,7 @@ export interface IMentionEntry
mentionId: number;
senderId: number;
senderUsername: string;
senderFigure: string;
roomId: number;
roomName: string;
message: string;
+3 -1
View File
@@ -48,7 +48,7 @@ import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView
import { UserSettingsView } from './user-settings/UserSettingsView';
import { WiredView } from './wired/WiredView';
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
import { MentionsView } from './mentions';
import { MentionsView, MentionToastsView } from './mentions';
export const MainView: FC<{}> = props =>
{
@@ -242,6 +242,8 @@ export const MainView: FC<{}> = props =>
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
{ GetConfigurationValue<boolean>('mentions_ui.enabled', true) &&
<MentionToastsView /> }
<ExternalPluginLoader />
</>
);
@@ -0,0 +1,63 @@
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useEffect } from 'react';
import { FaTimes } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
import { LayoutAvatarImageView } from '../../common';
import { useExternalSnapshot } from '../../hooks/events/useExternalSnapshot';
import { markRead } from '../../hooks/mentions/mentionsStore';
import { dismissMentionToast, getMentionToasts, MentionToast, subscribeMentionToasts } from '../../hooks/mentions/mentionToastsStore';
// Quanto resta visibile un toast prima di nascondersi da solo (resta non-letto).
const AUTO_DISMISS_MS = 8000;
const MentionToastItemView: FC<{ toast: MentionToast }> = ({ toast }) =>
{
useEffect(() =>
{
const timer = window.setTimeout(() => dismissMentionToast(toast.mentionId), AUTO_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [ toast.mentionId ]);
// Dismiss esplicito: segna letta (badge toolbar si aggiorna) + persiste sul server + chiude.
const onDismiss = (event: MouseEvent) =>
{
event.stopPropagation();
markRead(toast.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, toast.mentionId));
dismissMentionToast(toast.mentionId);
};
const onOpen = () =>
{
CreateLinkEvent('mentions/toggle');
dismissMentionToast(toast.mentionId);
};
return (
<div className="mention-toast" onClick={ onOpen }>
<div className="mention-toast-avatar">
<LayoutAvatarImageView headOnly direction={ 2 } figure={ toast.senderFigure } />
</div>
<div className="mention-toast-body">
<div className="mention-toast-title">{ toast.senderUsername }</div>
<div className="mention-toast-message">{ toast.message }</div>
</div>
<button className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } type="button" onClick={ onDismiss }>
<FaTimes />
</button>
</div>
);
};
export const MentionToastsView: FC = () =>
{
const toasts = useExternalSnapshot(subscribeMentionToasts, getMentionToasts);
if(!toasts || !toasts.length) return null;
return (
<div className="mention-toasts">
{ toasts.map(toast => <MentionToastItemView key={ toast.mentionId } toast={ toast } />) }
</div>
);
};
+1
View File
@@ -1,5 +1,6 @@
export * from './MentionMessageView';
export * from './MentionRowView';
export * from './MentionsView';
export * from './MentionToastsView';
export * from './mentionsFormat';
export * from './useMentionActions';
@@ -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;
};
/**
+131
View File
@@ -0,0 +1,131 @@
.mention-toasts {
position: fixed;
top: 130px;
right: 12px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
pointer-events: none;
}
.mention-toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 8px;
width: 300px;
padding: 8px 10px;
background: #2b2f3a;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.12);
border-left: 3px solid #1e7295;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.42);
cursor: pointer;
animation: mention-toast-in 0.22s ease;
}
@keyframes mention-toast-in {
from { opacity: 0; transform: translateX(22px); }
to { opacity: 1; transform: translateX(0); }
}
.mention-toast-avatar {
position: relative;
width: 44px;
height: 44px;
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
}
/* ricetta testa headOnly: l'avatar-image riempie il box (inset-0) e si croppa
sulla testa via background-position (come l'avatar-testa della toolbar),
invece di scalare il corpo. */
.mention-toast-avatar .avatar-image {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
background-size: auto !important;
background-position: -23px -32px !important;
}
.mention-toast-body {
flex: 1 1 auto;
min-width: 0;
}
.mention-toast-title {
font-weight: 700;
font-size: 12px;
line-height: 1.2;
color: #6cb6e0;
}
.mention-toast-message {
font-size: 12px;
line-height: 1.3;
color: #e6e8ec;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
.mention-toast-dismiss {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: #c9ccd3;
cursor: pointer;
font-size: 10px;
line-height: 1;
}
.mention-toast-dismiss:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
/* dropdown autocomplete @ : testine + alias */
.mention-suggest-avatar {
position: relative;
width: 32px;
height: 32px;
flex-shrink: 0;
overflow: hidden;
border-radius: 4px;
}
.mention-suggest-avatar .avatar-image {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
background-size: auto !important;
background-position: -27px -34px !important;
}
.mention-suggest-alias {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-weight: 700;
color: #1e7295;
background: rgba(30, 114, 149, 0.12);
border-radius: 4px;
}
@@ -3,7 +3,7 @@ import { addMention, setMentions, markAllRead, markRead, getMentionsSnapshot, ge
import { IMentionEntry } from '../../../api/mentions';
const make = (id: number, read = false): IMentionEntry => ({
mentionId: id, senderId: 1, senderUsername: 'Bob', roomId: 9, roomName: 'R',
mentionId: id, senderId: 1, senderUsername: 'Bob', senderFigure: '', roomId: 9, roomName: 'R',
message: '@me hi', mentionType: 0, timestamp: 0, read
});
+1
View File
@@ -1,2 +1,3 @@
export * from './useMentionsSnapshot';
export * from './useMentionMessages';
export * from './useMentionAutocomplete';
+58
View File
@@ -0,0 +1,58 @@
import { IMentionEntry } from '../../api';
// Toast laterali per le menzioni appena ricevute (avatar + messaggio + dismiss).
// Separato da mentionsStore: i toast sono effimeri, le menzioni persistono nel pannello.
export interface MentionToast
{
mentionId: number;
senderId: number;
senderUsername: string;
senderFigure: string;
message: string;
roomName: string;
}
const MAX_TOASTS = 4;
let toasts: MentionToast[] = [];
const listeners = new Set<() => void>();
const emit = (): void =>
{
for(const listener of listeners) listener();
};
export const subscribeMentionToasts = (callback: () => void): (() => void) =>
{
listeners.add(callback);
return () => { listeners.delete(callback); };
};
export const getMentionToasts = (): ReadonlyArray<MentionToast> => toasts;
export const pushMentionToast = (entry: IMentionEntry): void =>
{
toasts = [
{
mentionId: entry.mentionId,
senderId: entry.senderId,
senderUsername: entry.senderUsername,
senderFigure: entry.senderFigure,
message: entry.message,
roomName: entry.roomName
},
...toasts.filter(toast => toast.mentionId !== entry.mentionId)
].slice(0, MAX_TOASTS);
emit();
};
export const dismissMentionToast = (mentionId: number): void =>
{
const next = toasts.filter(toast => toast.mentionId !== mentionId);
if(next.length === toasts.length) return;
toasts = next;
emit();
};
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useState } from 'react';
import { MENTION_ROOM_ALIASES } from '../../components/room/widgets/chat/highlightMentions';
import { useFriendsState } from '../friends/useFriends';
import { useRoomUserListSnapshot } from '../session/useSessionSnapshots';
export interface MentionSuggestion
{
name: string;
figure: string;
isAlias: boolean;
}
const MAX_SUGGESTIONS = 8;
// Trova il token @<parziale> che si sta digitando alla FINE del valore.
// Restituisce il parziale (anche '' subito dopo @) oppure null se non si è in un @mention.
const activeMentionPartial = (value: string): string | null =>
{
if(!value || value.indexOf('@') < 0) return null;
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
return match ? match[1] : null;
};
export interface MentionAutocompleteState
{
isVisible: boolean;
suggestions: MentionSuggestion[];
selectedIndex: number;
setSelectedIndex: (index: number) => void;
moveUp: () => void;
moveDown: () => void;
current: () => MentionSuggestion | null;
// Inserisce il nome scelto sostituendo il parziale @... alla fine del valore.
applyTo: (value: string, name: string) => string;
}
export const useMentionAutocomplete = (chatValue: string): MentionAutocompleteState =>
{
const roomUsers = useRoomUserListSnapshot();
const { onlineFriends } = useFriendsState();
const [ selectedIndex, setSelectedIndex ] = useState(0);
const partial = useMemo(() => activeMentionPartial(chatValue), [ chatValue ]);
const suggestions = useMemo<MentionSuggestion[]>(() =>
{
if(partial === null) return [];
const query = partial.toLowerCase();
const seen = new Set<string>();
const out: MentionSuggestion[] = [];
const add = (name: string, figure: string, isAlias: boolean) =>
{
if(!name || out.length >= MAX_SUGGESTIONS) return;
const key = name.toLowerCase();
if(seen.has(key)) return;
if(query && !key.startsWith(query)) return;
seen.add(key);
out.push({ name, figure: figure || '', isAlias });
};
for(const user of (roomUsers || [])) add(user?.name, (user as any)?.figure, false);
for(const friend of (onlineFriends || [])) add(friend?.name, friend?.figure, false);
for(const alias of MENTION_ROOM_ALIASES) add(alias, '', true);
return out;
}, [ partial, roomUsers, onlineFriends ]);
useEffect(() => { setSelectedIndex(0); }, [ partial ]);
const isVisible = (partial !== null) && (suggestions.length > 0);
return {
isVisible,
suggestions,
selectedIndex,
setSelectedIndex,
moveUp: () => setSelectedIndex(index => (index <= 0 ? suggestions.length - 1 : index - 1)),
moveDown: () => setSelectedIndex(index => (index >= suggestions.length - 1 ? 0 : index + 1)),
current: () => suggestions[selectedIndex] ?? null,
applyTo: (value: string, name: string) => value.replace(/@([A-Za-z0-9_]*)$/, '@' + name + ' ')
};
};
+7 -12
View File
@@ -1,17 +1,15 @@
import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect } from 'react';
import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api';
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNotificationActions } from '../notification';
import { addMention, setMentions } from './mentionsStore';
import { pushMentionToast } from './mentionToastsStore';
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
const MENTION_SOUND_SAMPLE = 'mentions_notification';
export const useMentionMessages = (): void =>
{
const { showSingleBubble } = useNotificationActions();
const onMentionsList = useCallback((event: MentionsListEvent) =>
{
const list = event.getParser().mentions;
@@ -20,6 +18,7 @@ export const useMentionMessages = (): void =>
mentionId: m.mentionId,
senderId: m.senderId,
senderUsername: m.senderUsername,
senderFigure: m.senderFigure,
roomId: m.roomId,
roomName: m.roomName,
message: m.message,
@@ -39,6 +38,7 @@ export const useMentionMessages = (): void =>
mentionId: m.mentionId,
senderId: m.senderId,
senderUsername: m.senderUsername,
senderFigure: m.senderFigure,
roomId: m.roomId,
roomName: m.roomName,
message: m.message,
@@ -51,14 +51,9 @@ export const useMentionMessages = (): void =>
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
showSingleBubble(
LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]),
NotificationBubbleType.INFO,
null,
'mentions/toggle',
entry.senderUsername
);
}, [ showSingleBubble ]);
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
pushMentionToast(entry);
}, []);
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
+1
View File
@@ -23,6 +23,7 @@ import './css/catalog/CatalogClassicView.css';
import './css/emustats/EmuStatsView.css';
import './css/chat/Chats.css';
import './css/mentions/MentionToasts.css';
import './css/common/Buttons.css';