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 <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
This commit is contained in:
Ricardo
2026-03-03 13:13:28 +01:00
parent ab2363d123
commit 02d449d03c
8 changed files with 137 additions and 8 deletions

View File

@@ -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;
}

View File

@@ -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,

38
lib/emoji-utils.js Normal file
View File

@@ -0,0 +1,38 @@
/**
* Custom emoji replacement for fediverse content.
*
* Replaces :shortcode: patterns with <img> tags for custom emoji.
* Must be called AFTER sanitizeContent() — the inserted <img> 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 <img> 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,
`<img src="${emoji.url}" alt=":${emoji.shortcode}:" title=":${emoji.shortcode}:" class="ap-custom-emoji" loading="lazy">`,
);
}
return html;
}

View File

@@ -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 <img> 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 <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 } }.

View File

@@ -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,

View File

@@ -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",

View File

@@ -26,7 +26,7 @@
{# Boost header if this is a boosted post #}
{% if item.type == "boost" and item.boostedBy %}
<div class="ap-card__boost">
🔁 {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{{ item.boostedBy.name or "Someone" }}</a>{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %} {{ __("activitypub.reader.boosted") }}
🔁 {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
</div>
{% endif %}
@@ -49,9 +49,9 @@
<div class="ap-card__author-info">
<div class="ap-card__author-name">
{% if item.author.url %}
<a href="{{ mountPath }}/admin/reader/profile?url={{ item.author.url | urlencode }}">{{ item.author.name or "Unknown" }}</a>
<a href="{{ mountPath }}/admin/reader/profile?url={{ item.author.url | urlencode }}">{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</a>
{% else %}
<span>{{ item.author.name or "Unknown" }}</span>
<span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
{% endif %}
</div>
{% if item.author.handle %}

View File

@@ -9,7 +9,7 @@
<span class="ap-quote-embed__avatar ap-quote-embed__avatar--default">{{ item.quote.author.name[0] | upper if item.quote.author.name else "?" }}</span>
{% endif %}
<div class="ap-quote-embed__author-info">
<div class="ap-quote-embed__name">{{ item.quote.author.name or "Unknown" }}</div>
<div class="ap-quote-embed__name">{% if item.quote.author.nameHtml %}{{ item.quote.author.nameHtml | safe }}{% else %}{{ item.quote.author.name or "Unknown" }}{% endif %}</div>
{% if item.quote.author.handle %}
<div class="ap-quote-embed__handle">{{ item.quote.author.handle }}</div>
{% endif %}