mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Extract custom emoji from ActivityPub objects (Fedify Emoji tags) and Mastodon API (status.emojis, account.emojis). Replace :shortcode: patterns with <img> tags in the unified processing pipeline. Emoji rendering applies to post content, author display names, boost attribution, and quote embed authors. Uses the shared postProcessItems() pipeline so both reader and explore views get emoji automatically. Bump version to 2.5.1. Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
270 lines
8.3 KiB
JavaScript
270 lines
8.3 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";
|
|
|
|
/**
|
|
* 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. 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|