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,
+ `
`,
+ );
+ }
+
+ 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 %}