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