mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
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:
@@ -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,2 +1,3 @@
|
||||
export * from './useMentionsSnapshot';
|
||||
export * from './useMentionMessages';
|
||||
export * from './useMentionAutocomplete';
|
||||
|
||||
@@ -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 + ' ')
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user