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,7 +1,8 @@
import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
import { MentionNotificationBubbleItem, NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
import { NotificationBadgeReceivedBubbleView } from './NotificationBadgeReceivedBubbleView';
import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView';
import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView';
import { NotificationMentionBubbleView } from './NotificationMentionBubbleView';
export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => void) =>
{
@@ -15,6 +16,8 @@ export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => voi
return <NotificationBadgeReceivedBubbleView key={ item.id } { ...props } />;
case NotificationBubbleType.CLUBGIFT:
return <NotificationClubGiftBubbleView key={ item.id } { ...props } />;
case NotificationBubbleType.MENTION:
return <NotificationMentionBubbleView key={ item.id } item={ item as MentionNotificationBubbleItem } onClose={ onClose } />;
default:
return <NotificationDefaultBubbleView key={ item.id } { ...props } />;
}
@@ -0,0 +1,89 @@
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, MouseEvent } from 'react';
import { FaTimes } from 'react-icons/fa';
import { formatMentionTime, LocalizeText, MentionNotificationBubbleItem, MentionType, SendMessageComposer } from '../../../../api';
import { Flex, LayoutAvatarImageView, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common';
import { markRead } from '../../../../hooks/mentions/mentionsStore';
import { useUserDataSnapshot } from '../../../../hooks/session/useSessionSnapshots';
import { MentionMessageView } from '../../../mentions/MentionMessageView';
export interface NotificationMentionBubbleViewProps extends LayoutNotificationBubbleViewProps
{
item: MentionNotificationBubbleItem;
}
export const NotificationMentionBubbleView: FC<NotificationMentionBubbleViewProps> = props =>
{
const { item = null, onClose = null, ...rest } = props;
const { userName: ownUsername = '' } = useUserDataSnapshot();
const mention = item.mention;
const isRoom = (mention.mentionType === MentionType.ROOM);
const time = formatMentionTime(mention.timestamp);
const markReadOnServer = () =>
{
markRead(mention.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
};
// Whole-bubble click opens the mentions panel (and dismisses the bubble).
const open = () =>
{
CreateLinkEvent('mentions/toggle');
onClose();
};
const goto = () =>
{
markReadOnServer();
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
onClose();
};
const act = (event: MouseEvent, fn: () => void) =>
{
event.stopPropagation();
fn();
};
return (
<LayoutNotificationBubbleView
alignItems="start"
gap={ 2 }
classNames={ [ 'w-[330px]', 'max-w-[92vw]', 'cursor-pointer' ] }
onClick={ open }
onClose={ onClose }
{ ...rest }>
<div className="mention-toast-avatar">
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
</div>
<Flex column gap={ 0 } className="min-w-0 flex-1">
<Flex alignItems="center" gap={ 1 } className="min-w-0">
<Text bold truncate variant="white">{ mention.senderUsername }</Text>
<span className={ `mention-toast-chip ${ isRoom ? 'is-room' : 'is-direct' }` }>
{ LocalizeText(isRoom ? 'mentions.type.room' : 'mentions.type.direct') }
</span>
<span className="mention-toast-spacer" />
{ (time.length > 0) &&
<span className="mention-toast-time">{ time }</span> }
<button type="button" className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } onClick={ event => act(event, onClose) }>
<FaTimes />
</button>
</Flex>
{ (mention.roomName && (mention.roomName.length > 0)) &&
<span className="mention-toast-room">· { mention.roomName }</span> }
<MentionMessageView className="mention-toast-message" ownUsername={ ownUsername } text={ mention.message } />
<Flex gap={ 1 } className="mention-toast-actions">
<button type="button" className="mention-toast-btn" onClick={ event => act(event, open) }>
{ LocalizeText('mentions.window.title') }
</button>
{ (mention.roomId > 0) &&
<button type="button" className="mention-toast-btn" onClick={ event => act(event, goto) }>
{ LocalizeText('mentions.action.goto') }
</button> }
</Flex>
</Flex>
</LayoutNotificationBubbleView>
);
};