Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision af6e0851a56b01f208302b89d54a26bb0181c768)
+++ src/services/ActivityPubService.js	(revision a677616cd3a4564e824f55e278142df81152336c)
@@ -869,10 +869,27 @@
   const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
   const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
+  const name = (doc && (doc.name || doc.preferredUsername)) || handle;
   return {
-    name: (doc && (doc.name || doc.preferredUsername)) || handle,
+    name,
     handle,
     url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
     icon: safeUrl(icon) || null,
+    // FEP-9098 custom emojis in the display name (":shortcode:"), so the byline
+    // renders them. Only computed when the name actually has a shortcode.
+    emojis: /:[A-Za-z0-9_+-]+:/.test(name) ? actorNameEmojis(doc) : undefined,
   };
+}
+
+// Map ":shortcode:" → image url from an actor doc's Emoji tags (for a custom-
+// emoji display name). Undefined when there are none.
+function actorNameEmojis(doc) {
+  const arr = doc && Array.isArray(doc.tag) ? doc.tag : (doc && doc.tag ? [doc.tag] : []);
+  const out = {};
+  for (const t of arr) {
+    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Emoji' || typeof t.name !== 'string' || !t.icon) continue;
+    const u = t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url);
+    if (u) out[t.name] = u;
+  }
+  return Object.keys(out).length ? out : undefined;
 }
 
@@ -1539,4 +1556,6 @@
           // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
           { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
+          storeAuthorEmoji(o.id, s.slug, ai);   // custom-emoji display name for the byline
+
           // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
           { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
@@ -1672,4 +1691,5 @@
             try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, bn.summary || null); inserted = r.changes > 0; } catch { /* ignore */ }
             if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, s.slug, bn.id); } catch { /* ignore */ } }
+            storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
           }
           console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
@@ -2665,4 +2685,12 @@
 }
 
+// Store the author's display-name emoji map (from actorInfo().emojis) on a
+// timeline row, so the byline can render a ":shortcode:" name. No-op when the
+// name has no custom emoji (the common case).
+function storeAuthorEmoji(id, slug, ai) {
+  if (!ai || !ai.emojis || !Object.keys(ai.emojis).length) return;
+  try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(ai.emojis), id, slug); } catch { /* ignore */ }
+}
+
 // ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
 let _abCount, _cirkelPosts, _cirkelMembers;
@@ -2761,5 +2789,5 @@
 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone
 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 12; // v12: quote card snapshot media as array + quoted-post emojis
+const SELFHEAL_VERSION = 13; // v13: capture custom-emoji display names (author_emoji_json) onto already-cached posts
 async function fetchNoteAP(url) {
   try {
@@ -2856,4 +2884,5 @@
         // FEP-9098: keep custom-emoji tags from backfilled posts too.
         { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, slug); } catch { /* ignore */ } } }
+        storeAuthorEmoji(o.id, slug, ai);   // custom-emoji display name for the byline
         // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
         { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
@@ -2980,5 +3009,5 @@
     if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
     let rows = [];
-    try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
+    try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     let healed = 0, failed = 0;
     for (const r of rows) {
@@ -3000,4 +3029,10 @@
           db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ?, quote_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, quote, r.id);
           healed++;
+        }
+        // v13: a custom-emoji display name needs the author's emoji map. Fetch
+        // the actor once, only for rows whose name has a shortcode and no map yet.
+        if (/:[A-Za-z0-9_+-]+:/.test(r.author_name || '') && !r.author_emoji_json && r.author_uri) {
+          const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
+          if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
         }
       } catch { failed++; /* per-note best-effort */ }
