- Pass members/repos/aiConfigs lists to MessageContentWithMentions - Add resolveName() that looks up ID → display name per mention type - RoomMessageBubble now resolves user/repository/AI mention UIDs to real names
246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
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:
|
|
* - `<user>name</user>` (old backend format)
|
|
* - `@type:name` (old colon format)
|
|
* - `<type>name</type>` (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 = `</${typeCandidate}>`;
|
|
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<string, string> = {
|
|
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 <span key={index}>{node.text}</span>;
|
|
}
|
|
if (node.type === 'mention') {
|
|
const displayName = resolveName(node.mentionType, node.id, node.label);
|
|
return (
|
|
<span key={index} className={mentionStyles[node.mentionType] ?? mentionStyles.user}>
|
|
@{displayName}
|
|
</span>
|
|
);
|
|
}
|
|
if (node.type === 'ai_action') {
|
|
return (
|
|
<span
|
|
key={index}
|
|
className="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-mono text-xs"
|
|
>
|
|
/{node.action}
|
|
{node.args ? ` ${node.args}` : ''}
|
|
</span>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Renders message content with @mention highlighting using styled spans.
|
|
* Supports the new `<mention type="..." id="...">label</mention>` 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 <mention> and <ai> 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 (
|
|
<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',
|
|
)}
|
|
>
|
|
{nodes.map((node, i) => renderNode(node, i, resolveName))}
|
|
</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;
|
|
}
|