Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 86ec01bdc04a46850d02652f3b60e762d01520da)
+++ src/routes/activitypub.js	(revision 09fc5fb298f25f61bef8fa385fdf73f1c2c7781f)
@@ -283,4 +283,177 @@
 });
 
+// ── 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 leesPoorten(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 eigenHandle(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 berichtItem(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 verzondenItem(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 (leesPoorten) en
+// dezelfde kaartvorm (berichtItem/verzondenItem). Messages dicht sluit ook
+// hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
+function gesprekItems(req, auth, refs) {
+  const base = baseUrl(req);
+  const P = leesPoorten(auth.site);
+  const me = AP.actorId(base, auth.site.slug);
+  const ctx = { base, me, myHandle: eigenHandle(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 binnen = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.richting === 'in').map((r) => r.ref))
+    .map((m) => [m.object_uri, m]));
+  const uit = [];
+  for (const r of refs) {
+    if (r.richting === 'in') {
+      const m = binnen.get(r.ref);
+      if (!m) continue;
+      if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
+      uit.push(berichtItem(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) uit.push(verzondenItem(n, { me, mine }));
+    }
+  }
+  return uit;
+}
+
+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 koppen = AP.conversationHeads(auth.site.slug);
+  const items = gesprekItems(req, auth, koppen);
+  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 uit = AP.conversationHistory(auth.site.slug, other, {
+    before: req.query.before ? String(req.query.before) : null,
+    limit: req.query.limit,
+  });
+  const items = gesprekItems(req, auth, uit.rijen);
+  // 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 maat = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
+  const zelf = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
+  AP.sendAP(res, {
+    '@context': AP.AP_CONTEXT,
+    id: req.query.before ? `${zelf}${maat}&before=${encodeURIComponent(String(req.query.before))}` : `${zelf}${maat}`,
+    type: 'OrderedCollectionPage',
+    partOf: zelf,
+    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: uit.meer && uit.oudste ? `${zelf}${maat}&before=${encodeURIComponent(uit.oudste)}` : undefined,
+  }, 'private, no-store');
+});
+
 // ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
 // Hold the request until something push-worthy lands for this account, then
@@ -414,33 +587,15 @@
   // here, at serialisation: a blocked embed is never sent, because an embed the
   // client merely hides has still been delivered to the device.
-  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
-  const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
-  // The heavier sibling (5.6): may a third party's PLAYER run inside the app,
-  // and may a link hand the child over to a browser? Both are the guardians'
-  // call, both default to off for a ward, and both need the preview gate open
-  // first: you cannot play, or follow, what you may not see. Served here so
-  // the app knows what it may offer instead of guessing.
-  const playbackAllowed = embedsAllowed
-    && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
-  // De rest van de familie (shaer-ahy.1, 8-8): zelfde regel, zelfde plek --
-  // de poort zit bij de serialisatie, wat dicht is wordt nooit geleverd.
-  const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], isWard);
-  const imagesAllowed = gate('gate_images');
-  const musicAllowed = gate('gate_music');
-  const quotesAllowed = gate('gate_quote_cards');
-  const emojiAllowed = gate('gate_custom_emoji');
-  const messagesAllowed = gate('gate_messages');
-  const composeAllowed = gate('gate_compose');
-  const repliesAllowed = gate('gate_replies');
-  const threadsAllowed = gate('external_threads');
-  // Zelf iemand volgen (shaer-p729). Anders dan de rest betekent dicht hier niet
-  // "kan niet" maar "moet eerst gevraagd worden": het verzoek gaat naar de
-  // guardians. Juist dat hoort de app VOORAF te weten, zodat de knop kan zeggen
-  // dat je het gaat vragen in plaats van te doen alsof het al gelukt is en het
-  // kind het pas bij het antwoord te laten ontdekken.
-  const 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.
-  const gateAuthor = (a) => (a && !emojiAllowed ? { ...a, emojis: undefined } : a);
+  // De poorten van deze lezer (leesPoorten): een plek waar ze berekend worden,
+  // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
+  // kopie krijgen die kan gaan afwijken.
+  const P = leesPoorten(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) ────────────────
   //
@@ -521,38 +676,8 @@
   // 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 berichtCtx = { 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) => ({
-    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) ? 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 }, ...(emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
-      attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: imagesAllowed, audio: 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: quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
-      preview: embedsAllowed ? AP.previewObject(m.embed_json, { playback: playbackAllowed }) : undefined,
-    },
-  }));
+    .map((m) => berichtItem(m, berichtCtx));
   // Inbound REPLIES on your own posts: stored as interactions (the web's
   // comment machinery), never as mentions, so this read missed them and a
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 86ec01bdc04a46850d02652f3b60e762d01520da)
+++ src/services/ActivityPubService.js	(revision 09fc5fb298f25f61bef8fa385fdf73f1c2c7781f)
@@ -4191,10 +4191,115 @@
 }
 
+// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────────
+//
+// De oude lezing gaf de nieuwste 60 berichten over ALLE gesprekken samen. Dat
+// knipt geschiedenis weg zonder dat iemand het merkt, en het is bij DM's veel
+// erger dan bij posts: dat zijn er meer en het zijn kortere berichten, dus een
+// druk gesprek kan de 60 in zijn eentje opeten en de rest uit de lezing duwen.
+// Viel het laatste bericht van iemand erbuiten, dan verdween die persoon
+// helemaal uit Messages -- de avatarhemel plaatst mensen op de leeftijd van hun
+// laatste bericht, dus geen bericht is geen gezicht.
+//
+// Vandaar twee lezingen. Deze geeft EEN rij per tegenpartij, hoe druk iemand
+// ook is, en conversationHistory hieronder geeft het gesprek zelf met een
+// cursor. Wat de client van de hemel nodig heeft -- wie, wanneer, en waarmee --
+// zit in die ene nieuwste note.
+//
+// Een gesprek is hier hetzelfde als in de app: incoming zijn de ap_mentions
+// (die tabel IS de aan ons gerichte post), uitgaand zijn de eigen notes met
+// visibility 'direct'. Een publiek antwoord is geen gesprek en hoort niet als
+// gezicht in de hemel.
+const GESPREK_UNIE = `
+  SELECT m.actor_uri AS other, COALESCE(m.published, m.created_at) AS stamp,
+         'in' AS richting, m.object_uri AS ref
+    FROM ap_mentions m
+   WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''
+  UNION ALL
+  SELECT j.value AS other, o.created_at AS stamp,
+         'uit' AS richting, o.id AS ref
+    FROM ap_outbox o
+    JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
+   WHERE o.site_slug = @slug AND o.visibility = 'direct'
+     AND j.value IS NOT NULL AND j.value <> ''`;
+
+/**
+ * Een rij per tegenpartij: zijn nieuwste bericht, nieuwste gesprek eerst.
+ *
+ * Compleet van vorm -- het aantal rijen is het aantal mensen, niet het aantal
+ * berichten -- dus de hemel kan niemand meer kwijtraken doordat een ander druk
+ * was. Zonder limiet, en dat mag: dit schaalt met je kring.
+ */
+export function conversationHeads(slug) {
+  try {
+    return db.prepare(`
+      SELECT other, stamp, richting, ref FROM (
+        SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
+          FROM (${GESPREK_UNIE})
+      ) WHERE rn = 1
+      ORDER BY stamp DESC, ref DESC`).all({ slug });
+  } catch { return []; }
+}
+
+/**
+ * Een gesprek, nieuwste eerst, met een cursor.
+ *
+ * BEIDE KANTEN ONDER EEN LIMIET. In de oude lezing werden jouw kant
+ * (getSentNotes) en hun kant apart afgekapt, waardoor een gesprek eenzijdig
+ * kon lijken -- alsof iemand nooit geantwoord had. Hier is de limiet er een
+ * voor het gesprek als geheel.
+ *
+ * `before` is de cursor van het OUDSTE bericht dat je al hebt; je krijgt wat
+ * daarvoor ligt. Er komt er een extra op om te weten of er nog meer is: de
+ * client hoort dat te weten zonder te moeten gokken, en zonder dat weten kan
+ * 'load more' niet eerlijk verschijnen.
+ *
+ * DE CURSOR IS SAMENGESTELD -- '<stempel>|<ref>' -- en niet alleen de stempel.
+ * Twee berichten in dezelfde seconde is bij DM's geen randgeval maar een
+ * gesprek, en met 'stamp < before' zou alles wat die grensseconde deelt stil
+ * overgeslagen worden. Je zou het niet merken: de pagina komt gewoon, er
+ * ontbreekt alleen iets in het midden.
+ */
+const cursorVan = (r) => (r ? `${r.stamp}|${r.ref}` : null);
+
+export function conversationHistory(slug, other, { before = null, limit = 60 } = {}) {
+  try {
+    const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
+    const knip = String(before || '').indexOf('|');
+    const bStamp = before && knip > 0 ? String(before).slice(0, knip) : null;
+    const bRef = before && knip > 0 ? String(before).slice(knip + 1) : null;
+    const rijen = db.prepare(`
+      SELECT other, stamp, richting, ref FROM (${GESPREK_UNIE})
+       WHERE other = @other
+         AND (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
+       ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, other, bStamp, bRef, n: n + 1 });
+    const meer = rijen.length > n;
+    const uit = meer ? rijen.slice(0, n) : rijen;
+    return { rijen: uit, meer, oudste: cursorVan(uit[uit.length - 1]) };
+  } catch { return { rijen: [], meer: false, oudste: null }; }
+}
+
+// De kolommen die een bericht tot kaart maken. Een constante, want de
+// gesprekslezing haalt dezelfde rijen op: twee lijsten die uiteenlopen leveren
+// een kaart die op de ene plek een plaatje heeft en op de andere niet.
+const BERICHT_KOLOMMEN = `
+      m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
+      m.content, m.published, m.created_at, m.wave, m.help_request,
+      m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;
+
+/** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */
+export function messageRowsByUri(slug, uris) {
+  const lijst = (uris || []).filter((u) => typeof u === 'string' && u);
+  if (!lijst.length) return [];
+  try {
+    const gaten = lijst.map(() => '?').join(',');
+    return db.prepare(`SELECT ${BERICHT_KOLOMMEN} FROM ap_mentions m
+                        WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);
+  } catch { return []; }
+}
+
 export function getDirectMessages(slug, limit) {
   try {
     return db.prepare(`
-      SELECT m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
-             m.content, m.published, m.created_at, m.wave, m.help_request,
-             m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json
+      SELECT ${BERICHT_KOLOMMEN}
       FROM ap_mentions m
       WHERE m.slug = ?
@@ -6286,5 +6391,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, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  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,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
