feat: linkify URLs and extract @mentions in status creation
Mastodon clients send plain text — the server must convert bare URLs and @user@domain mentions into HTML links. Previously, URLs appeared as plain text and mentions were not stored as mention objects. - Bare URLs (http/https) are wrapped in <a> tags - @user@domain patterns are converted to profile links with h-card markup - Mentions are extracted into the mentions[] array with name and URL - Only processes content that doesn't already contain <a> tags (avoids double-linkifying Micropub-rendered content)
This commit is contained in:
@@ -247,12 +247,17 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Process content: linkify URLs and extract @mentions
|
||||||
|
const rawContent = data.properties.content || { text: statusText || "", html: "" };
|
||||||
|
const processedContent = processStatusContent(rawContent, statusText || "");
|
||||||
|
const mentions = extractMentions(statusText || "");
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const timelineItem = await addTimelineItem(collections, {
|
const timelineItem = await addTimelineItem(collections, {
|
||||||
uid: postUrl,
|
uid: postUrl,
|
||||||
url: postUrl,
|
url: postUrl,
|
||||||
type: data.properties["post-type"] || "note",
|
type: data.properties["post-type"] || "note",
|
||||||
content: data.properties.content || { text: statusText || "", html: "" },
|
content: processedContent,
|
||||||
summary: spoilerText || "",
|
summary: spoilerText || "",
|
||||||
sensitive: sensitive === true || sensitive === "true",
|
sensitive: sensitive === true || sensitive === "true",
|
||||||
visibility: visibility || "public",
|
visibility: visibility || "public",
|
||||||
@@ -274,7 +279,7 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|||||||
category: categories,
|
category: categories,
|
||||||
counts: { replies: 0, boosts: 0, likes: 0 },
|
counts: { replies: 0, boosts: 0, likes: 0 },
|
||||||
linkPreviews: [],
|
linkPreviews: [],
|
||||||
mentions: [],
|
mentions,
|
||||||
emojis: [],
|
emojis: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,4 +641,68 @@ async function loadItemInteractions(collections, item) {
|
|||||||
return { favouritedIds, rebloggedIds, bookmarkedIds };
|
return { favouritedIds, rebloggedIds, bookmarkedIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process status content: linkify bare URLs and convert @mentions to links.
|
||||||
|
*
|
||||||
|
* Mastodon clients send plain text — the server is responsible for
|
||||||
|
* converting URLs and mentions into HTML links.
|
||||||
|
*
|
||||||
|
* @param {object} content - { text, html } from Micropub pipeline
|
||||||
|
* @param {string} rawText - Original status text from client
|
||||||
|
* @returns {object} { text, html } with linkified content
|
||||||
|
*/
|
||||||
|
function processStatusContent(content, rawText) {
|
||||||
|
let html = content.html || content.text || rawText || "";
|
||||||
|
|
||||||
|
// If the HTML is just plain text wrapped in <p>, process it
|
||||||
|
// Don't touch HTML that already has links (from Micropub rendering)
|
||||||
|
if (!html.includes("<a ")) {
|
||||||
|
// Linkify bare URLs (http/https)
|
||||||
|
html = html.replace(
|
||||||
|
/(https?:\/\/[^\s<>"')\]]+)/g,
|
||||||
|
'<a href="$1" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert @user@domain mentions to profile links
|
||||||
|
html = html.replace(
|
||||||
|
/(?:^|\s)(@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g,
|
||||||
|
(match, full, username, domain) =>
|
||||||
|
match.replace(
|
||||||
|
full,
|
||||||
|
`<span class="h-card"><a href="https://${domain}/@${username}" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@${username}@${domain}</a></span>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: content.text || rawText || "",
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract @user@domain mentions from text into mention objects.
|
||||||
|
*
|
||||||
|
* @param {string} text - Status text
|
||||||
|
* @returns {Array<{name: string, url: string}>} Mention objects
|
||||||
|
*/
|
||||||
|
function extractMentions(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
const mentionRegex = /@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
|
||||||
|
const mentions = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let match;
|
||||||
|
while ((match = mentionRegex.exec(text)) !== null) {
|
||||||
|
const [, username, domain] = match;
|
||||||
|
const key = `${username}@${domain}`.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
mentions.push({
|
||||||
|
name: `@${username}@${domain}`,
|
||||||
|
url: `https://${domain}/@${username}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mentions;
|
||||||
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "3.7.2",
|
"version": "3.7.3",
|
||||||
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
Reference in New Issue
Block a user