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(``);
@@ -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(``);
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;
}