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:
simoleo89
2026-06-06 23:35:33 +02:00
parent 110363ab1c
commit dcbf44aedb
35 changed files with 1220 additions and 657 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './useMentionsSnapshot';
export * from './useMentionMessages';
export * from './useMentionAutocomplete';
export * from './useMentionActions';
-58
View File
@@ -1,58 +0,0 @@
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();
};
+39
View File
@@ -0,0 +1,39 @@
import { CreateLinkEvent, DeleteMentionComposer, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { useMemo } from 'react';
import { IMentionEntry, SendMessageComposer } from '../../api';
import { markRead, removeMention } from './mentionsStore';
export interface MentionActions
{
/** Row click: mark the mention as read (no navigation). */
open: (mention: IMentionEntry) => void;
/** Explicit "go to room" action: mark read, then jump to the origin room. */
goto: (mention: IMentionEntry) => void;
/** Permanently delete the mention server-side (DeleteMentionComposer) and
* drop it from the local list, so it does not reappear after a relog. */
remove: (mention: IMentionEntry) => void;
}
const markReadOnServer = (mention: IMentionEntry): void =>
{
if(mention.read) return;
markRead(mention.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
};
// Shared action handlers used by both MentionsView and the chat-history
// "Menzioni" tab so behaviour can't diverge.
export const useMentionActions = (): MentionActions => useMemo(() => ({
open: (mention) => markReadOnServer(mention),
goto: (mention) =>
{
markReadOnServer(mention);
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
},
remove: (mention) =>
{
// Permanent server-side delete, then drop it from the local list.
SendMessageComposer(new DeleteMentionComposer(mention.mentionId));
removeMention(mention.mentionId);
}
}), []);
@@ -1,89 +0,0 @@
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 -4
View File
@@ -2,14 +2,16 @@ import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from
import { useCallback, useEffect } from 'react';
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNotification } from '../notification/useNotification';
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 { showMentionBubble } = useNotification();
const onMentionsList = useCallback((event: MentionsListEvent) =>
{
const list = event.getParser().mentions;
@@ -51,9 +53,10 @@ export const useMentionMessages = (): void =>
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
pushMentionToast(entry);
}, []);
// Surface it through the client's standard notification stream, using the
// dedicated mention bubble layout (avatar + actions).
showMentionBubble(entry);
}, [ showMentionBubble ]);
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);