mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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
227 lines
6.8 KiB
JavaScript
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 };
|
|
}
|