Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/services/ActivityPubService.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -832,4 +832,17 @@
 }
 
+/**
+ * Does this note belong in the home timeline (de Krant)?
+ *
+ * Only if it is a POST. A direct note is addressed to named people, so it is a
+ * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
+ * guardian's wave. Those are stored as mentions instead and surface in
+ * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
+ */
+export function belongsInTimeline(o) {
+  if (!o || !o.id || o.inReplyTo) return false;
+  return noteVisibility(o) !== 'direct';
+}
+
 function iStmts() {
   if (!_insI) {
@@ -1524,4 +1537,17 @@
       iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
       console.log('[AP] reply', actorUri, '→', tgt.post_id);
+      // A reply is a post too: Berichten renders it the way de Krant renders a
+      // timeline row, so it needs the same media and the same quote/preview card.
+      {
+        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
+        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
+        const mj = mediaFromNote(o);
+        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
+        resolveCard(o).then((c) => {
+          if (!c) return;
+          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
+          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
+        }).catch(() => { /* best-effort */ });
+      }
       {
         // Private (followers/direct) replies push as a DM ping WITHOUT content
@@ -1541,5 +1567,5 @@
     }
     // Home timeline (client): a top-level post from an account we follow.
-    if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
+    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
       let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
       if (subs.length) {
@@ -1610,7 +1636,17 @@
         for (const slug of slugs) {
           try {
-            const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
-              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0);
+            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
+                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
+              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
+                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
             if (r.changes) {
+              // The quote / link-preview card resolves out of band (a remote
+              // fetch), exactly as it does for a timeline post, so the inbox
+              // answer is never blocked on it.
+              resolveCard(o).then((c) => {
+                if (!c) return;
+                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
+                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
+              }).catch(() => { /* best-effort */ });
               console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
               const vis = noteVisibility(o);
@@ -2849,5 +2885,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 = 20; // v20: oEmbed provider registry, so platforms that hide their tags from a server still resolve
+const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
 async function fetchNoteAP(url) {
   try {
@@ -2991,4 +3027,22 @@
   return JSON.stringify(snapshot);
 }
+
+/**
+ * The card under a post: a fediverse quote (FEP-044f) when the note has one,
+ * otherwise an external link preview. Both render as the SAME card, so only one
+ * of the two is ever stored. Returns {column, json} or null.
+ *
+ * Both halves reach out over the network, which is why every caller runs this
+ * out of band: an inbox answer must never wait on a third party.
+ */
+async function resolveCard(o) {
+  if (quoteHrefOf(o)) {
+    const qj = await resolveQuote(o);
+    return qj ? { column: 'quote_json', json: qj } : null;
+  }
+  const ej = await resolveExternalEmbed(o && o.content);
+  return ej ? { column: 'embed_json', json: ej } : null;
+}
+
 // A generic SSRF-safe AP GET (collections / pages).
 async function apGetJson(url) {
@@ -3158,4 +3212,16 @@
     try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
     if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
+    // v21: direct notes used to land in the timeline as if they were posts, so a
+    // ward's 🛟 help request showed up in the guardian's Krant. The insert now
+    // refuses them; drop the ones already cached. Scoped to the two kinds we can
+    // still recognise afterwards (help request, wave) — a plain public mention
+    // from someone you follow IS a timeline post and must stay.
+    try {
+      const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
+        SELECT 1 FROM ap_mentions m
+         WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
+           AND (m.help_request = 1 OR m.wave = 1))`).run();
+      if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
+    } catch { /* table may predate the columns */ }
     let rows = [];
     try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
@@ -3456,5 +3522,5 @@
     const rows = db.prepare(`
       SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility,
-             i.emoji_json, i.actor_emoji_json,
+             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
              p.slug AS post_slug, p.title AS post_title
       FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
@@ -3466,4 +3532,5 @@
       content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
       emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (messages render)
+      media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json,   // rendered like a Krant post
       // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
       visibility: r.visibility || 'public',
@@ -3488,6 +3555,10 @@
   } catch { /* ignore */ }
   try {
-    for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
-      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at });
+    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at,
+                                       emoji_json, actor_emoji_json, media_json, quote_json, embed_json
+                                FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
+      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at,
+        // Same trimmings a Krant row has, so Berichten renders the post identically.
+        emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
     }
   } catch { /* ignore */ }
@@ -3716,5 +3787,5 @@
   getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
-  noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
+  noteVisibility, belongsInTimeline, isRejectedObject, rejectInteraction, interactionReportTarget,
   getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
 };
