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 %}