Files
indiekit-endpoint-activitypub/lib/item-processing.js
Ricardo af2f899073 refactor: unify reader and explore processing pipeline (Release 0)
Extract shared item-processing.js module with postProcessItems(),
applyModerationFilters(), buildInteractionMap(), applyTabFilter(),
renderItemCards(), and loadModerationData(). All controllers (reader,
api-timeline, explore, hashtag-explore, tag-timeline) now flow through
the same pipeline.

Unify Alpine.js infinite scroll into single parameterized
apInfiniteScroll component configured via data attributes, replacing
the separate apExploreScroll component.

Also adds fetchAndStoreQuote() for quote enrichment and on-demand
quote fetching in post-detail controller.

Bump version to 2.5.0.

Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
2026-03-03 12:48:40 +01:00

227 lines
6.8 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";
/**
* 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. 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);
}
}
}
/**
* 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 };
}