/** * Shared utility for converting @memberName and @teamName mentions in plain text * to markdown links with mention:// and team:// protocols. * * Used by UserChatGroup, TeammateMessageItem, ActivityItem, TaskCommentsSection. * MarkdownViewer already handles rendering mention:// and team:// links as colored badges. */ /** * Convert `@memberName` in plain text to markdown links with mention:// protocol. * Encodes color in the URL so MarkdownViewer can render colored badges without extra context. * Greedy match: longer names are tried first to avoid partial matches. * * @param text - The plain text to process * @param memberColorMap - Map of member name → color key (e.g. "blue", "red") * @returns Text with @mentions replaced by markdown links */ export function linkifyMentionsInMarkdown( text: string, memberColorMap: Map ): string { if (memberColorMap.size === 0) return text; // Sort by name length descending for greedy matching const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length); const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const pattern = new RegExp( // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp `(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}'\u2019-]|$)`, 'gi' ); return text.replace(pattern, (_match: string, prefix: string, name: string) => { // Find the canonical name (case-insensitive lookup) const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; const color = memberColorMap.get(canonical) ?? ''; return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`; }); } /** * Convert `@teamName` in plain text to markdown links with team:// protocol. * Greedy match: longer names are tried first to avoid partial matches. * * @param text - The plain text to process * @param teamNames - Set or array of known team names * @returns Text with @teamName replaced by markdown links */ export function linkifyTeamMentionsInMarkdown( text: string, teamNames: ReadonlySet | readonly string[] ): string { const names: readonly string[] = Array.isArray(teamNames) ? teamNames : [...teamNames]; if (names.length === 0) return text; // Sort by name length descending for greedy matching const sorted = [...names].sort((a, b) => b.length - a.length); const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const pattern = new RegExp( // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp `(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}'\u2019-]|$)`, 'gi' ); return text.replace(pattern, (_match: string, prefix: string, name: string) => { const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`; }); } /** * Apply both member and team linkification. Team names are matched first to avoid * team names being captured as member mentions when they share similar names. * * @param text - The plain text to process * @param memberColorMap - Map of member name → color key * @param teamNames - Known team names * @returns Text with both @member and @team mentions replaced by markdown links */ export function linkifyAllMentionsInMarkdown( text: string, memberColorMap: Map, teamNames: ReadonlySet | readonly string[] = [] ): string { // Apply team linkification first (team names tend to be longer / more specific) let result = linkifyTeamMentionsInMarkdown(text, teamNames); // Then member linkification on the remaining text result = linkifyMentionsInMarkdown(result, memberColorMap); return result; }