Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 3bbf73d1fa44d3393eeff82f89790687762e1983)
+++ src/routes/activitypub.js	(revision f0a33e1fda60c0367aaf75361aa81bcd3e0254f6)
@@ -283,339 +283,25 @@
 });
 
-// ── De poorten van een lezer, op EEN plek (FEP-633c) ─────────────
+/** De byline-gegevens uit een tijdlijnrij, langs de emoji-poort. */
+function authorInfoFrom(r, prefix, gates) {
+  const info = {
+    name: r[`${prefix}name`] || undefined, handle: r[`${prefix}handle`] || undefined,
+    icon: r[`${prefix}icon`] || undefined, url: r[`${prefix}url`] || undefined,
+    emojis: (() => { try { return r[`${prefix}emoji_json`] ? JSON.parse(r[`${prefix}emoji_json`]) : undefined; } catch { return undefined; } })(),
+  };
+  return (info.name || info.handle || info.icon) ? gates.gateAuthor(info) : undefined;
+}
+
+// ── Een tijdlijnpost als AS2-item: EEN beschrijving van de kaartvorm ──
 //
-// De inbox-lezing rekende ze inline uit. Nu er meer lezingen zijn die
-// dezelfde poorten moeten eerbiedigen (de gesprekken, de geschiedenis), zou
-// dat evenveel kopieen worden -- en een poort die op een van die plekken
-// vergeten wordt, levert stil iets uit dat dicht hoorde te staan.
-function gatesFor(site) {
-  const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-  const embeds = Guardianship.externalEmbedsAllowed(site.external_embeds, isWard);
-  const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
-  const emoji = gate('gate_custom_emoji');
-  return {
-    isWard,
-    embedsAllowed: embeds,
-    playbackAllowed: embeds && Guardianship.externalPlaybackAllowed(site.external_playback, isWard),
-    imagesAllowed: gate('gate_images'),
-    musicAllowed: gate('gate_music'),
-    quotesAllowed: gate('gate_quote_cards'),
-    emojiAllowed: emoji,
-    messagesAllowed: gate('gate_messages'),
-    composeAllowed: gate('gate_compose'),
-    repliesAllowed: gate('gate_replies'),
-    threadsAllowed: gate('external_threads'),
-    followingAllowed: gate('gate_following'),
-    // Emoji dicht raakt ook de bylines: de plaatjes in een naam komen net zo
-    // goed van een vreemde server. De naam zelf blijft, met :shortcode: als tekst.
-    gateAuthor: (a) => (a && !emoji ? { ...a, emojis: undefined } : a),
-  };
-}
-
-/** De naam waaronder deze lezer zichzelf herkent in een Mention. */
-function ownHandle(base, slug) {
-  try { return `@${slug}@${new URL(base).host}`; } catch { return `@${slug}`; }
-}
-
-// ── Een bericht als AS2-item: EEN beschrijving van de kaartvorm ──
-//
-// Gebruikt door de inbox-lezing en door de gesprekslezingen. Twee keer
-// opschrijven is twee vormen die uit de pas kunnen lopen, en dat merk je pas
-// als een kaart ergens anders rendert dan waar je keek.
-function messageItem(m, { base, me, myHandle, p }) {
-  return {
-    id: `${m.object_uri}#create`,
-    type: 'Create',
-    actor: m.actor_uri,
-    published: AP.isoStamp(m.published || m.created_at),
-    object: {
-      id: m.object_uri,
-      type: 'Note',
-      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
-        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
-        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
-        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
-      }) : undefined),
-      content: AP.stripLeadingMentions(m.content),
-      url: m.note_url || undefined,
-      published: AP.isoStamp(m.published || m.created_at),
-      // Addressed to us and to nobody we know of: the other recipients of a
-      // note to several people are not ours to see, so we serve what we know.
-      to: [me],
-      // The Mention is how the client recognises itself as the addressee and
-      // groups the note into a conversation. No FEP-e232 link tags here: a
-      // mention row keeps the resolved quote, not the raw tags.
-      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(p.emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
-      attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: p.imagesAllowed, audio: p.musicAllowed }),
-      // FEP-633c: what kind of message this is. The wave is a gentle nudge from
-      // a guardian; the help request is the buoy. Both render differently.
-      'shaer:wave': m.wave ? true : undefined,
-      'shaer:helpRequest': m.help_request ? true : undefined,
-      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
-      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
-    },
-  };
-}
-
-/** Een eigen verzonden note als AS2-item, zelfde vorm als de inbox-leg. */
-function sentItem(n, { me, mine }) {
-  return {
-    id: `${n.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: n.published,
-    // The leading mention anchor is addressing, not prose (the DM leg strips
-    // it the same way); the Mention tags built from the full content stay.
-    object: {
-      ...n, content: AP.stripLeadingMentions(n.content),
-      attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
-    },
-  };
-}
-
-// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────
-//
-// Twee lezingen naast de bestaande inbox-lezing, niet in de plaats ervan: de
-// apps in het veld lezen die nog. /conversations geeft EEN rij per tegenpartij
-// -- compleet van vorm, dus de avatarhemel kan niemand kwijtraken doordat een
-// ander druk was -- en /messages geeft een gesprek met een cursor, zodat een
-// 'load more' eerlijk kan verschijnen in plaats van dat de geschiedenis stil
-// ophoudt.
-//
-// Beide lopen langs dezelfde poorten als de inbox-lezing (gatesFor) en
-// dezelfde kaartvorm (messageItem/sentItem). Messages dicht sluit ook
-// hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
-function conversationItems(req, auth, refs) {
-  const base = baseUrl(req);
-  const P = gatesFor(auth.site);
-  const me = AP.actorId(base, auth.site.slug);
-  const ctx = { base, me, myHandle: ownHandle(base, auth.site.slug), p: P };
-  const mine = AP.selfAuthor(base, auth.site);
-  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
-
-  const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
-    .map((m) => [m.object_uri, m]));
-  const items = [];
-  for (const r of refs) {
-    if (r.direction === 'in') {
-      const m = incoming.get(r.ref);
-      if (!m) continue;
-      if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
-      items.push(messageItem(m, ctx));
-    } else {
-      const n = AP.getOutboxNote(base, r.ref);
-      // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
-      // niet wat je zelf gezegd hebt.
-      if (n) items.push(sentItem(n, { me, mine }));
-    }
-  }
-  return items;
-}
-
-router.get('/ap/users/:slug/conversations', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const heads = AP.conversationHeads(auth.site.slug);
-  const items = conversationItems(req, auth, heads);
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/conversations`,
-    type: 'OrderedCollection',
-    totalItems: items.length,
-    orderedItems: items,
-    'shaer:cursor': AP.feedCursor(auth.site.slug),
-  }, 'private, no-store');
-});
-
-router.get('/ap/users/:slug/messages', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const other = String(req.query.with || '');
-  if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
-  const page = AP.conversationHistory(auth.site.slug, other, {
-    before: req.query.before ? String(req.query.before) : null,
-    limit: req.query.limit,
-  });
-  const items = conversationItems(req, auth, page.rows);
-  // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
-  // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
-  // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
-  const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
-  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: req.query.before ? `${self}${size}&before=${encodeURIComponent(String(req.query.before))}` : `${self}${size}`,
-    type: 'OrderedCollectionPage',
-    partOf: self,
-    orderedItems: items,
-    // De volgende pagina is de standaardvorm van 'er is meer' (AS2). Ontbreekt
-    // hij, dan is het gesprek op -- en dat mag de client weten zonder gokken,
-    // want anders kan een 'load more' niet eerlijk verschijnen.
-    next: page.more && page.oldest ? `${self}${size}&before=${encodeURIComponent(page.oldest)}` : undefined,
-  }, 'private, no-store');
-});
-
-// ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
-// Hold the request until something push-worthy lands for this account, then
-// answer 200 (news: re-read your feed) or 204 after ~25s (nothing: re-arm).
-// The thread in the app stays live without interval polling.
-router.get('/ap/users/:slug/inbox/wait', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  let settled = false;
-  const done = (code) => {
-    if (settled) return;
-    settled = true;
-    clearTimeout(timer);
-    off();
-    if (!res.headersSent) res.status(code).end();
-  };
-  const off = AP.onNews(auth.site.slug, () => done(200));
-  const timer = setTimeout(() => done(204), 25_000);
-  req.on('close', () => done(204));
-});
-
-// ── Blocked collection (owner only, AP §5.6) ──────────────────────
-// The server blocklist is the source of truth for Shaer's "in Orbit":
-// clients read it here instead of keeping their own state. Actor-kind
-// blocks only (domain blocks are instance policy, not an Orbit member).
-router.get('/ap/users/:slug/blocked', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const base = baseUrl(req);
-  const items = AP.listBlocks(auth.site.slug)
-    .filter((b) => b.kind === 'actor')
-    .map((b) => b.target);
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${base}/ap/users/${auth.site.slug}/blocked`,
-    type: 'OrderedCollection',
-    totalItems: items.length,
-    orderedItems: items,
-  });
-});
-
-// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
-// The dashboard collections the Shaer clients read: pending adoption offers,
-// gated follows (empty in Klonkt for now) and the guardian's wards. Same
-// contract as the Shaer test daemon.
-function queueRoute(name, build) {
-  router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
-    const auth = OAuth.verifyBearer(req.headers.authorization);
-    if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-    const base = baseUrl(req);
-    const me = `${base}/ap/users/${auth.site.slug}`;
-    // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
-    // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
-    // honderd wards inclusief poorten terug.
-    AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
-  });
-}
-queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
-queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
-// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
-// waiting on its guardians. Owner-only like the rest — who a child wants to
-// follow is nobody else's business.
-queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
-queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
-// Availability (FEP-633c 3.6.1) is never public: the ward reads its
-// guardians' real states here and nowhere else.
-queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
-
-// ── Het logboek (FEP-633c §4.2, shaer:log) ────────────────────────────
-// NAAST de wachtrijen en niet erin: alles onder shaer:queues wacht op een
-// antwoord, dit is wat er al besloten is. Eigen pad, dezelfde eigenaar-only
-// bearer. Het bestaat omdat een weigering anders alleen te merken viel doordat
-// er iets uit een lijst verdween, en "het is weg" is geen reden.
-router.get('/ap/users/:slug/log', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const me = `${baseUrl(req)}/ap/users/${auth.site.slug}`;
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    ...Guardianship.logCollection(`${me}/log`, auth.site.slug, (s) => AP.listGuardianEvents(s, 50)),
-  }, 'private, no-store');
-});
-// De hulpvragen MET hun staat (5.2.1, shaer-lgo). De apps lazen ze uit de feed
-// en wisten dus niet of er al iemand op af was -- daarom bleef een afgehandeld
-// verzoek daar staan (Barts melding, 8-8).
-queueRoute('help', (id, slug) => Guardianship.helpCollection(id, slug));
-
-// ── Inbox read (owner only, AP C2S) ───────────────────────────────
-// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
-// scoped to this site) reads recent inbound posts (the timeline: accounts
-// they follow) as Create(Note) items, so an app (Shaer) can build a unified
-// feed. Anyone else gets 403; the inbox stays write-only for the public.
-router.get('/ap/users/:slug/inbox', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const base = baseUrl(req);
-  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
-  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
-  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
-  // gedraagt de route zich exact zoals altijd.
-  //
-  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
-  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
-  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
-  // ronde.
-  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
-  if (req.query.since && wachtS > 0) {
-    const afbreken = new AbortController();
-    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
-    const uit = await AP.waitForFeedChange(auth.site.slug, {
-      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
-    });
-    if (res.writableEnded || afbreken.signal.aborted) return undefined;
-    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
-    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
-    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
-    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
-    // dat voor nieuws twee rondjes nodig heeft.
-    //
-    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
-    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
-    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
-    // een lege merksteen sturen we dus gewoon de collectie.
-    if (!uit.changed && uit.cursor !== '0') {
-      res.set('Vary', 'Authorization');
-      return res.status(304).end();
-    }
-  }
-  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
-  // world outside the fediverse is the guardians' call. The gate is applied
-  // here, at serialisation: a blocked embed is never sent, because an embed the
-  // client merely hides has still been delivered to the device.
-  // De poorten van deze lezer (gatesFor): een plek waar ze berekend worden,
-  // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
-  // kopie krijgen die kan gaan afwijken.
-  const P = gatesFor(auth.site);
+// Zelfde reden als messageItem hieronder: de volledige lezing en de
+// verschil-lezing bouwen dezelfde kaart, en twee beschrijvingen lopen uit de
+// pas zonder dat iemand het merkt.
+function timelineItem(t, { p, reactions }) {
+  const authorInfo = (r, prefix) => authorInfoFrom(r, prefix, p);
   const {
-    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed,
-    emojiAllowed, messagesAllowed, composeAllowed, repliesAllowed, threadsAllowed,
-    followingAllowed, gateAuthor,
-  } = P;
-  // De rechten-lijst hieronder vraagt er nog een paar rechtstreeks op.
-  const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], P.isWard);
-  // ── Standaardvormen naast het dialect (shaer-nmw) ────────────────
-  //
-  // Een lezer die AS2 kent heeft nu genoeg aan attributedTo (ingesloten
-  // actor), quote (FEP-044f als object), preview (AS2 core) en de
-  // Announce-wrapper. De shaer:-velden blijven er nog naast staan voor apps
-  // in het veld; die gaan eruit als de clients om zijn.
-  const authorInfo = (r, p) => {
-    const info = {
-      name: r[`${p}name`] || undefined, handle: r[`${p}handle`] || undefined,
-      icon: r[`${p}icon`] || undefined, url: r[`${p}url`] || undefined,
-      emojis: (() => { try { return r[`${p}emoji_json`] ? JSON.parse(r[`${p}emoji_json`]) : undefined; } catch { return undefined; } })(),
-    };
-    return (info.name || info.handle || info.icon) ? gateAuthor(info) : undefined;
-  };
-  const rows = AP.getTimeline(auth.site.slug, 60);
-  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
-  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
-  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
-  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
-  const posts = rows.map((t) => {
+    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed, emojiAllowed,
+  } = p;
+  const reacties = reactions || new Map();
     const auteur = authorInfo(t, 'author_');
     const booster = authorInfo(t, 'reblog_');
@@ -664,25 +350,9 @@
     },
   };
-  });
-  // The direct notes addressed to this account: a plain DM, a guardian's wave
-  // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
-  // they are not in the timeline; without them the app's Berichten shows only
-  // what you said yourself. Same shape as a post, so one parser handles both.
-  const me = AP.actorId(base, auth.site.slug);
-  const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
-  // Messages dicht (shaer-3ow) sluit vreemden en vrienden, maar NOOIT het
-  // guardian-kanaal: de zwaai en het gesprek na een hulpvraag zijn precies
-  // het kanaal dat het kind veilig houdt, en een poort die dat afsnijdt
-  // beschermt niemand. De hulpvraag zelf gaat aan de innamekant al altijd voor.
-  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
-  const messageCtx = { base, me, myHandle, p: P };
-  const messages = AP.getDirectMessages(auth.site.slug, 60)
-    .filter((m) => messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))
-    .map((m) => messageItem(m, messageCtx));
-  // Inbound REPLIES on your own posts: stored as interactions (the web's
-  // comment machinery), never as mentions, so this read missed them and a
-  // friend's reply arrived everywhere except in your app (Robins melding,
-  // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
-  const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => ({
+}
+
+/** Een inkomend antwoord op je eigen post als AS2-item. */
+function replyItem(m, { base, me, myHandle, p }) {
+  return {
     id: `${m.object_uri}#create`,
     type: 'Create',
@@ -692,5 +362,5 @@
       id: m.object_uri,
       type: 'Note',
-      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? gateAuthor({
+      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
         name: m.actor_name || undefined, handle: m.actor_handle || undefined,
         icon: m.actor_icon || undefined, url: m.actor_url || undefined,
@@ -703,8 +373,413 @@
       tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
       attachment: AP.timelineAttachments(m.media_json),
-      quote: quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
-      preview: embedsAllowed ? AP.previewObject(m.embed_json, { playback: playbackAllowed }) : undefined,
+      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
+      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
     },
-  }));
+  };
+}
+
+// ── De poorten van een lezer, op EEN plek (FEP-633c) ─────────────
+//
+// De inbox-lezing rekende ze inline uit. Nu er meer lezingen zijn die
+// dezelfde poorten moeten eerbiedigen (de gesprekken, de geschiedenis), zou
+// dat evenveel kopieen worden -- en een poort die op een van die plekken
+// vergeten wordt, levert stil iets uit dat dicht hoorde te staan.
+function gatesFor(site) {
+  const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
+  const embeds = Guardianship.externalEmbedsAllowed(site.external_embeds, isWard);
+  const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
+  const emoji = gate('gate_custom_emoji');
+  return {
+    isWard,
+    embedsAllowed: embeds,
+    playbackAllowed: embeds && Guardianship.externalPlaybackAllowed(site.external_playback, isWard),
+    imagesAllowed: gate('gate_images'),
+    musicAllowed: gate('gate_music'),
+    quotesAllowed: gate('gate_quote_cards'),
+    emojiAllowed: emoji,
+    messagesAllowed: gate('gate_messages'),
+    composeAllowed: gate('gate_compose'),
+    repliesAllowed: gate('gate_replies'),
+    threadsAllowed: gate('external_threads'),
+    followingAllowed: gate('gate_following'),
+    // Emoji dicht raakt ook de bylines: de plaatjes in een naam komen net zo
+    // goed van een vreemde server. De naam zelf blijft, met :shortcode: als tekst.
+    gateAuthor: (a) => (a && !emoji ? { ...a, emojis: undefined } : a),
+  };
+}
+
+/** De naam waaronder deze lezer zichzelf herkent in een Mention. */
+function ownHandle(base, slug) {
+  try { return `@${slug}@${new URL(base).host}`; } catch { return `@${slug}`; }
+}
+
+// ── Een bericht als AS2-item: EEN beschrijving van de kaartvorm ──
+//
+// Gebruikt door de inbox-lezing en door de gesprekslezingen. Twee keer
+// opschrijven is twee vormen die uit de pas kunnen lopen, en dat merk je pas
+// als een kaart ergens anders rendert dan waar je keek.
+function messageItem(m, { base, me, myHandle, p }) {
+  return {
+    id: `${m.object_uri}#create`,
+    type: 'Create',
+    actor: m.actor_uri,
+    published: AP.isoStamp(m.published || m.created_at),
+    object: {
+      id: m.object_uri,
+      type: 'Note',
+      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
+        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
+        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
+        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
+      }) : undefined),
+      content: AP.stripLeadingMentions(m.content),
+      url: m.note_url || undefined,
+      published: AP.isoStamp(m.published || m.created_at),
+      // Addressed to us and to nobody we know of: the other recipients of a
+      // note to several people are not ours to see, so we serve what we know.
+      to: [me],
+      // The Mention is how the client recognises itself as the addressee and
+      // groups the note into a conversation. No FEP-e232 link tags here: a
+      // mention row keeps the resolved quote, not the raw tags.
+      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(p.emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
+      attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: p.imagesAllowed, audio: p.musicAllowed }),
+      // FEP-633c: what kind of message this is. The wave is a gentle nudge from
+      // a guardian; the help request is the buoy. Both render differently.
+      'shaer:wave': m.wave ? true : undefined,
+      'shaer:helpRequest': m.help_request ? true : undefined,
+      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
+      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
+    },
+  };
+}
+
+/** Een eigen verzonden note als AS2-item, zelfde vorm als de inbox-leg. */
+function sentItem(n, { me, mine }) {
+  return {
+    id: `${n.id}#create`,
+    type: 'Create',
+    actor: me,
+    published: n.published,
+    // The leading mention anchor is addressing, not prose (the DM leg strips
+    // it the same way); the Mention tags built from the full content stay.
+    object: {
+      ...n, content: AP.stripLeadingMentions(n.content),
+      attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
+    },
+  };
+}
+
+// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────
+//
+// Twee lezingen naast de bestaande inbox-lezing, niet in de plaats ervan: de
+// apps in het veld lezen die nog. /conversations geeft EEN rij per tegenpartij
+// -- compleet van vorm, dus de avatarhemel kan niemand kwijtraken doordat een
+// ander druk was -- en /messages geeft een gesprek met een cursor, zodat een
+// 'load more' eerlijk kan verschijnen in plaats van dat de geschiedenis stil
+// ophoudt.
+//
+// Beide lopen langs dezelfde poorten als de inbox-lezing (gatesFor) en
+// dezelfde kaartvorm (messageItem/sentItem). Messages dicht sluit ook
+// hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
+function conversationItems(req, auth, refs) {
+  const base = baseUrl(req);
+  const P = gatesFor(auth.site);
+  const me = AP.actorId(base, auth.site.slug);
+  const ctx = { base, me, myHandle: ownHandle(base, auth.site.slug), p: P };
+  const mine = AP.selfAuthor(base, auth.site);
+  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
+
+  const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
+    .map((m) => [m.object_uri, m]));
+  const items = [];
+  for (const r of refs) {
+    if (r.direction === 'in') {
+      const m = incoming.get(r.ref);
+      if (!m) continue;
+      if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
+      items.push(messageItem(m, ctx));
+    } else {
+      const n = AP.getOutboxNote(base, r.ref);
+      // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
+      // niet wat je zelf gezegd hebt.
+      if (n) items.push(sentItem(n, { me, mine }));
+    }
+  }
+  return items;
+}
+
+router.get('/ap/users/:slug/conversations', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const heads = AP.conversationHeads(auth.site.slug);
+  const items = conversationItems(req, auth, heads);
+  AP.sendAP(res, {
+    '@context': AP.AP_CONTEXT,
+    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/conversations`,
+    type: 'OrderedCollection',
+    totalItems: items.length,
+    orderedItems: items,
+    'shaer:cursor': AP.feedCursor(auth.site.slug),
+  }, 'private, no-store');
+});
+
+router.get('/ap/users/:slug/messages', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const other = String(req.query.with || '');
+  if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
+  const page = AP.conversationHistory(auth.site.slug, other, {
+    before: req.query.before ? String(req.query.before) : null,
+    limit: req.query.limit,
+  });
+  const items = conversationItems(req, auth, page.rows);
+  // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
+  // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
+  // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
+  const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
+  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
+  AP.sendAP(res, {
+    '@context': AP.AP_CONTEXT,
+    id: req.query.before ? `${self}${size}&before=${encodeURIComponent(String(req.query.before))}` : `${self}${size}`,
+    type: 'OrderedCollectionPage',
+    partOf: self,
+    orderedItems: items,
+    // De volgende pagina is de standaardvorm van 'er is meer' (AS2). Ontbreekt
+    // hij, dan is het gesprek op -- en dat mag de client weten zonder gokken,
+    // want anders kan een 'load more' niet eerlijk verschijnen.
+    next: page.more && page.oldest ? `${self}${size}&before=${encodeURIComponent(page.oldest)}` : undefined,
+  }, 'private, no-store');
+});
+
+// ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
+// Hold the request until something push-worthy lands for this account, then
+// answer 200 (news: re-read your feed) or 204 after ~25s (nothing: re-arm).
+// The thread in the app stays live without interval polling.
+router.get('/ap/users/:slug/inbox/wait', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  let settled = false;
+  const done = (code) => {
+    if (settled) return;
+    settled = true;
+    clearTimeout(timer);
+    off();
+    if (!res.headersSent) res.status(code).end();
+  };
+  const off = AP.onNews(auth.site.slug, () => done(200));
+  const timer = setTimeout(() => done(204), 25_000);
+  req.on('close', () => done(204));
+});
+
+// ── Blocked collection (owner only, AP §5.6) ──────────────────────
+// The server blocklist is the source of truth for Shaer's "in Orbit":
+// clients read it here instead of keeping their own state. Actor-kind
+// blocks only (domain blocks are instance policy, not an Orbit member).
+router.get('/ap/users/:slug/blocked', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const base = baseUrl(req);
+  const items = AP.listBlocks(auth.site.slug)
+    .filter((b) => b.kind === 'actor')
+    .map((b) => b.target);
+  AP.sendAP(res, {
+    '@context': AP.AP_CONTEXT,
+    id: `${base}/ap/users/${auth.site.slug}/blocked`,
+    type: 'OrderedCollection',
+    totalItems: items.length,
+    orderedItems: items,
+  });
+});
+
+// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
+// The dashboard collections the Shaer clients read: pending adoption offers,
+// gated follows (empty in Klonkt for now) and the guardian's wards. Same
+// contract as the Shaer test daemon.
+function queueRoute(name, build) {
+  router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
+    const auth = OAuth.verifyBearer(req.headers.authorization);
+    if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+    const base = baseUrl(req);
+    const me = `${base}/ap/users/${auth.site.slug}`;
+    // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
+    // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
+    // honderd wards inclusief poorten terug.
+    AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
+  });
+}
+queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
+queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
+// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
+// waiting on its guardians. Owner-only like the rest — who a child wants to
+// follow is nobody else's business.
+queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
+queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
+// Availability (FEP-633c 3.6.1) is never public: the ward reads its
+// guardians' real states here and nowhere else.
+queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
+
+// ── Het logboek (FEP-633c §4.2, shaer:log) ────────────────────────────
+// NAAST de wachtrijen en niet erin: alles onder shaer:queues wacht op een
+// antwoord, dit is wat er al besloten is. Eigen pad, dezelfde eigenaar-only
+// bearer. Het bestaat omdat een weigering anders alleen te merken viel doordat
+// er iets uit een lijst verdween, en "het is weg" is geen reden.
+router.get('/ap/users/:slug/log', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const me = `${baseUrl(req)}/ap/users/${auth.site.slug}`;
+  AP.sendAP(res, {
+    '@context': AP.AP_CONTEXT,
+    ...Guardianship.logCollection(`${me}/log`, auth.site.slug, (s) => AP.listGuardianEvents(s, 50)),
+  }, 'private, no-store');
+});
+// De hulpvragen MET hun staat (5.2.1, shaer-lgo). De apps lazen ze uit de feed
+// en wisten dus niet of er al iemand op af was -- daarom bleef een afgehandeld
+// verzoek daar staan (Barts melding, 8-8).
+queueRoute('help', (id, slug) => Guardianship.helpCollection(id, slug));
+
+// ── Inbox read (owner only, AP C2S) ───────────────────────────────
+// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
+// scoped to this site) reads recent inbound posts (the timeline: accounts
+// they follow) as Create(Note) items, so an app (Shaer) can build a unified
+// feed. Anyone else gets 403; the inbox stays write-only for the public.
+router.get('/ap/users/:slug/inbox', async (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const base = baseUrl(req);
+  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
+  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
+  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
+  // gedraagt de route zich exact zoals altijd.
+  //
+  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
+  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
+  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
+  // ronde.
+  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
+  if (req.query.since && wachtS > 0) {
+    const afbreken = new AbortController();
+    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
+    const uit = await AP.waitForFeedChange(auth.site.slug, {
+      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
+    });
+    if (res.writableEnded || afbreken.signal.aborted) return undefined;
+    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
+    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
+    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
+    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
+    // dat voor nieuws twee rondjes nodig heeft.
+    //
+    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
+    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
+    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
+    // een lege merksteen sturen we dus gewoon de collectie.
+    if (!uit.changed && uit.cursor !== '0') {
+      res.set('Vary', 'Authorization');
+      return res.status(304).end();
+    }
+  }
+  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
+  // world outside the fediverse is the guardians' call. The gate is applied
+  // here, at serialisation: a blocked embed is never sent, because an embed the
+  // client merely hides has still been delivered to the device.
+  // De poorten van deze lezer (gatesFor): een plek waar ze berekend worden,
+  // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
+  // kopie krijgen die kan gaan afwijken.
+  const P = gatesFor(auth.site);
+  const {
+    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed,
+    emojiAllowed, messagesAllowed, composeAllowed, repliesAllowed, threadsAllowed,
+    followingAllowed, gateAuthor,
+  } = P;
+  // De rechten-lijst hieronder vraagt er nog een paar rechtstreeks op.
+  const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], P.isWard);
+  // ── Standaardvormen naast het dialect (shaer-nmw) ────────────────
+  //
+  // Een lezer die AS2 kent heeft nu genoeg aan attributedTo (ingesloten
+  // actor), quote (FEP-044f als object), preview (AS2 core) en de
+  // Announce-wrapper. De shaer:-velden blijven er nog naast staan voor apps
+  // in het veld; die gaan eruit als de clients om zijn.
+  // Wie ik ben en wie mijn guardians zijn: allebei de lezingen hieronder
+  // hebben ze nodig, dus een keer, hierboven.
+  const me = AP.actorId(base, auth.site.slug);
+  const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
+  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
+  // ── Alleen het VERSCHIL, als de client daarom vraagt (shaer-pq4) ──
+  //
+  // De wachtende lezing zei tot nu toe alleen DAT er iets veranderde, waarna de
+  // client alles opnieuw las: vier legs van zestig met al hun media-, quote- en
+  // embed-JSON, voor een enkel nieuw bericht. ap_feed_state houdt per object al
+  // bij wat er wanneer veranderde, dus het verschil lag er klaar en werd alleen
+  // nooit uitgedeeld (feedChangesSince had geen enkele aanroeper).
+  //
+  // OPT-IN met ?changes=1, en dat is geen franje: een app in het veld stuurt
+  // `since` al mee en vervangt haar hele feed door wat er terugkomt. Zou
+  // `since` opeens een verschil betekenen, dan wist die app zichzelf leeg.
+  //
+  // Het antwoord is een OrderedCollectionPage met partOf, want dat is wat het
+  // IS -- een deel, geen collectie. Een generieke lezer ziet dat verschil ook.
+  if (req.query.changes && req.query.since) {
+    const veranderd = AP.feedChangesSince(auth.site.slug, String(req.query.since));
+    const levend = veranderd.filter((c) => c.kind !== 'deleted').map((c) => c.object_uri);
+    const tl = new Map(AP.timelineRowsByIds(auth.site.slug, levend).map((r) => [r.id, r]));
+    const mn = new Map(AP.messageRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u))).map((r) => [r.object_uri, r]));
+    const rp = new Map(AP.replyRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u) && !mn.has(u))).map((r) => [r.object_uri, r]));
+    const reacties = AP.getReactionsFor(auth.site.slug, [...tl.keys()]);
+    const ctx = { base, me, myHandle, p: P };
+    const items = [];
+    for (const c of veranderd) {
+      if (c.kind === 'deleted') {
+        // Een verwijdering reisde tot nu toe als AFWEZIGHEID mee: de volledige
+        // lezing bevatte hem simpelweg niet meer. Die volledigheid is precies
+        // wat hier wegvalt, dus zonder grafsteen zou een weggehaalde post voor
+        // altijd in de app blijven staan -- en dat faalt stil. AS2 heeft er een
+        // vorm voor, en de rij lag er al.
+        items.push({ type: 'Delete', actor: me, object: { id: c.object_uri, type: 'Tombstone' } });
+        continue;
+      }
+      const t = tl.get(c.object_uri);
+      if (t) { items.push(timelineItem(t, { p: P, reactions: reacties })); continue; }
+      const m = mn.get(c.object_uri);
+      if (m) {
+        if (messagesAllowed || m.help_request || guardianUris.has(m.actor_uri)) items.push(messageItem(m, ctx));
+        continue;
+      }
+      const r = rp.get(c.object_uri);
+      if (r) { items.push(replyItem(r, ctx)); continue; }
+      const n = AP.getOutboxNote(base, c.object_uri);
+      if (n) items.push(sentItem(n, { me, mine: AP.selfAuthor(base, auth.site) }));
+    }
+    return AP.sendAP(res, {
+      '@context': AP.AP_CONTEXT,
+      id: `${base}/ap/users/${encodeURIComponent(auth.site.slug)}/inbox?changes=1&since=${encodeURIComponent(String(req.query.since))}`,
+      type: 'OrderedCollectionPage',
+      partOf: `${base}/ap/users/${auth.site.slug}/inbox`,
+      orderedItems: items,
+      'shaer:cursor': AP.feedCursor(auth.site.slug),
+    }, 'private, no-store');
+  }
+  const rows = AP.getTimeline(auth.site.slug, 60);
+  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
+  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
+  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
+  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
+  const posts = rows.map((t) => timelineItem(t, { p: P, reactions: reacties }));
+  // The direct notes addressed to this account: a plain DM, a guardian's wave
+  // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
+  // they are not in the timeline; without them the app's Berichten shows only
+  // what you said yourself. Same shape as a post, so one parser handles both.
+  // Messages dicht (shaer-3ow) sluit vreemden en vrienden, maar NOOIT het
+  // guardian-kanaal: de zwaai en het gesprek na een hulpvraag zijn precies
+  // het kanaal dat het kind veilig houdt, en een poort die dat afsnijdt
+  // beschermt niemand. De hulpvraag zelf gaat aan de innamekant al altijd voor.
+  const messageCtx = { base, me, myHandle, p: P };
+  const messages = AP.getDirectMessages(auth.site.slug, 60)
+    .filter((m) => messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))
+    .map((m) => messageItem(m, messageCtx));
+  // Inbound REPLIES on your own posts: stored as interactions (the web's
+  // comment machinery), never as mentions, so this read missed them and a
+  // friend's reply arrived everywhere except in your app (Robins melding,
+  // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
+  const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => replyItem(m, messageCtx));
   // Your OWN sent notes (replies and direct messages, ap_outbox): without
   // them a reply existed everywhere except in your own app, Messages showed
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 3bbf73d1fa44d3393eeff82f89790687762e1983)
+++ src/services/ActivityPubService.js	(revision f0a33e1fda60c0367aaf75361aa81bcd3e0254f6)
@@ -4095,10 +4095,44 @@
 // everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet
 // binnen bij de ander").
+const REPLY_COLUMNS = `
+      i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
+      i.content, i.published, i.created_at, i.parent_uri, i.post_id,
+      i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json`;
+
+/** Dezelfde antwoordrijen, maar op object-uri -- voor de verschil-lezing. */
+export function replyRowsByUri(slug, uris) {
+  const list = (uris || []).filter((u) => typeof u === 'string' && u);
+  if (!list.length) return [];
+  try {
+    const holes = list.map(() => '?').join(',');
+    return db.prepare(`SELECT ${REPLY_COLUMNS} FROM ap_interactions i
+                        JOIN posts p ON p.id = i.post_id
+                        JOIN sites s ON s.id = p.site_id
+                       WHERE s.slug = ? AND i.kind = 'reply' AND i.object_uri IN (${holes})`)
+      .all(slug, ...list);
+  } catch { return []; }
+}
+
+/** Tijdlijnrijen op id, met dezelfde afgeleide liked/boosted als getTimeline. */
+export function timelineRowsByIds(slug, ids) {
+  const list = (ids || []).filter((u) => typeof u === 'string' && u);
+  if (!list.length) return [];
+  try {
+    const holes = list.map(() => '?').join(',');
+    const rows = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? AND id IN (${holes})`).all(slug, ...list);
+    const reacties = getReactionsFor(slug, rows.map((r) => r.id));
+    for (const r of rows) {
+      const x = reacties.get(r.id);
+      r.liked = !!(x && x.liked);
+      r.boosted = !!(x && x.boosted);
+    }
+    return rows;
+  } catch { return []; }
+}
+
 export function getReplyMessages(slug, limit) {
   try {
     return db.prepare(`
-      SELECT i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
-             i.content, i.published, i.created_at, i.parent_uri, i.post_id,
-             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json
+      SELECT ${REPLY_COLUMNS}
       FROM ap_interactions i
       JOIN posts p ON p.id = i.post_id
@@ -6391,5 +6425,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, messageRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, getDirectMessages, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
Index: test/feed-changes.test.js
===================================================================
--- test/feed-changes.test.js	(revision f0a33e1fda60c0367aaf75361aa81bcd3e0254f6)
+++ test/feed-changes.test.js	(revision f0a33e1fda60c0367aaf75361aa81bcd3e0254f6)
@@ -0,0 +1,95 @@
+// Alleen het verschil, als de client erom vraagt (shaer-pq4).
+//
+// De wachtende lezing zei tot nu toe alleen DAT er iets veranderde, waarna de
+// client alles opnieuw las. Deze toetsen leggen drie dingen vast: dat het
+// verschil klopt, dat een verwijdering meereist (want de volledigheid die dat
+// tot nu toe deed valt hier weg), en dat de OUDE lezing niet verandert -- een
+// app in het veld stuurt `since` al mee en zou zichzelf leegwissen.
+//
+// In-memory SQLite. Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kind', 'Kind', 'u1');
+
+const post = (id, tekst) => db.prepare(
+  `INSERT INTO ap_timeline (id, slug, author_uri, author_name, author_handle, content)
+   VALUES (?, 'kind', 'https://elders/u/til', 'Til', '@til@elders', ?)`).run(id, tekst);
+
+// De server leeft over de hele lezing heen: sluiten in een t.after van de
+// EERSTE test zou hem dichttrekken voordat de tweede eraan toe is.
+const crypto = await import('crypto');
+const express = (await import('express')).default;
+const routes = (await import('../src/routes/activitypub.js')).default;
+const bearer = 'test-token-' + 'f'.repeat(24);
+db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
+  .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write');
+const app = express();
+app.use(routes);
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const poort = server.address().port;
+// unref: een luisterende socket houdt de lus open, en dan wacht de testrunner
+// eeuwig op een proces dat nooit vanzelf afloopt.
+server.unref();
+
+const get = async (qs) => {
+  const r = await fetch(`http://127.0.0.1:${poort}/ap/users/kind/inbox${qs}`,
+    { headers: { Authorization: `Bearer ${bearer}` } });
+  return { status: r.status, body: r.status === 304 ? null : await r.json() };
+};
+
+test('het verschil draagt alleen wat er veranderde', async () => {
+  post('https://elders/n/1', '<p>eerste</p>');
+  const vol = await get('');
+  const cursor = vol.body['shaer:cursor'];
+  assert.equal(vol.body.orderedItems.length, 1);
+
+  post('https://elders/n/2', '<p>tweede</p>');
+  const delta = await get(`?changes=1&since=${encodeURIComponent(cursor)}`);
+  assert.equal(delta.body.type, 'OrderedCollectionPage', 'een deel, en dat zegt het ook');
+  assert.ok(delta.body.partOf.endsWith('/inbox'));
+  assert.equal(delta.body.orderedItems.length, 1, 'alleen de nieuwe');
+  assert.equal(delta.body.orderedItems[0].object.id, 'https://elders/n/2');
+  // En de byline staat er gewoon op: dezelfde kaartvorm als de volle lezing.
+  assert.equal(delta.body.orderedItems[0].object.attributedTo.name, 'Til');
+});
+
+test('een verwijdering reist mee als grafsteen', async () => {
+  const heen = await get('');
+  const cursor = heen.body['shaer:cursor'];
+  db.prepare("DELETE FROM ap_timeline WHERE id = ?").run('https://elders/n/1');
+  const delta = await get(`?changes=1&since=${encodeURIComponent(cursor)}`);
+  const weg = delta.body.orderedItems.find((i) => i.type === 'Delete');
+  assert.ok(weg, 'zonder dit zou de post voor altijd in de app blijven staan');
+  assert.equal(weg.object.id, 'https://elders/n/1');
+  assert.equal(weg.object.type, 'Tombstone');
+});
+
+test('zonder changes=1 verandert er NIETS aan de oude lezing', async () => {
+  // De belangrijkste van deze vier: een app in het veld stuurt since al mee en
+  // vervangt haar hele feed door wat er terugkomt.
+  const vol = await get('');
+  const cursor = vol.body['shaer:cursor'];
+  const oud = await get(`?since=${encodeURIComponent(cursor)}`);
+  assert.equal(oud.body.type, 'OrderedCollection', 'nog steeds de hele collectie');
+  assert.equal(oud.body.orderedItems.length, vol.body.orderedItems.length);
+});
+
+test('niets veranderd is een leeg verschil, niet een leeg antwoord', async () => {
+  const vol = await get('');
+  const delta = await get(`?changes=1&since=${encodeURIComponent(vol.body['shaer:cursor'])}`);
+  assert.equal(delta.status, 200);
+  assert.deepEqual(delta.body.orderedItems, []);
+  assert.equal(delta.body['shaer:cursor'], vol.body['shaer:cursor'], 'en de cursor staat stil');
+});
