// 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 = { everyone: 'mentions_ui.aliases.everyone', friends: 'mentions_ui.aliases.friends', room: 'mentions_ui.aliases.room' }; export const MENTION_ALIAS_DEFAULTS: Record = { everyone: [ 'all', 'everyone', 'tutti' ], friends: [ 'friends', 'amici' ], room: [ 'room', 'stanza' ] }; export const MENTION_ALIAS_DESCRIPTION_KEY: Record = { 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, aliases: ReadonlyArray, 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; };