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
+10 -10
View File
@@ -1,5 +1,5 @@
import { FC, Fragment, ReactNode } from 'react';
import { tokenIsMention } from '../room/widgets/chat/highlightMentions';
import { classifyMentionToken } from '../../api/mentions/mentionTokens';
interface MentionMessageViewProps
{
@@ -9,10 +9,11 @@ interface MentionMessageViewProps
}
/**
* Renders a mention's message text as React nodes, wrapping the token(s) that
* mention the local user or a room-broadcast alias in a `.mention-highlight`
* span. Pure text segmentation (no innerHTML) → no XSS risk from other users'
* chat content. Original spacing is preserved verbatim.
* Renders a mention's message text as React nodes, wrapping every @user token
* in a `.mention-tag` span (with the `.mention-tag--self` modifier when the
* token targets the local user or a broadcast alias). Pure text segmentation
* (no innerHTML) → no XSS risk from other users' chat content. Original spacing
* is preserved verbatim.
*/
export const MentionMessageView: FC<MentionMessageViewProps> = props =>
{
@@ -24,12 +25,11 @@ export const MentionMessageView: FC<MentionMessageViewProps> = props =>
{
if(segment.length === 0) return null;
if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername))
{
return <Fragment key={ index }>{ segment }</Fragment>;
}
const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername);
return <span key={ index } className="mention-highlight">{ segment }</span>;
if(!kind) return <Fragment key={ index }>{ segment }</Fragment>;
return <span key={ index } className={ (kind === 'self') ? 'mention-tag mention-tag--self' : 'mention-tag' }>{ segment }</span>;
});
return <span className={ className }>{ nodes }</span>;
+26 -34
View File
@@ -1,8 +1,8 @@
import { FC, MouseEvent } from 'react';
import { IMentionEntry, LocalizeText, MentionType } from '../../api';
import { Flex, Text } from '../../common';
import { FaArrowRight, FaTimes } from 'react-icons/fa';
import { formatMentionTime, IMentionEntry, LocalizeText, MentionType } from '../../api';
import { LayoutAvatarImageView } from '../../common';
import { MentionMessageView } from './MentionMessageView';
import { formatMentionTime } from './mentionsFormat';
interface MentionRowViewProps
{
@@ -28,39 +28,31 @@ export const MentionRowView: FC<MentionRowViewProps> = props =>
};
return (
<Flex pointer alignItems="center" className="group relative px-1 py-[3px] rounded hover:bg-black/5" gap={ 2 } onClick={ () => onOpen(mention) }>
<span
className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` }
title={ mention.read ? '' : LocalizeText('mentions.filter.unread') } />
<span
title={ typeTitle }
className={ `flex items-center justify-center shrink-0 w-[18px] h-[18px] rounded text-[10px] font-bold leading-none text-white ${ isRoom ? 'bg-[#d08a1e]' : 'bg-[#1e7295]' }` }>
{ isRoom ? '@' : '@' }
</span>
<Flex grow column className="min-w-0" gap={ 0 }>
<Flex alignItems="center" gap={ 1 } className="min-w-0">
<Text bold={ !mention.read } truncate variant="primary">{ mention.senderUsername }</Text>
{ (mention.roomName && mention.roomName.length > 0) &&
<Text small truncate variant="gray">· { mention.roomName }</Text> }
</Flex>
<MentionMessageView className="block truncate text-black text-sm" ownUsername={ ownUsername } text={ mention.message } />
</Flex>
<Flex alignItems="center" gap={ 1 } className="shrink-0">
<div className={ `mention-row ${ mention.read ? '' : 'is-unread' }` } onClick={ () => onOpen(mention) }>
{ !mention.read &&
<span className="mention-row-unread-dot" aria-hidden /> }
<div className="mention-row-avatar" title={ typeTitle }>
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
<span className={ `mention-row-type ${ isRoom ? 'is-room' : 'is-direct' }` }>{ isRoom ? '' : '@' }</span>
</div>
<div className="mention-row-body">
<div className="mention-row-head">
<span className="mention-row-name">{ mention.senderUsername }</span>
{ (mention.roomName && (mention.roomName.length > 0)) &&
<span className="mention-row-room">· { mention.roomName }</span> }
</div>
<MentionMessageView className="mention-row-msg" ownUsername={ ownUsername } text={ mention.message } />
</div>
<div className="mention-row-meta">
{ (time.length > 0) &&
<Text small variant="gray" className="tabular-nums group-hover:hidden">{ time }</Text> }
<Flex alignItems="center" gap={ 1 } className="hidden group-hover:flex">
<span className="mention-row-time">{ time }</span> }
<div className="mention-row-actions">
{ onGoto &&
<span
title={ LocalizeText('mentions.action.goto') }
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-black/20 text-[12px] leading-none"
onClick={ event => stop(event, () => onGoto(mention)) }></span> }
<button type="button" className="mention-row-action" title={ LocalizeText('mentions.action.goto') } onClick={ event => stop(event, () => onGoto(mention)) }><FaArrowRight /></button> }
{ onRemove &&
<span
title={ LocalizeText('mentions.action.remove') }
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-red-500 hover:text-white text-[11px] leading-none"
onClick={ event => stop(event, () => onRemove(mention)) }></span> }
</Flex>
</Flex>
</Flex>
<button type="button" className="mention-row-action is-remove" title={ LocalizeText('mentions.action.remove') } onClick={ event => stop(event, () => onRemove(mention)) }><FaTimes /></button> }
</div>
</div>
</div>
);
};
@@ -1,63 +0,0 @@
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>
);
};
+65 -37
View File
@@ -1,13 +1,12 @@
import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useState } from 'react';
import { IMentionEntry, LocalizeText, MentionType, SendMessageComposer } from '../../api';
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMentionsSnapshot } from '../../hooks';
import { MarkMentionsReadComposer, RequestMentionsComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaSearch, FaSync } from 'react-icons/fa';
import { getMentionDateGroup, IMentionEntry, LocalizeText, MentionDateGroup, MentionType, SendMessageComposer } from '../../api';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useMentionActions, useMentionsSnapshot } from '../../hooks';
import { markAllRead } from '../../hooks/mentions/mentionsStore';
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
import { MentionRowView } from './MentionRowView';
import { getMentionDateGroup, MentionDateGroup } from './mentionsFormat';
import { useMentionActions } from './useMentionActions';
interface MentionsViewProps
{
@@ -41,6 +40,17 @@ const matchesFilter = (mention: IMentionEntry, filter: MentionFilter): boolean =
}
};
const matchesQuery = (mention: IMentionEntry, query: string): boolean =>
{
if(!query) return true;
const q = query.toLowerCase();
return ((mention.senderUsername || '').toLowerCase().includes(q)
|| (mention.roomName || '').toLowerCase().includes(q)
|| (mention.message || '').toLowerCase().includes(q));
};
export const MentionsView: FC<MentionsViewProps> = props =>
{
const { onClose } = props;
@@ -48,6 +58,15 @@ export const MentionsView: FC<MentionsViewProps> = props =>
const { userName: ownUsername = '' } = useUserDataSnapshot();
const { open, goto, remove } = useMentionActions();
const [ filter, setFilter ] = useState<MentionFilter>('all');
const [ query, setQuery ] = useState('');
// Re-request from the server: once on open, and on the manual refresh button.
const refresh = useCallback(() => SendMessageComposer(new RequestMentionsComposer()), []);
useEffect(() =>
{
refresh();
}, [ refresh ]);
const onMarkAll = useCallback(() =>
{
@@ -62,48 +81,57 @@ export const MentionsView: FC<MentionsViewProps> = props =>
for(const mention of mentions)
{
if(!matchesFilter(mention, filter)) continue;
if(!matchesQuery(mention, query)) continue;
buckets[getMentionDateGroup(mention.timestamp)].push(mention);
}
return GROUP_ORDER
.map(key => ({ key, items: buckets[key] }))
.filter(group => group.items.length > 0);
}, [ mentions, filter ]);
}, [ mentions, filter, query ]);
const hasAny = groups.length > 0;
const title = `${ LocalizeText('mentions.window.title') }${ (unreadCount > 0) ? ` (${ unreadCount })` : '' }`;
return (
<NitroCardView className="w-[360px] h-[440px] has-classic-scrollbar" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
<NitroCardView className="mentions-window w-[360px] has-classic-scrollbar" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ title } onCloseClick={ onClose } />
<NitroCardContentView gap={ 1 }>
<Flex alignItems="center" className="flex-wrap" gap={ 1 }>
{ FILTERS.map(({ key, label }) =>
{
const active = (filter === key);
const showCount = ((key === 'unread') && (unreadCount > 0));
<div className="mentions-search">
<FaSearch className="mentions-search-icon" />
<input
type="text"
value={ query }
placeholder={ LocalizeText('generic.search') }
onChange={ event => setQuery(event.target.value) } />
</div>
<div className="mentions-toolbar">
<div className="mentions-filters">
{ FILTERS.map(({ key, label }) =>
{
const active = (filter === key);
const showCount = ((key === 'unread') && (unreadCount > 0));
return (
<button
key={ key }
type="button"
onClick={ () => setFilter(key) }
className={ `px-2 py-[2px] rounded-full text-xs border transition-colors ${ active ? 'bg-[#1e7295] text-white border-[#1e7295]' : 'bg-black/5 text-black/70 border-transparent hover:bg-black/10' }` }>
{ LocalizeText(label) }{ showCount ? ` (${ unreadCount })` : '' }
</button>
);
}) }
</Flex>
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
return (
<button key={ key } type="button" className={ `mentions-filter ${ active ? 'is-active' : '' }` } onClick={ () => setFilter(key) }>
{ LocalizeText(label) }{ showCount ? ` ${ unreadCount }` : '' }
</button>
);
}) }
</div>
<button type="button" className="mentions-refresh" title="Aggiorna" onClick={ refresh }>
<FaSync />
</button>
</div>
<div className="mentions-list">
{ !hasAny &&
<Flex grow column center gap={ 2 } className="py-6 text-center">
<span className="flex items-center justify-center w-[44px] h-[44px] rounded-full bg-black/5 text-[#1e7295] text-[22px] font-bold">@</span>
<Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
</Flex> }
<div className="mentions-empty">
<span className="mentions-empty-glyph">@</span>
<span className="mentions-empty-text">{ LocalizeText('mentions.window.empty') }</span>
</div> }
{ hasAny && groups.map(group => (
<Flex key={ group.key } column gap={ 0 }>
<Text small bold variant="gray" className="px-1 pt-2 pb-[2px] uppercase tracking-wide">
{ LocalizeText(GROUP_LABEL[group.key]) }
</Text>
<div key={ group.key } className="mentions-group">
<div className="mentions-group-label">{ LocalizeText(GROUP_LABEL[group.key]) }</div>
{ group.items.map(mention => (
<MentionRowView
key={ mention.mentionId }
@@ -113,9 +141,9 @@ export const MentionsView: FC<MentionsViewProps> = props =>
onRemove={ remove }
ownUsername={ ownUsername } />
)) }
</Flex>
</div>
)) }
</Flex>
</div>
{ (unreadCount > 0) &&
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
</NitroCardContentView>
-3
View File
@@ -1,6 +1,3 @@
export * from './MentionMessageView';
export * from './MentionRowView';
export * from './MentionsView';
export * from './MentionToastsView';
export * from './mentionsFormat';
export * from './useMentionActions';
@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest';
import { formatMentionTime, getMentionDateGroup } from './mentionsFormat';
// Fixed reference "now": 2026-06-02 14:30 local time.
const NOW = new Date(2026, 5, 2, 14, 30, 0);
const at = (y: number, mo: number, d: number, h = 12, mi = 0): number => Math.floor(new Date(y, mo, d, h, mi, 0).getTime() / 1000);
describe('getMentionDateGroup', () =>
{
it('buckets same-day as today', () =>
{
expect(getMentionDateGroup(at(2026, 5, 2, 9, 15), NOW)).toBe('today');
});
it('buckets previous day as yesterday', () =>
{
expect(getMentionDateGroup(at(2026, 5, 1, 23, 59), NOW)).toBe('yesterday');
});
it('buckets two+ days ago as older', () =>
{
expect(getMentionDateGroup(at(2026, 4, 28, 10, 0), NOW)).toBe('older');
});
it('treats missing/zero timestamp as older', () =>
{
expect(getMentionDateGroup(0, NOW)).toBe('older');
});
});
describe('formatMentionTime', () =>
{
it('shows HH:MM (zero-padded) for today', () =>
{
expect(formatMentionTime(at(2026, 5, 2, 9, 5), NOW)).toBe('09:05');
});
it('shows HH:MM for yesterday', () =>
{
expect(formatMentionTime(at(2026, 5, 1, 18, 45), NOW)).toBe('18:45');
});
it('shows DD-MM for older entries', () =>
{
expect(formatMentionTime(at(2026, 4, 28, 10, 0), NOW)).toBe('28-05');
});
it('returns empty string for missing timestamp', () =>
{
expect(formatMentionTime(0, NOW)).toBe('');
});
});
-41
View File
@@ -1,41 +0,0 @@
// Date/time helpers for the mentions box. Kept framework-free and pure so they
// are unit-testable. Timestamps are unix SECONDS (as carried on the wire).
export type MentionDateGroup = 'today' | 'yesterday' | 'older';
const DAY_MS = 86_400_000;
const startOfDay = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const pad = (n: number): string => (n < 10 ? `0${ n }` : `${ n }`);
/**
* Bucket a mention timestamp into today / yesterday / older relative to `now`.
*/
export const getMentionDateGroup = (timestampSeconds: number, now: Date = new Date()): MentionDateGroup =>
{
if(!timestampSeconds || timestampSeconds <= 0) return 'older';
const ts = timestampSeconds * 1000;
const todayStart = startOfDay(now);
if(ts >= todayStart) return 'today';
if(ts >= (todayStart - DAY_MS)) return 'yesterday';
return 'older';
};
/**
* Compact per-row time label: HH:MM for today/yesterday (the section header
* disambiguates the day), DD-MM for older entries. Empty string when unknown.
*/
export const formatMentionTime = (timestampSeconds: number, now: Date = new Date()): string =>
{
if(!timestampSeconds || timestampSeconds <= 0) return '';
const d = new Date(timestampSeconds * 1000);
if(getMentionDateGroup(timestampSeconds, now) === 'older') return `${ pad(d.getDate()) }-${ pad(d.getMonth() + 1) }`;
return `${ pad(d.getHours()) }:${ pad(d.getMinutes()) }`;
};
@@ -1,39 +0,0 @@
import { CreateLinkEvent, DeleteMentionComposer, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { useMemo } from 'react';
import { IMentionEntry, SendMessageComposer } from '../../api';
import { markRead, removeMention } from '../../hooks/mentions/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);
}
}), []);