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,
Index: test/conversations.test.js
===================================================================
--- test/conversations.test.js	(revision 09fc5fb298f25f61bef8fa385fdf73f1c2c7781f)
+++ test/conversations.test.js	(revision 09fc5fb298f25f61bef8fa385fdf73f1c2c7781f)
@@ -0,0 +1,137 @@
+// Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso).
+//
+// De oude lezing gaf 60 berichten over ALLE gesprekken samen. Deze toetsen
+// leggen vast wat daaraan mis was en niet meer mag terugkomen: dat een druk
+// gesprek de rest wegdrukt, dat iemand daardoor uit de hemel verdwijnt, en dat
+// jouw kant en hun kant apart afgekapt werden.
+//
+// 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) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
+
+const TANTE = 'https://elders/u/tante';
+const OMA = 'https://elders/u/oma';
+const VREEMDE = 'https://elders/u/vreemde';
+
+const binnen = (uri, van, stamp) =>
+  db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, content, published)
+              VALUES ('kind', ?, ?, 'iemand', '<p>hoi</p>', ?)`).run(uri, van, stamp);
+const verstuurd = (id, naar, stamp, visibility = 'direct') =>
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, to_actor, to_actors, content, visibility, created_at)
+              VALUES (?, 'kind', 'p1', ?, ?, '<p>terug</p>', ?, ?)`)
+    .run(id, naar, JSON.stringify([naar]), visibility, stamp);
+
+// Een DRUK gesprek met oma: genoeg om in de oude lezing de rest weg te drukken.
+for (let i = 0; i < 80; i++) binnen(`https://elders/n/oma-${i}`, OMA, `2026-08-09T10:${String(i % 60).padStart(2, '0')}:00Z`);
+// En een stil gesprek met tante, ouder dan al die drukte.
+binnen('https://elders/n/tante-1', TANTE, '2026-08-01T09:00:00Z');
+verstuurd('mijn-1', TANTE, '2026-08-01T09:30:00Z');
+// Een PUBLIEK antwoord aan een vreemde is geen gesprek.
+verstuurd('mijn-publiek', VREEMDE, '2026-08-09T12:00:00Z', 'public');
+
+test('de oude lezing verliest tante achter een druk gesprek -- dat is de bug', () => {
+  const oud = AP.getDirectMessages('kind', 60);
+  assert.equal(oud.length, 60);
+  assert.ok(!oud.some((m) => m.actor_uri === TANTE), 'tante valt buiten de 60');
+});
+
+test('een rij per tegenpartij, hoe druk de drukste ook is', () => {
+  const koppen = AP.conversationHeads('kind');
+  assert.deepEqual(koppen.map((k) => k.other), [OMA, TANTE], 'nieuwste gesprek eerst, allebei aanwezig');
+});
+
+test('een publiek antwoord is geen gesprek en wordt geen gezicht', () => {
+  assert.ok(!AP.conversationHeads('kind').some((k) => k.other === VREEMDE));
+});
+
+test('het gesprek draagt beide kanten onder EEN limiet', () => {
+  const g = AP.conversationHistory('kind', TANTE, { limit: 60 });
+  assert.equal(g.rijen.length, 2);
+  assert.deepEqual(g.rijen.map((r) => r.richting), ['uit', 'in'], 'nieuwste eerst, mijn antwoord bovenaan');
+  assert.equal(g.meer, false);
+});
+
+test('load more: er is een cursor en een eerlijk antwoord op "is er meer"', () => {
+  // De 80 berichten van oma delen stempels (i % 60), en dat is met opzet:
+  // twee berichten in dezelfde seconde is bij DM's een gesprek, geen randgeval.
+  const eerste = AP.conversationHistory('kind', OMA, { limit: 30 });
+  assert.equal(eerste.rijen.length, 30);
+  assert.equal(eerste.meer, true, 'er is meer, en dat mag de client weten');
+
+  const tweede = AP.conversationHistory('kind', OMA, { limit: 30, before: eerste.oudste });
+  assert.equal(tweede.rijen.length, 30);
+  // Geen overlap en geen gat: de tweede pagina begint waar de eerste ophield.
+  const ids = new Set(eerste.rijen.map((r) => r.ref));
+  assert.ok(tweede.rijen.every((r) => !ids.has(r.ref)), 'geen dubbele');
+
+  const derde = AP.conversationHistory('kind', OMA, { limit: 30, before: tweede.oudste });
+  assert.equal(derde.rijen.length, 20, 'de staart');
+  assert.equal(derde.meer, false, 'en dan is het op');
+  // Alles bij elkaar: precies 80, geen dubbele en niets overgeslagen. Met een
+  // cursor op alleen de stempel zou de grensseconde hier stil wegvallen.
+  const alles = new Set([...eerste.rijen, ...tweede.rijen, ...derde.rijen].map((r) => r.ref));
+  assert.equal(alles.size, 80, 'elke berichtje precies een keer');
+});
+
+// ── Door de routes heen, want daar wordt de vorm beslist ────────────
+test('de gesprekslezingen: een rij per persoon, en een gesprek met next', async (t) => {
+  const crypto = await import('crypto');
+  const express = (await import('express')).default;
+  const routes = (await import('../src/routes/activitypub.js')).default;
+
+  const bearer = 'test-token-' + 'e'.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);
+  t.after(() => server.close());
+  await new Promise((r) => server.once('listening', r));
+  const haal = async (pad) => {
+    const r = await fetch(`http://127.0.0.1:${server.address().port}${pad}`,
+      { headers: { Authorization: `Bearer ${bearer}` } });
+    return { status: r.status, body: await r.json() };
+  };
+  const slug = '/ap/users/kind';
+
+  const g = await haal(`${slug}/conversations`);
+  assert.equal(g.status, 200);
+  assert.equal(g.body.totalItems, 2, 'oma en tante, en niet de publieke vreemde');
+  // De byline zit in de ingesloten actor, net als overal sinds shaer-nmw.
+  assert.ok(g.body.orderedItems.every((i) => i.object.attributedTo));
+
+  const eerste = await haal(`${slug}/messages?with=${encodeURIComponent(OMA)}&limit=30`);
+  assert.equal(eerste.body.orderedItems.length, 30);
+  assert.ok(eerste.body.next, 'er is meer, en de standaardvorm zegt het');
+  assert.ok(eerste.body.next.includes('limit=30'), 'de paginagrootte reist mee');
+
+  // De next-link volgen doet wat hij belooft: geen dubbele, en uiteindelijk op.
+  const pad2 = eerste.body.next.replace(/^https?:\/\/[^/]+/, '');
+  const tweede = await haal(pad2);
+  const eersteIds = new Set(eerste.body.orderedItems.map((i) => i.object.id));
+  assert.ok(tweede.body.orderedItems.every((i) => !eersteIds.has(i.object.id)), 'geen overlap');
+  const derde = await haal(tweede.body.next.replace(/^https?:\/\/[^/]+/, ''));
+  assert.equal(derde.body.orderedItems.length, 20);
+  assert.equal(derde.body.next, undefined, 'op is op, en dat staat er ook');
+
+  // Een gesprek draagt beide kanten; het eigen antwoord aan tante hoort erbij.
+  const tante = await haal(`${slug}/messages?with=${encodeURIComponent(TANTE)}`);
+  assert.equal(tante.body.orderedItems.length, 2);
+  assert.ok(tante.body.orderedItems.some((i) => i.actor === 'https://klonkt.test/ap/users/kind'), 'mijn eigen kant zit erin');
+
+  const stuk = await haal(`${slug}/messages?with=nonsens`);
+  assert.equal(stuk.status, 400, 'geen actor-uri, geen gesprek');
+});
