mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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
292 lines
9.0 KiB
JavaScript
292 lines
9.0 KiB
JavaScript
/**
|
|
* Shared item processing pipeline for the ActivityPub reader.
|
|
*
|
|
* Both the reader (inbox-sourced items) and explore (Mastodon API items)
|
|
* flow through these functions. This ensures every enhancement (emoji,
|
|
* quote stripping, moderation, etc.) is implemented once.
|
|
*/
|
|
|
|
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.
|
|
* Called after items are loaded from any source (MongoDB or Mastodon API).
|
|
*
|
|
* @param {Array} items - Timeline items (from DB or Mastodon API mapping)
|
|
* @param {object} [options]
|
|
* @param {object} [options.moderation] - { mutedUrls, mutedKeywords, blockedUrls, filterMode }
|
|
* @param {object} [options.interactionsCol] - MongoDB collection for interaction state
|
|
* @returns {Promise<{ items: Array, interactionMap: object }>}
|
|
*/
|
|
export async function postProcessItems(items, options = {}) {
|
|
// 1. Moderation filters (muted actors, keywords, blocked actors)
|
|
if (options.moderation) {
|
|
items = applyModerationFilters(items, options.moderation);
|
|
}
|
|
|
|
// 2. Strip "RE:" paragraphs from items with quote embeds
|
|
stripQuoteReferences(items);
|
|
|
|
// 3. Replace custom emoji shortcodes with <img> tags
|
|
applyCustomEmoji(items);
|
|
|
|
// 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)
|
|
: {};
|
|
|
|
return { items, interactionMap };
|
|
}
|
|
|
|
/**
|
|
* Apply moderation filters to items.
|
|
* Blocked actors are always hidden. Muted actors/keywords are hidden or
|
|
* marked with a content warning depending on filterMode.
|
|
*
|
|
* @param {Array} items
|
|
* @param {object} moderation
|
|
* @param {string[]} moderation.mutedUrls
|
|
* @param {string[]} moderation.mutedKeywords
|
|
* @param {string[]} moderation.blockedUrls
|
|
* @param {string} moderation.filterMode - "hide" or "warn"
|
|
* @returns {Array}
|
|
*/
|
|
export function applyModerationFilters(items, { mutedUrls, mutedKeywords, blockedUrls, filterMode }) {
|
|
const blockedSet = new Set(blockedUrls);
|
|
const mutedSet = new Set(mutedUrls);
|
|
|
|
if (blockedSet.size === 0 && mutedSet.size === 0 && mutedKeywords.length === 0) {
|
|
return items;
|
|
}
|
|
|
|
return items.filter((item) => {
|
|
// Blocked actors are ALWAYS hidden
|
|
if (item.author?.url && blockedSet.has(item.author.url)) {
|
|
return false;
|
|
}
|
|
|
|
// Check muted actor
|
|
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
|
|
|
// Check muted keywords against content, title, and summary
|
|
let matchedKeyword = null;
|
|
if (mutedKeywords.length > 0) {
|
|
const searchable = [item.content?.text, item.name, item.summary]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
if (searchable) {
|
|
matchedKeyword = mutedKeywords.find((kw) =>
|
|
searchable.includes(kw.toLowerCase()),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isMutedActor || matchedKeyword) {
|
|
if (filterMode === "warn") {
|
|
// Mark for content warning instead of hiding
|
|
item._moderated = true;
|
|
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
|
if (matchedKeyword) {
|
|
item._moderationKeyword = matchedKeyword;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Strip "RE:" quote reference paragraphs from items that have quote embeds.
|
|
* Mutates items in place.
|
|
*
|
|
* @param {Array} items
|
|
*/
|
|
export function stripQuoteReferences(items) {
|
|
for (const item of items) {
|
|
const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
|
|
if (item.quote && quoteRef && item.content?.html) {
|
|
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace custom emoji :shortcode: patterns with <img> tags.
|
|
* Handles both content HTML and display names.
|
|
* Mutates items in place.
|
|
*
|
|
* @param {Array} items
|
|
*/
|
|
function applyCustomEmoji(items) {
|
|
for (const item of items) {
|
|
// Replace emoji in post content
|
|
if (item.emojis?.length && item.content?.html) {
|
|
item.content.html = replaceCustomEmoji(item.content.html, item.emojis);
|
|
}
|
|
|
|
// Replace emoji in author display name → stored as author.nameHtml
|
|
const authorEmojis = item.author?.emojis;
|
|
if (authorEmojis?.length && item.author?.name) {
|
|
item.author.nameHtml = replaceCustomEmoji(item.author.name, authorEmojis);
|
|
}
|
|
|
|
// Replace emoji in boostedBy display name
|
|
const boostEmojis = item.boostedBy?.emojis;
|
|
if (boostEmojis?.length && item.boostedBy?.name) {
|
|
item.boostedBy.nameHtml = replaceCustomEmoji(item.boostedBy.name, boostEmojis);
|
|
}
|
|
|
|
// Replace emoji in quote embed content and author name
|
|
if (item.quote) {
|
|
if (item.quote.emojis?.length && item.quote.content?.html) {
|
|
item.quote.content.html = replaceCustomEmoji(item.quote.content.html, item.quote.emojis);
|
|
}
|
|
const qAuthorEmojis = item.quote.author?.emojis;
|
|
if (qAuthorEmojis?.length && item.quote.author?.name) {
|
|
item.quote.author.nameHtml = replaceCustomEmoji(item.quote.author.name, qAuthorEmojis);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 } }.
|
|
*
|
|
* @param {Array} items
|
|
* @param {object} interactionsCol - MongoDB collection
|
|
* @returns {Promise<object>}
|
|
*/
|
|
export async function buildInteractionMap(items, interactionsCol) {
|
|
const interactionMap = {};
|
|
const lookupUrls = new Set();
|
|
const objectUrlToUid = new Map();
|
|
|
|
for (const item of items) {
|
|
const uid = item.uid;
|
|
const displayUrl = item.url || item.originalUrl;
|
|
|
|
if (uid) {
|
|
lookupUrls.add(uid);
|
|
objectUrlToUid.set(uid, uid);
|
|
}
|
|
if (displayUrl) {
|
|
lookupUrls.add(displayUrl);
|
|
objectUrlToUid.set(displayUrl, uid || displayUrl);
|
|
}
|
|
}
|
|
|
|
if (lookupUrls.size === 0) return interactionMap;
|
|
|
|
const interactions = await interactionsCol
|
|
.find({ objectUrl: { $in: [...lookupUrls] } })
|
|
.toArray();
|
|
|
|
for (const interaction of interactions) {
|
|
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
|
if (!interactionMap[key]) interactionMap[key] = {};
|
|
interactionMap[key][interaction.type] = true;
|
|
}
|
|
|
|
return interactionMap;
|
|
}
|
|
|
|
/**
|
|
* Filter items by tab type (reader-specific).
|
|
*
|
|
* @param {Array} items
|
|
* @param {string} tab - "notes", "articles", "boosts", "replies", "media", "all"
|
|
* @returns {Array}
|
|
*/
|
|
export function applyTabFilter(items, tab) {
|
|
if (tab === "replies") {
|
|
return items.filter((item) => item.inReplyTo);
|
|
}
|
|
if (tab === "media") {
|
|
return items.filter(
|
|
(item) =>
|
|
(item.photo && item.photo.length > 0) ||
|
|
(item.video && item.video.length > 0) ||
|
|
(item.audio && item.audio.length > 0),
|
|
);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Render items to HTML using ap-item-card.njk.
|
|
* Used by all API endpoints that return pre-rendered card HTML.
|
|
*
|
|
* @param {Array} items
|
|
* @param {object} request - Express request (for app.render)
|
|
* @param {object} templateData - Merged template context (locals, mountPath, csrfToken, interactionMap)
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function renderItemCards(items, request, templateData) {
|
|
const htmlParts = await Promise.all(
|
|
items.map(
|
|
(item) =>
|
|
new Promise((resolve, reject) => {
|
|
request.app.render(
|
|
"partials/ap-item-card.njk",
|
|
{ ...templateData, item },
|
|
(err, html) => {
|
|
if (err) reject(err);
|
|
else resolve(html);
|
|
},
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
return htmlParts.join("");
|
|
}
|
|
|
|
/**
|
|
* Load moderation data from MongoDB collections.
|
|
* Convenience wrapper to reduce boilerplate in controllers.
|
|
*
|
|
* @param {object} modCollections - { ap_muted, ap_blocked, ap_profile }
|
|
* @returns {Promise<object>} moderation data for postProcessItems()
|
|
*/
|
|
export async function loadModerationData(modCollections) {
|
|
// Dynamic import to avoid circular dependency
|
|
const { getMutedUrls, getMutedKeywords, getBlockedUrls, getFilterMode } =
|
|
await import("./storage/moderation.js");
|
|
|
|
const [mutedUrls, mutedKeywords, blockedUrls, filterMode] = await Promise.all([
|
|
getMutedUrls(modCollections),
|
|
getMutedKeywords(modCollections),
|
|
getBlockedUrls(modCollections),
|
|
getFilterMode(modCollections),
|
|
]);
|
|
|
|
return { mutedUrls, mutedKeywords, blockedUrls, filterMode };
|
|
}
|