import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client'; import type { RoomAiConfig } from '@/components/room/MentionPopover'; import { memo, useCallback, useMemo } from 'react'; import { cn } from '@/lib/utils'; import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast'; type MentionType = 'repository' | 'user' | 'ai' | 'notify'; interface LegacyMentionToken { full: string; type: MentionType; name: string; } function isMentionNameChar(ch: string): boolean { return /[A-Za-z0-9._:\/-]/.test(ch); } /** * Parses legacy mention formats for backward compatibility with old messages: * - `name` (old backend format) * - `@type:name` (old colon format) * - `name` (old XML format) */ function extractLegacyMentionTokens(text: string): LegacyMentionToken[] { const tokens: LegacyMentionToken[] = []; let cursor = 0; while (cursor < text.length) { const angleOpen = text.indexOf('<', cursor); const atOpen = text.indexOf('@', cursor); let next: number; let style: 'angle' | 'at'; if (angleOpen >= 0 && (atOpen < 0 || angleOpen <= atOpen)) { next = angleOpen; style = 'angle'; } else if (atOpen >= 0) { next = atOpen; style = 'at'; } else { break; } const typeStart = next + 1; const colon = text.indexOf(':', typeStart); const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify']; if (colon >= 0 && colon - typeStart <= 20) { const typeRaw = text.slice(typeStart, colon).toLowerCase(); if (!validTypes.includes(typeRaw as MentionType)) { cursor = next + 1; continue; } let end = colon + 1; while (end < text.length && isMentionNameChar(text[end])) { end++; } const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end; const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket); if (name) { tokens.push({ full: text.slice(next, closeBracket), type: typeRaw as MentionType, name }); cursor = closeBracket; continue; } } if (style === 'angle') { let typeEnd = typeStart; while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) { typeEnd++; } const typeCandidate = text.slice(typeStart, typeEnd); if (validTypes.includes(typeCandidate as MentionType)) { const closeTag = ``; const contentStart = typeEnd; const tagClose = text.indexOf(closeTag, cursor); if (tagClose >= 0) { const name = text.slice(contentStart, tagClose); const fullMatch = text.slice(next, tagClose + closeTag.length); tokens.push({ full: fullMatch, type: typeCandidate as MentionType, name }); cursor = tagClose + closeTag.length; continue; } } } cursor = next + 1; } return tokens; } function extractFirstMentionName(text: string, type: MentionType): string | null { const token = extractLegacyMentionTokens(text).find((item) => item.type === type); return token?.name ?? null; } interface MessageContentWithMentionsProps { content: string; /** Members list for resolving user mention IDs to display names */ members?: RoomMemberResponse[]; /** Repository list for resolving repository mention IDs to display names */ repos?: ProjectRepositoryItem[]; /** AI configs for resolving AI mention IDs to display names */ aiConfigs?: RoomAiConfig[]; } const mentionStyles: Record = { user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60 transition-colors text-sm leading-5', repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium cursor-pointer hover:bg-purple-200 dark:hover:bg-purple-900/60 transition-colors text-sm leading-5', ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium cursor-pointer hover:bg-green-200 dark:hover:bg-green-900/60 transition-colors text-sm leading-5', notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium cursor-pointer hover:bg-yellow-200 dark:hover:bg-yellow-900/60 transition-colors text-sm leading-5', }; function renderNode( node: Node, index: number, resolveName: (type: string, id: string, label: string) => string, ): React.ReactNode { if (node.type === 'text') { return {node.text}; } if (node.type === 'mention') { const displayName = resolveName(node.mentionType, node.id, node.label); return ( @{displayName} ); } if (node.type === 'ai_action') { return ( /{node.action} {node.args ? ` ${node.args}` : ''} ); } return null; } /** Renders message content with @mention highlighting using styled spans. * Supports the new `label` format * and legacy formats for backward compatibility with old messages. */ export const MessageContentWithMentions = memo(function MessageContentWithMentions({ content, members = [], repos = [], aiConfigs = [], }: MessageContentWithMentionsProps) { const nodes = useMemo(() => { // Try the new AST parser first (handles and tags) const ast = parse(content); if (ast.length > 0) return ast; // Fall back to legacy parser for old-format messages const legacy = extractLegacyMentionTokens(content); if (legacy.length === 0) return [{ type: 'text' as const, text: content }]; const parts: Node[] = []; let cursor = 0; for (const token of legacy) { const idx = content.indexOf(token.full, cursor); if (idx === -1) continue; if (idx > cursor) { parts.push({ type: 'text', text: content.slice(cursor, idx) }); } parts.push({ type: 'mention', mentionType: token.type as MentionMentionType, id: '', label: token.name, }); cursor = idx + token.full.length; } if (cursor < content.length) { parts.push({ type: 'text', text: content.slice(cursor) }); } return parts; }, [content]); // Resolve ID → display name for each mention type const resolveName = useCallback( (type: string, id: string, label: string): string => { if (type === 'user') { const member = members.find((m) => m.user === id); return member?.user_info?.username ?? member?.user ?? label; } if (type === 'repository') { const repo = repos.find((r) => r.uid === id); return repo?.repo_name ?? label; } if (type === 'ai') { const cfg = aiConfigs.find((c) => c.model === id); return cfg?.modelName ?? cfg?.model ?? label; } return label; }, [members, repos, aiConfigs], ); return (
{nodes.map((node, i) => renderNode(node, i, resolveName))}
); }); /** Extract first mentioned user name from text */ export function extractMentionedUserUid( text: string, participants: Array<{ uid: string; name: string; is_ai: boolean }>, ): string | null { const userName = extractFirstMentionName(text, 'user'); if (!userName) return null; const user = participants.find((p) => !p.is_ai && p.name === userName); return user ? user.uid : null; } /** Extract first mentioned AI name from text */ export function extractMentionedAiUid( text: string, participants: Array<{ uid: string; name: string; is_ai: boolean }>, ): string | null { const aiName = extractFirstMentionName(text, 'ai'); if (!aiName) return null; const ai = participants.find((p) => p.is_ai && p.name === aiName); return ai ? ai.uid : null; }