mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
@@ -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
72
lib/content-utils.js
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } }.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user