feat: content enhancements — URL shortening, hashtag collapse, bot badge, edit indicator (Release 7)

Shorten long URLs in post content (30 char display limit with tooltip).
Collapse hashtag-heavy paragraphs into expandable <details> toggle.
Show BOT badge for Service/Application actors. Show pencil icon for
edited posts with hover tooltip showing edit timestamp.

Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
This commit is contained in:
Ricardo
2026-03-03 16:40:01 +01:00
parent fca1738bd3
commit b9fc98f40c
7 changed files with 156 additions and 4 deletions

View File

@@ -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
========================================================================== */

72
lib/content-utils.js Normal file
View File

@@ -0,0 +1,72 @@
/**
* Content post-processing utilities.
* Applied after sanitization and emoji replacement in the item pipeline.
*/
/**
* Shorten displayed URLs in <a> tags that exceed maxLength.
* Keeps the full URL in href, only truncates the visible text.
*
* Example: <a href="https://example.com/very/long/path">https://example.com/very/long/path</a>
* → <a href="https://example.com/very/long/path" title="https://example.com/very/long/path">example.com/very/lon…</a>
*
* @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 <a ...>URL text</a> where the visible text looks like a URL
return html.replace(
/(<a\s[^>]*>)(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 <p> blocks where 80%+ of the text content is hashtag links
* and wraps them in a <details> 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 <p> blocks
return html.replace(/<p>([^]*?)<\/p>/gi, (match, inner) => {
// Count hashtag links: <a ...>#something</a> or plain #word
const hashtagLinks = inner.match(/<a[^>]*>#[^<]+<\/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 `<details class="ap-hashtag-overflow"><summary>Show ${hashtagLinks.length} tags</summary><p>${inner}</p></details>`;
}
return match;
});
}

View File

@@ -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,

View File

@@ -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 <img> 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 } }.

View File

@@ -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,

View File

@@ -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",

View File

@@ -53,6 +53,7 @@
{% else %}
<span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
{% endif %}
{% if item.author.bot %}<span class="ap-card__bot-badge" title="Bot account">BOT</span>{% endif %}
</div>
{% if item.author.handle %}
<div class="ap-card__author-handle">{{ item.author.handle }}</div>
@@ -63,6 +64,7 @@
<time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
{{ item.published | date("PPp") }}
</time>
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
</a>
{% endif %}
</header>