Files
Nitro-V3/src/hooks/rooms/widgets/useChatMentions.helpers.ts
T
simoleo89 dcbf44aedb 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.
2026-06-06 23:37:17 +02:00

144 lines
4.1 KiB
TypeScript

// Pure helpers for the chat-input @-mention autocomplete. Kept framework-free
// so the suggestion building and the caret-context detection are unit-testable.
export type MentionSuggestionKind = 'user' | 'alias';
export interface MentionSuggestion
{
key: string;
kind: MentionSuggestionKind;
name: string;
insertToken: string;
figure?: string;
description?: string;
}
export type MentionAliasScope = 'everyone' | 'friends' | 'room';
export interface MentionAlias
{
key: string;
scope: MentionAliasScope;
description: string;
}
export interface MentionContext
{
atIndex: number;
replaceFrom: number;
replaceTo: number;
query: string;
}
export const MAX_MENTION_SUGGESTIONS = 8;
const USER_TYPE_REAL_USER = 1;
export const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
everyone: 'mentions_ui.aliases.everyone',
friends: 'mentions_ui.aliases.friends',
room: 'mentions_ui.aliases.room'
};
export const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
everyone: [ 'all', 'everyone', 'tutti' ],
friends: [ 'friends', 'amici' ],
room: [ 'room', 'stanza' ]
};
export const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
everyone: 'mentions.alias.description.everyone',
friends: 'mentions.alias.description.friends',
room: 'mentions.alias.description.room'
};
export const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
{
if(!Array.isArray(raw)) return fallback;
const out: string[] = [];
for(const entry of raw)
{
if(typeof entry !== 'string') continue;
const trimmed = entry.trim();
if(!trimmed) continue;
out.push(trimmed);
}
return out;
};
/**
* Detect an in-progress `@partial` token at the END of the input. Returns the
* token bounds + the query (text after `@`), or null when not in a mention (no
* trailing `@token`, `@` glued to a previous word, or a command popover open).
* End-anchored (no caret read) so it stays a pure render-safe computation.
*/
export const computeMentionContext = (value: string, commandSelectorVisible: boolean): MentionContext | null =>
{
if(!value) return null;
if(commandSelectorVisible) return null;
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
if(!match) return null;
const query = match[1];
const atIndex = value.length - query.length - 1;
return { atIndex, replaceFrom: atIndex, replaceTo: value.length, query };
};
interface MentionRoomUser
{
webID?: number;
type?: number;
name?: string;
figure?: string;
}
/**
* Build the suggestion list for the current query: real room users (minus the
* viewer themselves — match by id, name as fallback) then the broadcast
* aliases, prefix-filtered and capped.
*/
export const buildChatMentionSuggestions = (
query: string,
roomUserList: ReadonlyArray<MentionRoomUser>,
aliases: ReadonlyArray<MentionAlias>,
ownUserId: number,
ownUsername: string,
max: number = MAX_MENTION_SUGGESTIONS
): MentionSuggestion[] =>
{
const q = (query || '').toLowerCase();
const ownNameLower = (ownUsername || '').toLowerCase();
const out: MentionSuggestion[] = [];
for(const user of (roomUserList || []))
{
if(out.length >= max) break;
if(!user || (user.type !== USER_TYPE_REAL_USER)) continue;
if(!user.name) continue;
// You can't mention yourself — skip the own user (match by id, name as fallback).
if((user.webID === ownUserId) || (ownNameLower && (user.name.toLowerCase() === ownNameLower))) continue;
if((q.length > 0) && !user.name.toLowerCase().startsWith(q)) continue;
out.push({ key: `user:${ user.webID }`, kind: 'user', name: user.name, insertToken: user.name, figure: user.figure || '' });
}
for(const alias of aliases)
{
if(out.length >= max) break;
if((q.length > 0) && !alias.key.toLowerCase().startsWith(q)) continue;
out.push({ key: `alias:${ alias.key }`, kind: 'alias', name: alias.key, insertToken: alias.key, description: alias.description });
}
return out;
};