diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 46bf9af..b56428a 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -789,6 +789,28 @@ export const TimelineItem = ({ }, [mentionMap] ); + const resolveMentionUrl = useCallback( + (handle: string) => { + const normalizedHandle = normalizeMentionHandle(handle); + if (!normalizedHandle) { + return null; + } + const mention = mentionMap.get(normalizedHandle) ?? null; + if (mention?.url) { + return mention.url; + } + if (!normalizedHandle.includes("@")) { + return null; + } + const [username, ...rest] = normalizedHandle.split("@"); + const host = rest.join("@"); + if (!username || !host) { + return null; + } + return `https://${host}/@${username}`; + }, + [mentionMap] + ); const normalizedActiveMentionHandle = useMemo(() => { const base = normalizeMentionHandle(activeAccountHandle); if (!base) { @@ -932,7 +954,7 @@ export const TimelineItem = ({ showCustomEmojis && displayStatus.customEmojis.length > 0 ? buildEmojiMap(displayStatus.customEmojis) : undefined; - const markdownHtml = renderMarkdown(displayStatus.content, emojiMap); + const markdownHtml = renderMarkdown(displayStatus.content, emojiMap, { mentionResolver: resolveMentionUrl }); return (
{ return !hasScheme || /^https?:/i.test(trimmed); }; +const normalizeMentionHandle = (handle: string): string => handle.replace(/^@/, "").trim().toLowerCase(); + const renderImageTag = (alt: string, url: string): string => { if (!isSafeUrl(url)) { return escapeHtml(`![${alt}](${url})`); @@ -50,7 +52,12 @@ const linkifyBareUrls = (text: string): string => { const formatInline = ( text: string, emojiMap?: Map, - options: { linkify?: boolean; parseLinks?: boolean } = {} + options: { + linkify?: boolean; + parseLinks?: boolean; + parseMentions?: boolean; + mentionResolver?: (handle: string) => string | null; + } = {} ): string => { const codeSpans: string[] = []; let tokenized = text.replace(/`([^`]+)`/g, (_match, code) => { @@ -68,12 +75,33 @@ const formatInline = ( if (options.parseLinks !== false) { tokenized = tokenized.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => { // Parse link labels once; avoid nested markdown/link parsing and linkify here. - const safeLabel = formatInline(label, emojiMap, { linkify: false, parseLinks: false }); + const safeLabel = formatInline(label, emojiMap, { + linkify: false, + parseLinks: false, + parseMentions: false + }); const safeUrl = escapeAttr(url); links.push(`${safeLabel}`); return `\u0003${links.length - 1}\u0003`; }); } + const mentions: string[] = []; + if (options.parseMentions !== false && options.mentionResolver) { + tokenized = tokenized.replace( + /@[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?=[^\w@]|$)/g, + (match) => { + const normalized = normalizeMentionHandle(match); + const url = options.mentionResolver?.(normalized); + if (!url) { + return match; + } + const safeUrl = escapeAttr(url); + const safeLabel = escapeHtml(match); + mentions.push(`${safeLabel}`); + return `\u0004${mentions.length - 1}\u0004`; + } + ); + } const emojis: string[] = []; if (emojiMap && emojiMap.size > 0) { tokenized = tokenized.replace(/:([a-zA-Z0-9_]+):/g, (_match, shortcode) => { @@ -96,10 +124,15 @@ const formatInline = ( out = out.replace(/\u0002(\d+)\u0002/g, (_match, index) => emojis[Number(index)] ?? ""); out = out.replace(/\u0001(\d+)\u0001/g, (_match, index) => codeSpans[Number(index)] ?? ""); out = out.replace(/\u0003(\d+)\u0003/g, (_match, index) => links[Number(index)] ?? ""); + out = out.replace(/\u0004(\d+)\u0004/g, (_match, index) => mentions[Number(index)] ?? ""); return out; }; -export const renderMarkdown = (markdown: string, emojiMap?: Map): string => { +export const renderMarkdown = ( + markdown: string, + emojiMap?: Map, + options?: { mentionResolver?: (handle: string) => string | null } +): string => { const lines = markdown.split(/\r?\n/); const blocks: string[] = []; let inCode = false; @@ -110,14 +143,18 @@ export const renderMarkdown = (markdown: string, emojiMap?: Map) const flushParagraph = () => { if (paragraphBuffer.length === 0) return; - const content = paragraphBuffer.map((line) => formatInline(line, emojiMap)).join("
"); + const content = paragraphBuffer + .map((line) => formatInline(line, emojiMap, { mentionResolver: options?.mentionResolver })) + .join("
"); blocks.push(`

${content}

`); paragraphBuffer = []; }; const flushList = () => { if (listBuffer.length === 0) return; - const items = listBuffer.map((item) => `
  • ${formatInline(item, emojiMap)}
  • `).join(""); + const items = listBuffer + .map((item) => `
  • ${formatInline(item, emojiMap, { mentionResolver: options?.mentionResolver })}
  • `) + .join(""); blocks.push(`
      ${items}
    `); listBuffer = []; }; @@ -167,7 +204,9 @@ export const renderMarkdown = (markdown: string, emojiMap?: Map) flushParagraph(); flushList(); const level = headingMatch[1].length; - blocks.push(`${formatInline(headingMatch[2], emojiMap)}`); + blocks.push( + `${formatInline(headingMatch[2], emojiMap, { mentionResolver: options?.mentionResolver })}` + ); continue; }