diff --git a/assets/reader.css b/assets/reader.css index 9f896a4..fd708d0 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -301,6 +301,21 @@ text-decoration: underline; } +.ap-card__bot-badge { + display: inline-block; + font-size: 0.6rem; + font-weight: 700; + line-height: 1; + padding: 0.15em 0.35em; + margin-left: 0.3em; + border: var(--border-width-thin) solid var(--color-on-offset); + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.03em; +} + .ap-card__author-handle { color: var(--color-on-offset); font-size: var(--font-size-s); @@ -315,9 +330,17 @@ font-size: var(--font-size-xs); } +.ap-card__edited { + font-size: var(--font-size-xs); + margin-left: 0.2em; +} + .ap-card__timestamp-link { color: inherit; text-decoration: none; + display: flex; + align-items: center; + gap: 0; } .ap-card__timestamp-link:hover { @@ -706,6 +729,30 @@ opacity: 0.7; } +/* Hashtag stuffing collapse */ +.ap-hashtag-overflow { + margin: var(--space-xs) 0; + font-size: var(--font-size-s); +} + +.ap-hashtag-overflow summary { + cursor: pointer; + color: var(--color-on-offset); + list-style: none; +} + +.ap-hashtag-overflow summary::before { + content: "▸ "; +} + +.ap-hashtag-overflow[open] summary::before { + content: "▾ "; +} + +.ap-hashtag-overflow p { + margin-top: var(--space-xs); +} + /* ========================================================================== Interaction Buttons ========================================================================== */ diff --git a/lib/content-utils.js b/lib/content-utils.js new file mode 100644 index 0000000..f30741a --- /dev/null +++ b/lib/content-utils.js @@ -0,0 +1,72 @@ +/** + * Content post-processing utilities. + * Applied after sanitization and emoji replacement in the item pipeline. + */ + +/** + * Shorten displayed URLs in tags that exceed maxLength. + * Keeps the full URL in href, only truncates the visible text. + * + * Example: https://example.com/very/long/path + * → example.com/very/lon… + * + * @param {string} html - Sanitized HTML content + * @param {number} [maxLength=30] - Max visible URL length before truncation + * @returns {string} HTML with shortened display URLs + */ +export function shortenDisplayUrls(html, maxLength = 30) { + if (!html) return html; + + // Match URL text where the visible text looks like a URL + return html.replace( + /(]*>)(https?:\/\/[^<]+)(<\/a>)/gi, + (match, openTag, urlText, closeTag) => { + if (urlText.length <= maxLength) return match; + + // Strip protocol for display + const display = urlText.replace(/^https?:\/\//, ""); + const truncated = display.slice(0, maxLength - 1) + "\u2026"; + + // Add title attribute with full URL for hover tooltip (if not already present) + let tag = openTag; + if (!tag.includes("title=")) { + tag = tag.replace(/>$/, ` title="${urlText}">`); + } + + return `${tag}${truncated}${closeTag}`; + }, + ); +} + +/** + * Collapse paragraphs that are mostly hashtag links (hashtag stuffing). + * Detects

blocks where 80%+ of the text content is hashtag links + * and wraps them in a

element. + * + * @param {string} html - Sanitized HTML content + * @param {number} [minTags=3] - Minimum number of hashtag links to trigger collapse + * @returns {string} HTML with hashtag-heavy paragraphs collapsed + */ +export function collapseHashtagStuffing(html, minTags = 3) { + if (!html) return html; + + // Match

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 `

Show ${hashtagLinks.length} tags

${inner}

`; + } + + return match; + }); +} diff --git a/lib/controllers/explore-utils.js b/lib/controllers/explore-utils.js index e8b11bc..3c11318 100644 --- a/lib/controllers/explore-utils.js +++ b/lib/controllers/explore-utils.js @@ -119,12 +119,14 @@ export function mapMastodonStatusToItem(status, instance) { summary: status.spoiler_text || "", sensitive: status.sensitive || false, published: status.created_at || new Date().toISOString(), + updated: status.edited_at || "", author: { name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }), url: account.url || "", photo: account.avatar || account.avatar_static || "", handle, emojis: authorEmojis, + bot: account.bot || false, }, category, mentions, diff --git a/lib/item-processing.js b/lib/item-processing.js index 2b27dc3..f9adcdb 100644 --- a/lib/item-processing.js +++ b/lib/item-processing.js @@ -8,6 +8,7 @@ import { stripQuoteReferenceHtml } from "./og-unfurl.js"; import { replaceCustomEmoji } from "./emoji-utils.js"; +import { shortenDisplayUrls, collapseHashtagStuffing } from "./content-utils.js"; /** * Post-process timeline items for rendering. @@ -31,7 +32,10 @@ export async function postProcessItems(items, options = {}) { // 3. Replace custom emoji shortcodes with 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 %}
{{ item.author.handle }}
@@ -63,6 +64,7 @@ + {% if item.updated %}✏️{% endif %} {% endif %}