/** Encoded mention token stored in comment text: `@[userId:Display Name]`. */ export const MENTION_TOKEN_PATTERN = /@\[([^:\]]+):([^\]]+)\]/g; export type CommentBodySegment = { type: "text"; value: string } | { type: "mention"; userId: string; label: string }; export type MentionLookup = { id: string | number; name: string; }; export function encodeMentionToken(userId: string | number, userName: string): string { const trimmedName = userName.trim(); return `@[${String(userId)}:${trimmedName}]`; } export function extractMentionIds(text: string): string[] { const ids: string[] = []; const pattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g"); for (const match of text.matchAll(pattern)) { const userId = match[1]; if (userId && !ids.includes(userId)) { ids.push(userId); } } return ids; } export function toDisplayText(text: string): string { return text.replace(MENTION_TOKEN_PATTERN, (_full, _userId: string, userName: string) => { return `@${userName}`; }); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function pushTextSegment(segments: CommentBodySegment[], value: string): void { if (!value) { return; } const last = segments.length > 0 ? segments[segments.length - 1] : undefined; if (last?.type === "text") { last.value += value; return; } segments.push({ type: "text", value }); } function resolveMentionNames( mentionIds: string[] | undefined, users: MentionLookup[] | undefined, ): Map { const byId = new Map(); const usersById = new Map((users ?? []).map((user) => [String(user.id), user.name] as const)); // Only ids that were actually mentioned — never seed the full dispatcher roster. for (const mentionId of mentionIds ?? []) { const id = String(mentionId); byId.set(id, usersById.get(id) ?? id); } return byId; } function appendPlainMentionMatches( segments: CommentBodySegment[], plainText: string, mentionNames: string[], ): void { if (!plainText || mentionNames.length === 0) { pushTextSegment(segments, plainText); return; } const sortedNames = [...mentionNames].sort((a, b) => b.length - a.length); const namePattern = sortedNames.map((name) => escapeRegExp(name)).join("|"); // Require a non-word boundary after the name so "@Ann" does not match inside "@Annual". const mentionRegex = new RegExp(`@(${namePattern})(?![\\w])`, "g"); let cursor = 0; for (const match of plainText.matchAll(mentionRegex)) { const index = match.index ?? 0; pushTextSegment(segments, plainText.slice(cursor, index)); const label = match[1] ?? ""; segments.push({ type: "mention", userId: label, label }); cursor = index + match[0].length; } pushTextSegment(segments, plainText.slice(cursor)); } export function buildCommentBodySegments( text: string, mentionIds?: string[], users?: MentionLookup[], ): CommentBodySegment[] { if (!text) { return []; } const segments: CommentBodySegment[] = []; const encodedPattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g"); const mentionNameById = resolveMentionNames(mentionIds, users); const encodedLabels: string[] = []; for (const match of text.matchAll(new RegExp(MENTION_TOKEN_PATTERN.source, "g"))) { const label = match[2]?.trim(); if (label) encodedLabels.push(label); } const mentionNames = [...new Set([...mentionNameById.values(), ...encodedLabels])].filter( Boolean, ); let cursor = 0; for (const match of text.matchAll(encodedPattern)) { const index = match.index ?? 0; const plainBetween = text.slice(cursor, index); appendPlainMentionMatches(segments, plainBetween, mentionNames); const userId = match[1] ?? ""; const label = match[2] ?? userId; segments.push({ type: "mention", userId, label }); cursor = index + match[0].length; } const trailingPlain = text.slice(cursor); appendPlainMentionMatches(segments, trailingPlain, mentionNames); return segments.length > 0 ? segments : [{ type: "text", value: text }]; } export type ActiveMentionQuery = { start: number; query: string; }; export function findActiveMentionQuery(text: string, cursor: number): ActiveMentionQuery | null { const beforeCursor = text.slice(0, cursor); const atIndex = beforeCursor.lastIndexOf("@"); if (atIndex < 0) { return null; } const prefix = beforeCursor.slice(0, atIndex); const lastPrefixChar = prefix.length > 0 ? prefix[prefix.length - 1] : ""; if (prefix.length > 0 && !/\s/.test(lastPrefixChar)) { return null; } const query = beforeCursor.slice(atIndex + 1); if (/[\n\r\t[\]]/.test(query)) { return null; } return { start: atIndex, query }; } export function filterMentionCandidates( candidates: MentionLookup[], query: string, ): MentionLookup[] { const normalized = query.trim().toLowerCase(); const filtered = normalized ? candidates.filter((candidate) => candidate.name.toLowerCase().includes(normalized)) : candidates; return filtered.slice(0, 8); } export function insertMentionToken( text: string, queryStart: number, cursor: number, user: MentionLookup, ): { nextText: string; nextCursor: number } { const token = `${encodeMentionToken(user.id, user.name)} `; const nextText = `${text.slice(0, queryStart)}${token}${text.slice(cursor)}`; const nextCursor = queryStart + token.length; return { nextText, nextCursor }; }