blocks where 80%+ of the text content is hashtag links
+ * and wraps them in a blocks
+ return html.replace(/ ([^]*?)<\/p>/gi, (match, inner) => {
+ // Count hashtag links: #something or plain #word
+ const hashtagLinks = inner.match(/]*>#[^<]+<\/a>/gi) || [];
+ if (hashtagLinks.length < minTags) return match;
+
+ // Calculate what fraction of text content is hashtags
+ const textOnly = inner.replace(/<[^>]*>/g, "").trim();
+ const hashtagText = hashtagLinks
+ .map((link) => link.replace(/<[^>]*>/g, "").trim())
+ .join(" ");
+
+ // If hashtags make up 80%+ of the text content, collapse
+ if (hashtagText.length / Math.max(textOnly.length, 1) >= 0.8) {
+ return ` ${inner}Show ${hashtagLinks.length} tags
tags
applyCustomEmoji(items);
- // 4. Build interaction map (likes/boosts) — empty when no collection
+ // 4. Shorten long URLs and collapse hashtag stuffing in content
+ applyContentEnhancements(items);
+
+ // 5. Build interaction map (likes/boosts) — empty when no collection
const interactionMap = options.interactionsCol
? await buildInteractionMap(items, options.interactionsCol)
: {};
@@ -154,6 +158,24 @@ function applyCustomEmoji(items) {
}
}
+/**
+ * Shorten long URLs and collapse hashtag-heavy paragraphs in content.
+ * Mutates items in place.
+ *
+ * @param {Array} items
+ */
+function applyContentEnhancements(items) {
+ for (const item of items) {
+ if (item.content?.html) {
+ item.content.html = shortenDisplayUrls(item.content.html);
+ item.content.html = collapseHashtagStuffing(item.content.html);
+ }
+ if (item.quote?.content?.html) {
+ item.quote.content.html = shortenDisplayUrls(item.quote.content.html);
+ }
+ }
+}
+
/**
* Build interaction map (likes/boosts) for template rendering.
* Returns { [uid]: { like: true, boost: true } }.
diff --git a/lib/timeline-store.js b/lib/timeline-store.js
index 34d6bd9..5eff1c6 100644
--- a/lib/timeline-store.js
+++ b/lib/timeline-store.js
@@ -3,7 +3,7 @@
* @module timeline-store
*/
-import { Article, Emoji, Hashtag, Mention } from "@fedify/fedify/vocab";
+import { Article, Application, Emoji, Hashtag, Mention, Service } from "@fedify/fedify/vocab";
import sanitizeHtml from "sanitize-html";
/**
@@ -101,7 +101,10 @@ export async function extractActorInfo(actor, options = {}) {
// Emoji extraction failed — non-critical
}
- return { name, url, photo, handle, emojis };
+ // Bot detection — Service and Application actors are automated accounts
+ const bot = actor instanceof Service || actor instanceof Application;
+
+ return { name, url, photo, handle, emojis, bot };
}
/**
@@ -154,6 +157,9 @@ export async function extractObjectData(object, options = {}) {
? String(object.published)
: new Date().toISOString();
+ // Edited date — non-null when the post has been updated after publishing
+ const updated = object.updated ? String(object.updated) : "";
+
// Extract author — try multiple strategies in order of reliability
const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
let authorObj = null;
@@ -304,6 +310,7 @@ export async function extractObjectData(object, options = {}) {
summary,
sensitive,
published,
+ updated,
author,
category,
mentions,
diff --git a/package.json b/package.json
index 3bb2fee..deabf8e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
- "version": "2.5.5",
+ "version": "2.6.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk
index 7cc7065..08b1a33 100644
--- a/views/partials/ap-item-card.njk
+++ b/views/partials/ap-item-card.njk
@@ -53,6 +53,7 @@
{% else %}
{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}
{% endif %}
+ {% if item.author.bot %}BOT{% endif %}
{% if item.author.handle %}
@@ -63,6 +64,7 @@
+ {% if item.updated %}✏️{% endif %}
{% endif %}