321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
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 = `
|
|
<style>
|
|
:host {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
background: rgba(59, 130, 246, 0.15);
|
|
color: #3b82f6;
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 0.25rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
line-height: 1.25rem;
|
|
transition: background 0.15s;
|
|
}
|
|
:host(:hover) {
|
|
background: rgba(59, 130, 246, 0.25);
|
|
}
|
|
.icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
margin-right: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="12" cy="7" r="4"/>
|
|
</svg>
|
|
<span>@${name}</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
class MentionRepo extends HTMLElement {
|
|
connectedCallback() {
|
|
const name = this.getAttribute('name') || '';
|
|
this.attachShadow({ mode: 'open' }).innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
background: rgba(168, 85, 247, 0.15);
|
|
color: #a855f7;
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 0.25rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
line-height: 1.25rem;
|
|
transition: background 0.15s;
|
|
}
|
|
:host(:hover) {
|
|
background: rgba(168, 85, 247, 0.25);
|
|
}
|
|
.icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
margin-right: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
<span>@${name}</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
class MentionAi extends HTMLElement {
|
|
connectedCallback() {
|
|
const name = this.getAttribute('name') || '';
|
|
this.attachShadow({ mode: 'open' }).innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
background: rgba(34, 197, 94, 0.15);
|
|
color: #22c55e;
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 0.25rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
line-height: 1.25rem;
|
|
transition: background 0.15s;
|
|
}
|
|
:host(:hover) {
|
|
background: rgba(34, 197, 94, 0.25);
|
|
}
|
|
.icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
margin-right: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"/>
|
|
<circle cx="8" cy="14" r="1"/>
|
|
<circle cx="16" cy="14" r="1"/>
|
|
</svg>
|
|
<span>@${name}</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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: <user> or <user: or @user:
|
|
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;
|
|
|
|
// Check for colon format: <type:name> or @type:name
|
|
const colon = text.indexOf(':', typeStart);
|
|
const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify'];
|
|
|
|
if (colon >= 0 && colon - typeStart <= 20) {
|
|
// Colon format: <type:name> 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: <type>name</type>
|
|
// 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 <user>
|
|
const closeTag = `</${typeCandidate}>`;
|
|
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 (
|
|
<div
|
|
className={cn(
|
|
'text-sm text-foreground',
|
|
'max-w-full min-w-0 break-words whitespace-pre-wrap',
|
|
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
|
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
|
)}
|
|
>
|
|
{processed.map((part, i) =>
|
|
part.type === 'mention' ? (
|
|
part.mention.type === 'user' ? (
|
|
// @ts-ignore custom element
|
|
<mention-user key={i} name={part.mention.name} />
|
|
) : part.mention.type === 'repository' ? (
|
|
// @ts-ignore custom element
|
|
<mention-repo key={i} name={part.mention.name} />
|
|
) : part.mention.type === 'ai' ? (
|
|
// @ts-ignore custom element
|
|
<mention-ai key={i} name={part.mention.name} />
|
|
) : (
|
|
<span
|
|
key={i}
|
|
className="inline-flex items-center rounded bg-blue-100 px-1 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"
|
|
>
|
|
@{part.mention.name}
|
|
</span>
|
|
)
|
|
) : (
|
|
<span key={i}>{part.content}</span>
|
|
),
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/** 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;
|
|
}
|