Index: test/messages-conversations.test.js
===================================================================
--- test/messages-conversations.test.js	(revision 7875a80e55bfc41b72ae6716ca804d1893175379)
+++ test/messages-conversations.test.js	(revision 7875a80e55bfc41b72ae6716ca804d1893175379)
@@ -0,0 +1,113 @@
+// Berichten als gesprekken: antwoorden, mentions en je eigen verzonden
+// berichten vouwen samen tot draden, de rest van de stroom blijft ongemoeid.
+// Dit dekt de groepeerlogica zelf (zuiver, geen DB, geen netwerk); het
+// samenstellen van de stroom zit in getMessages en leunt op de database.
+//
+// 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');
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+const { threadKey, groupConversations } = AP;
+
+// Aflopend gesorteerd, zoals getMessages ze aanlevert.
+const ts = (n) => new Date(Date.UTC(2026, 0, 1, 12, n)).toISOString();
+
+test('threadKey: een post wint van een persoon', () => {
+  assert.equal(threadKey({ type: 'reply', post_slug: 'hallo', handle: '@anna@a.test' }), 'post:hallo');
+  assert.equal(threadKey({ type: 'sent', post_slug: 'hallo', to_handle: '@anna@a.test' }), 'post:hallo');
+  // Zonder post loopt de draad per tegenpartij, hoofdletter- en @-ongevoelig.
+  assert.equal(threadKey({ type: 'mention', handle: '@Anna@A.test' }), 'actor:anna@a.test');
+  assert.equal(threadKey({ type: 'sent', to_handle: 'Anna@a.test' }), 'actor:anna@a.test');
+});
+
+test('threadKey: alles wat geen gesprek is krijgt geen draad', () => {
+  for (const type of ['like', 'announce', 'follow', 'report', 'poll_done']) {
+    assert.equal(threadKey({ type, post_slug: 'hallo' }), null, `${type} hoort geen draad te krijgen`);
+  }
+  assert.equal(threadKey(null), null);
+  assert.equal(threadKey({ type: 'mention' }), null, 'een mention zonder afzender heeft geen sleutel');
+});
+
+test('ontvangen en verzonden op dezelfde post komen in EEN draad', () => {
+  const out = groupConversations([
+    { type: 'sent', post_slug: 'hallo', content: 'graag gedaan', created_at: ts(3) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', name: 'Anna', content: 'dank!', created_at: ts(2) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@bo@b.test', name: 'Bo', content: 'mooi', created_at: ts(1) },
+  ]);
+  assert.equal(out.length, 1, 'drie berichten, één draad');
+  const t = out[0];
+  assert.equal(t.type, 'thread');
+  assert.equal(t.count, 3);
+  // De context hoort bij de draad: dit gesprek gaat over een post.
+  assert.deepEqual(t.post, { slug: 'hallo', title: 'Hallo fediverse' });
+  // Een gesprek leest naar beneden: oud → nieuw.
+  assert.deepEqual(t.messages.map((m) => m.content), ['mooi', 'dank!', 'graag gedaan']);
+  // De draad staat op de tijd van zijn NIEUWSTE bericht, zodat een levend
+  // gesprek bovenaan komt.
+  assert.equal(t.created_at, ts(3));
+  // Jij zit er niet bij als deelnemer, de anderen wel.
+  assert.deepEqual(t.people.map((p) => p.name), ['Bo', 'Anna']);
+  assert.equal(t.mine, true);
+});
+
+test('een mention zonder post loopt per persoon, niet per post', () => {
+  const out = groupConversations([
+    { type: 'sent', to_handle: '@cas@c.test', content: 'hoi terug', created_at: ts(2) },
+    { type: 'mention', handle: '@cas@c.test', name: 'Cas', content: 'hoi!', created_at: ts(1) },
+  ]);
+  assert.equal(out.length, 1);
+  assert.equal(out[0].key, 'actor:cas@c.test');
+  assert.equal(out[0].post, null, 'zonder post is er geen context-link');
+  assert.deepEqual(out[0].messages.map((m) => m.content), ['hoi!', 'hoi terug']);
+});
+
+test('twee posts blijven twee draden, op volgorde van hun nieuwste bericht', () => {
+  const out = groupConversations([
+    { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@anna@a.test', created_at: ts(4) },
+    { type: 'reply', post_slug: 'een', post_title: 'Een', handle: '@bo@b.test', created_at: ts(3) },
+    { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@bo@b.test', created_at: ts(2) },
+  ]);
+  assert.deepEqual(out.map((t) => t.key), ['post:twee', 'post:een']);
+  assert.equal(out[0].count, 2);
+  assert.equal(out[1].count, 1);
+});
+
+test('likes, boosts en follows stromen ongemoeid tussen de draden door', () => {
+  const out = groupConversations([
+    { type: 'like', post_slug: 'hallo', handle: '@anna@a.test', created_at: ts(4) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo', handle: '@bo@b.test', created_at: ts(3) },
+    { type: 'follow', handle: '@cas@c.test', created_at: ts(2) },
+    { type: 'report', handle: '@dee@d.test', created_at: ts(1) },
+  ]);
+  assert.deepEqual(out.map((i) => i.type), ['like', 'thread', 'follow', 'report']);
+  // Een like op dezelfde post trekt die post NIET de draad in: het is geen
+  // gesprek, en Activiteit blijft zijn eigen chip houden.
+  assert.equal(out[0].post_slug, 'hallo');
+});
+
+test('de titel komt van welk bericht in de draad hem ook maar kent', () => {
+  // Een verzonden antwoord kent alleen de slug; een ontvangen antwoord de titel.
+  const out = groupConversations([
+    { type: 'sent', post_slug: 'hallo', created_at: ts(2) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', created_at: ts(1) },
+  ]);
+  assert.deepEqual(out[0].post, { slug: 'hallo', title: 'Hallo fediverse' });
+});
+
+test('lege en rommelige invoer levert geen kapotte draden op', () => {
+  assert.deepEqual(groupConversations([]), []);
+  assert.deepEqual(groupConversations(null), []);
+  // Een gesprekssoort zonder sleutel blijft een losse regel in plaats van te
+  // verdwijnen: nooit een bericht kwijtraken in de groepering.
+  const out = groupConversations([{ type: 'sent', content: 'aan niemand', created_at: ts(1) }]);
+  assert.equal(out.length, 1);
+  assert.equal(out[0].type, 'sent');
+});
Index: test/messages.test.js
===================================================================
--- test/messages.test.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
+++ test/messages.test.js	(revision 7875a80e55bfc41b72ae6716ca804d1893175379)
@@ -2,4 +2,8 @@
 // in één stroom, groepeert opeenvolgende likes/boosts op dezelfde post, en geeft
 // visibility door (voor de privé-badge). Besluit Robin+Bart 2026-07-16.
+//
+// Sinds Berichten gesprekken toont vouwen antwoorden, mentions en je eigen
+// verzonden berichten samen tot draden (zie messages-conversations.test.js voor
+// de groepeerlogica zelf); likes, boosts en follows blijven losse regels.
 //
 // Run: npm test   (= node --test)
@@ -42,10 +46,16 @@
 const msgs = AP.getMessages('me', 50);
 
-test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => {
-  const sent = msgs.find((m) => m.type === 'sent');
+test('eigen outbound reply en het ontvangen antwoord staan in EEN draad', () => {
+  const thread = msgs.find((m) => m.type === 'thread');
+  assert.ok(thread, 'draad ontbreekt');
+  assert.equal(msgs[0], thread, 'de draad met het nieuwste bericht hoort bovenaan');
+  const sent = thread.messages.find((m) => m.type === 'sent');
   assert.ok(sent, 'sent-item ontbreekt');
   assert.equal(sent.outboxId, 'out1');
   assert.equal(sent.to_handle, '@dana@r.test');
-  assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan');
+  // Hier gaat het om: Dana's antwoord en het jouwe zaten in aparte chips
+  // (Gesprekken en Verzonden) en staan nu op volgorde in dezelfde draad.
+  assert.deepEqual(thread.messages.map((m) => m.type), ['reply', 'sent']);
+  assert.equal(thread.post.slug, 'mijn-post');
 });
 
@@ -58,8 +68,11 @@
 
 test('privé-reply draagt visibility voor de badge en heeft post-context', () => {
-  const reply = msgs.find((m) => m.type === 'reply');
+  const thread = msgs.find((m) => m.type === 'thread');
+  const reply = thread.messages.find((m) => m.type === 'reply');
   assert.equal(reply.visibility, 'direct');
   assert.equal(reply.post_slug, 'mijn-post');
   assert.equal(reply.post_title, 'Mijn post');
+  // De context hangt ook aan de draad zelf: die voedt de link in de kop.
+  assert.deepEqual(thread.post, { slug: 'mijn-post', title: 'Mijn post' });
 });
 
