import { memo, useMemo, useEffect } from 'react'; import { cn } from '@/lib/utils'; // Register web components for mentions function registerMentionComponents() { if (typeof window === 'undefined') return; if (customElements.get('mention-user')) return; class MentionUser extends HTMLElement { connectedCallback() { const name = this.getAttribute('name') || ''; this.attachShadow({ mode: 'open' }).innerHTML = ` @${name} `; } } class MentionRepo extends HTMLElement { connectedCallback() { const name = this.getAttribute('name') || ''; this.attachShadow({ mode: 'open' }).innerHTML = ` @${name} `; } } class MentionAi extends HTMLElement { connectedCallback() { const name = this.getAttribute('name') || ''; this.attachShadow({ mode: 'open' }).innerHTML = ` @${name} `; } } customElements.define('mention-user', MentionUser); customElements.define('mention-repo', MentionRepo); customElements.define('mention-ai', MentionAi); } type MentionType = 'repository' | 'user' | 'ai' | 'notify'; interface MentionToken { full: string; type: MentionType; name: string; } function isMentionNameChar(ch: string): boolean { return /[A-Za-z0-9._:\/-]/.test(ch); } function extractMentionTokens(text: string): MentionToken[] { const tokens: MentionToken[] = []; let cursor = 0; while (cursor < text.length) { // Find next potential mention: or = 0 && (atOpen < 0 || angleOpen <= atOpen)) { next = angleOpen; style = 'angle'; } else if (atOpen >= 0) { next = atOpen; style = 'at'; } else { break; } const typeStart = next + 1; // Check for colon format: or @type:name const colon = text.indexOf(':', typeStart); const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify']; if (colon >= 0 && colon - typeStart <= 20) { // Colon format: or @type:name 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++; } // For angle style, require closing > 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; } } // Check for XML-like format: name // Only for angle style if (style === 'angle') { // Find space or > to determine type end 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)) { // Found opening tag like const closeTag = ``; const contentStart = typeEnd; // Find the closing tag 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 = extractMentionTokens(text).find((item) => item.type === type); return token?.name ?? null; } interface MessageContentWithMentionsProps { content: string; } /** Renders message content with @mention highlighting using web components */ export const MessageContentWithMentions = memo(function MessageContentWithMentions({ content, }: MessageContentWithMentionsProps) { // Register web components on first render useEffect(() => { registerMentionComponents(); }, []); const processed = useMemo(() => { const tokens = extractMentionTokens(content); if (tokens.length === 0) return [{ type: 'text' as const, content }]; const parts: Array<{ type: 'text'; content: string } | { type: 'mention'; mention: MentionToken }> = []; let cursor = 0; for (const token of tokens) { const idx = content.indexOf(token.full, cursor); if (idx === -1) continue; if (idx > cursor) { parts.push({ type: 'text', content: content.slice(cursor, idx) }); } parts.push({ type: 'mention', mention: token }); cursor = idx + token.full.length; } if (cursor < content.length) { parts.push({ type: 'text', content: content.slice(cursor) }); } return parts; }, [content]); return (
{processed.map((part, i) => part.type === 'mention' ? ( part.mention.type === 'user' ? ( // @ts-ignore custom element ) : part.mention.type === 'repository' ? ( // @ts-ignore custom element ) : part.mention.type === 'ai' ? ( // @ts-ignore custom element ) : ( @{part.mention.name} ) ) : ( {part.content} ), )}
); }); /** 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; }