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
+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>