From 02d449d03ca542ead25a5fdeb7d21cc0e4bec5fe Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 3 Mar 2026 13:13:28 +0100 Subject: [PATCH] feat: render custom emoji in reader (Release 1) Extract custom emoji from ActivityPub objects (Fedify Emoji tags) and Mastodon API (status.emojis, account.emojis). Replace :shortcode: patterns with 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 --- assets/reader.css | 9 +++++++ lib/controllers/explore-utils.js | 10 +++++++ lib/emoji-utils.js | 38 ++++++++++++++++++++++++++ lib/item-processing.js | 45 ++++++++++++++++++++++++++++++- lib/timeline-store.js | 33 +++++++++++++++++++++-- package.json | 2 +- views/partials/ap-item-card.njk | 6 ++--- views/partials/ap-quote-embed.njk | 2 +- 8 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 lib/emoji-utils.js diff --git a/assets/reader.css b/assets/reader.css index dd7d04c..2e11659 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -2528,3 +2528,12 @@ padding: var(--space-s) 0 var(--space-xs); } +/* Custom emoji */ +.ap-custom-emoji { + height: 1.2em; + width: auto; + vertical-align: middle; + display: inline; + margin: 0 0.05em; +} + diff --git a/lib/controllers/explore-utils.js b/lib/controllers/explore-utils.js index ff31c9f..03a95a6 100644 --- a/lib/controllers/explore-utils.js +++ b/lib/controllers/explore-utils.js @@ -92,6 +92,14 @@ export function mapMastodonStatusToItem(status, instance) { } } + // Extract custom emoji — Mastodon API provides emojis on both status and account + const emojis = (status.emojis || []) + .filter((e) => e.shortcode && e.url) + .map((e) => ({ shortcode: e.shortcode, url: e.url })); + const authorEmojis = (account.emojis || []) + .filter((e) => e.shortcode && e.url) + .map((e) => ({ shortcode: e.shortcode, url: e.url })); + const item = { uid: status.url || status.uri || "", url: status.url || status.uri || "", @@ -109,9 +117,11 @@ export function mapMastodonStatusToItem(status, instance) { url: account.url || "", photo: account.avatar || account.avatar_static || "", handle, + emojis: authorEmojis, }, category, mentions, + emojis, photo, video, audio, diff --git a/lib/emoji-utils.js b/lib/emoji-utils.js new file mode 100644 index 0000000..254aab7 --- /dev/null +++ b/lib/emoji-utils.js @@ -0,0 +1,38 @@ +/** + * Custom emoji replacement for fediverse content. + * + * Replaces :shortcode: patterns with tags for custom emoji. + * Must be called AFTER sanitizeContent() — the inserted tags + * would be stripped if run through the sanitizer. + */ + +/** + * Escape special regex characters in a string. + * @param {string} str + * @returns {string} + */ +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Replace :shortcode: patterns in HTML with custom emoji tags. + * + * @param {string} html - HTML string (already sanitized) + * @param {Array<{shortcode: string, url: string}>} emojis - Custom emoji list + * @returns {string} HTML with emoji shortcodes replaced by img tags + */ +export function replaceCustomEmoji(html, emojis) { + if (!html || !emojis?.length) return html; + + for (const emoji of emojis) { + if (!emoji.shortcode || !emoji.url) continue; + const pattern = new RegExp(`:${escapeRegex(emoji.shortcode)}:`, "g"); + html = html.replace( + pattern, + `:${emoji.shortcode}:`, + ); + } + + return html; +} diff --git a/lib/item-processing.js b/lib/item-processing.js index cf96a6f..2b27dc3 100644 --- a/lib/item-processing.js +++ b/lib/item-processing.js @@ -7,6 +7,7 @@ */ import { stripQuoteReferenceHtml } from "./og-unfurl.js"; +import { replaceCustomEmoji } from "./emoji-utils.js"; /** * Post-process timeline items for rendering. @@ -27,7 +28,10 @@ export async function postProcessItems(items, options = {}) { // 2. Strip "RE:" paragraphs from items with quote embeds stripQuoteReferences(items); - // 3. Build interaction map (likes/boosts) — empty when no collection + // 3. Replace custom emoji shortcodes with tags + applyCustomEmoji(items); + + // 4. Build interaction map (likes/boosts) — empty when no collection const interactionMap = options.interactionsCol ? await buildInteractionMap(items, options.interactionsCol) : {}; @@ -111,6 +115,45 @@ export function stripQuoteReferences(items) { } } +/** + * 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); + } + } + } +} + /** * Build interaction map (likes/boosts) for template rendering. * Returns { [uid]: { like: true, boost: true } }. diff --git a/lib/timeline-store.js b/lib/timeline-store.js index 9056aec..714de76 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -3,7 +3,7 @@ * @module timeline-store */ -import { Article, Hashtag, Mention } from "@fedify/fedify/vocab"; +import { Article, Emoji, Hashtag, Mention } from "@fedify/fedify/vocab"; import sanitizeHtml from "sanitize-html"; /** @@ -82,7 +82,26 @@ export async function extractActorInfo(actor, options = {}) { // Invalid URL, keep handle empty } - return { name, url, photo, handle }; + // Extract custom emoji from actor tags + const emojis = []; + try { + if (typeof actor.getTags === "function") { + const tags = await actor.getTags(loaderOpts); + for await (const tag of tags) { + if (tag instanceof Emoji) { + const shortcode = (tag.name?.toString() || "").replace(/^:|:$/g, ""); + const iconUrl = tag.iconId?.href || ""; + if (shortcode && iconUrl) { + emojis.push({ shortcode, url: iconUrl }); + } + } + } + } + } catch { + // Emoji extraction failed — non-critical + } + + return { name, url, photo, handle, emojis }; } /** @@ -190,8 +209,10 @@ export async function extractObjectData(object, options = {}) { // Extract tags — Fedify uses async getTags() which returns typed vocab objects. // Hashtag → category[] (plain strings, # prefix stripped) // Mention → mentions[] ({ name, url } objects for profile linking) + // Emoji → emojis[] ({ shortcode, url } for custom emoji rendering) const category = []; const mentions = []; + const emojis = []; try { if (typeof object.getTags === "function") { const tags = await object.getTags(loaderOpts); @@ -206,6 +227,13 @@ export async function extractObjectData(object, options = {}) { // tag.href is a URL object — use .href to get the string const mentionUrl = tag.href?.href || ""; if (mentionName) mentions.push({ name: mentionName, url: mentionUrl }); + } else if (tag instanceof Emoji) { + // Custom emoji: name is ":shortcode:", icon is an Image with url + const shortcode = (tag.name?.toString() || "").replace(/^:|:$/g, ""); + const iconUrl = tag.iconId?.href || ""; + if (shortcode && iconUrl) { + emojis.push({ shortcode, url: iconUrl }); + } } } } @@ -259,6 +287,7 @@ export async function extractObjectData(object, options = {}) { author, category, mentions, + emojis, photo, video, audio, diff --git a/package.json b/package.json index 0c242b5..3dec390 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.5.0", + "version": "2.5.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk index 7e92006..c551cac 100644 --- a/views/partials/ap-item-card.njk +++ b/views/partials/ap-item-card.njk @@ -26,7 +26,7 @@ {# Boost header if this is a boosted post #} {% if item.type == "boost" and item.boostedBy %}
- 🔁 {% if item.boostedBy.url %}{{ item.boostedBy.name or "Someone" }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %} {{ __("activitypub.reader.boosted") }} + 🔁 {% if item.boostedBy.url %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
{% endif %} @@ -49,9 +49,9 @@
{% if item.author.url %} - {{ item.author.name or "Unknown" }} + {% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %} {% else %} - {{ item.author.name or "Unknown" }} + {% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %} {% endif %}
{% if item.author.handle %} diff --git a/views/partials/ap-quote-embed.njk b/views/partials/ap-quote-embed.njk index 59b6c04..24d7f13 100644 --- a/views/partials/ap-quote-embed.njk +++ b/views/partials/ap-quote-embed.njk @@ -9,7 +9,7 @@ {{ item.quote.author.name[0] | upper if item.quote.author.name else "?" }} {% endif %}
-
{{ item.quote.author.name or "Unknown" }}
+
{% if item.quote.author.nameHtml %}{{ item.quote.author.nameHtml | safe }}{% else %}{{ item.quote.author.name or "Unknown" }}{% endif %}
{% if item.quote.author.handle %}
{{ item.quote.author.handle }}
{% endif %}