Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision 9dbb17595baeaf2fe5c9a3de9d7495d5699216fa)
+++ CHANGELOG.de.md	(revision 3778ddb5c7b7cd65a19638dca0cc1627dab0cac6)
@@ -15,4 +15,10 @@
 
 ### Behoben
+- **Private Antworten erscheinen nicht mehr auf der öffentlichen Beitragsseite.**
+  Eine Nur-Follower- oder Direkt-(DM-)Antwort auf deinen Beitrag wurde für alle
+  im öffentlichen Thread gezeigt. Eingehende Antworten speichern jetzt ihre
+  Fediverse-Adressierung; der öffentliche Thread zeigt nur öffentliche und
+  ungelistete Antworten. Private erreichen dich weiterhin über Meldungen, mit
+  dem zugehörigen Beitrag.
 - **Nackte Video/Audio-Embeds laufen nicht mehr über die Spalte hinaus.** Ein
   `.webm` / `.mp4` / `.mp3`-Player passt jetzt in die Inhaltsbreite wie die
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision 9dbb17595baeaf2fe5c9a3de9d7495d5699216fa)
+++ CHANGELOG.md	(revision 3778ddb5c7b7cd65a19638dca0cc1627dab0cac6)
@@ -15,4 +15,9 @@
 
 ### Fixed
+- **Private replies no longer show on the public post page.** A followers-only
+  or direct (DM) reply to your post was rendered in the public thread for
+  everyone. Incoming replies now record their fediverse addressing; the public
+  thread only shows public and unlisted replies. Private ones still reach you in
+  notifications, with the post they belong to.
 - **Bare video/audio embeds no longer overflow their column.** A `.webm` /
   `.mp4` / `.mp3` player now fits the content width like the iframe embeds do;
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision 9dbb17595baeaf2fe5c9a3de9d7495d5699216fa)
+++ CHANGELOG.nl.md	(revision 3778ddb5c7b7cd65a19638dca0cc1627dab0cac6)
@@ -15,4 +15,9 @@
 
 ### Opgelost
+- **Privéreacties staan niet meer op de publieke postpagina.** Een followers-only
+  of directe (DM-)reactie op je post werd voor iedereen in de publieke thread
+  getoond. Inkomende reacties slaan nu hun fediverse-adressering op; de publieke
+  thread toont alleen publieke en unlisted reacties. Privéreacties bereiken je
+  nog steeds via meldingen, mét de post waar ze bij horen.
 - **Kale video/audio-embeds lopen niet meer buiten de kolom.** Een `.webm` /
   `.mp4` / `.mp3`-speler past nu netjes in de kolombreedte, net als de
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 9dbb17595baeaf2fe5c9a3de9d7495d5699216fa)
+++ src/config/database.js	(revision 3778ddb5c7b7cd65a19638dca0cc1627dab0cac6)
@@ -392,4 +392,10 @@
   // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
   ensureColumn('posts', 'content_rendered', 'TEXT');
+
+  // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
+  // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
+  // replies; followers/direct replies surface in notifications (and later Messages) with post
+  // context instead. Existing rows default to 'public' (historically almost all were).
+  ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
 }
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 9dbb17595baeaf2fe5c9a3de9d7495d5699216fa)
+++ src/services/ActivityPubService.js	(revision 3778ddb5c7b7cd65a19638dca0cc1627dab0cac6)
@@ -596,10 +596,25 @@
 // ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
 let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
+// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
+// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
+// followers-collectie zonder Public = followers-only, anders direct (DM). Public
+// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
+export function noteVisibility(o) {
+  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
+  const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
+  const to = arr(o && o.to).map(String);
+  const cc = arr(o && o.cc).map(String);
+  if (to.some(isPub)) return 'public';
+  if (cc.some(isPub)) return 'unlisted';
+  if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
+  return 'direct';
+}
+
 function iStmts() {
   if (!_insI) {
-    _insI = db.prepare('INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
+    _insI = db.prepare('INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, visibility, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
     _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
-    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
+    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like, visibility FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
     _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
     _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, created_at) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
@@ -678,5 +693,10 @@
 export function getInteractions(postId, base, site) {
   const s = iStmts();
-  const rows = s.list.all(postId);
+  // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
+  // public web, so it must NOT render in the public thread. It still reaches the owner
+  // via notifications (post context + reference included there). Legacy rows without a
+  // visibility value are treated as public. Likes/boosts stay counted (count-only).
+  const rows = s.list.all(postId).filter((r) =>
+    r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
   const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
   const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
@@ -1145,5 +1165,5 @@
       const ai = actorInfo(await resolveActor(actorUri), actorUri);
       const html = HtmlSanitizerService.sanitize(o.content || '');
-      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri);
+      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o));
       console.log('[AP] reply', actorUri, '→', tgt.post_id);
       return 202;
@@ -1235,5 +1255,5 @@
     if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
       const ai = actorInfo(await resolveActor(actorUri), actorUri);
-      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
+      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
       console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
     } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
@@ -2137,5 +2157,5 @@
         const html = HtmlSanitizerService.sanitize(child.content || '');
         // The child replies to `note` by construction (it's in note's replies collection).
-        try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri); added++; } catch { /* ignore */ }
+        try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri, noteVisibility(child)); added++; } catch { /* ignore */ }
         nextFrontier.push(child.id); // expand this reply's own replies next depth
       }
@@ -2524,3 +2544,4 @@
   getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
+  noteVisibility,
 };
Index: test/reply-visibility.test.js
===================================================================
--- test/reply-visibility.test.js	(revision 3778ddb5c7b7cd65a19638dca0cc1627dab0cac6)
+++ test/reply-visibility.test.js	(revision 3778ddb5c7b7cd65a19638dca0cc1627dab0cac6)
@@ -0,0 +1,86 @@
+// Privacy: private replies (followers-only / direct) mogen NIET in de publieke
+// thread op de post-pagina verschijnen; ze horen bij notifications/Messages.
+// Dekt noteVisibility() (to/cc parsing) + het getInteractions-filter af.
+//
+// Run: npm test   (= node --test)
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+// Isoleer van de echte DB: ':memory:' MOET gezet zijn vóór de eerste import van
+// config/database.js (die maakt de singleton-connectie op basis van deze env).
+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;
+const AP = await import('../src/services/ActivityPubService.js');
+
+dbMod.initializeDatabase();
+
+const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
+
+// ── noteVisibility: to/cc → visibility ──────────────────────────────────
+test('Public in to = public', () => {
+  assert.equal(AP.noteVisibility({ to: [PUBLIC], cc: ['https://a/followers'] }), 'public');
+});
+test('Public in cc = unlisted', () => {
+  assert.equal(AP.noteVisibility({ to: ['https://a/followers'], cc: [PUBLIC] }), 'unlisted');
+});
+test('as:Public / Public shorthands tellen ook', () => {
+  assert.equal(AP.noteVisibility({ to: ['as:Public'] }), 'public');
+  assert.equal(AP.noteVisibility({ cc: ['Public'] }), 'unlisted');
+});
+test('followers-collectie zonder Public = followers', () => {
+  assert.equal(AP.noteVisibility({ to: ['https://mastodon.social/users/a/followers'] }), 'followers');
+});
+test('alleen personen geadresseerd = direct (DM)', () => {
+  assert.equal(AP.noteVisibility({ to: ['https://klonkt.test/ap/users/me'] }), 'direct');
+});
+test('string ipv array en ontbrekende velden crashen niet', () => {
+  assert.equal(AP.noteVisibility({ to: PUBLIC }), 'public');
+  assert.equal(AP.noteVisibility({}), 'direct');
+  assert.equal(AP.noteVisibility(null), 'direct');
+});
+
+// ── getInteractions: private replies uit de publieke thread ─────────────
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+  .run('s1', 'me', 'Me', 'u1');
+db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, type, created_at, updated_at, published_at)
+  VALUES ('p1','s1','post','u1','Post','<p>x</p>','published','post',datetime('now'),datetime('now'),datetime('now'))`).run();
+
+function addReply(objectUri, visibility, content) {
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, visibility)
+    VALUES ('reply','p1',?,?,?,?,?)`).run(objectUri, 'https://remote.test/users/r', 'R', content, visibility);
+}
+addReply('https://remote.test/n/1', 'public', '<p>publieke reply</p>');
+addReply('https://remote.test/n/2', 'unlisted', '<p>unlisted reply</p>');
+addReply('https://remote.test/n/3', 'followers', '<p>followers-only reply</p>');
+addReply('https://remote.test/n/4', 'direct', '<p>DM reply</p>');
+// legacy rij zonder visibility (pre-migratie) → telt als public
+db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, visibility)
+  VALUES ('reply','p1','https://remote.test/n/5','https://remote.test/users/r','R','<p>legacy</p>',NULL)`).run();
+// een followers-only like blijft gewoon meetellen (count-only, geen content)
+db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, visibility)
+  VALUES ('like','p1','','https://remote.test/users/r','followers')`).run();
+
+test('publieke thread bevat public/unlisted/legacy, maar geen followers/direct', () => {
+  const view = AP.getInteractions('p1', 'https://klonkt.test', { slug: 'me', title: 'Me' });
+  const html = JSON.stringify(view);
+  assert.ok(html.includes('publieke reply'));
+  assert.ok(html.includes('unlisted reply'));
+  assert.ok(html.includes('legacy'));
+  assert.ok(!html.includes('followers-only reply'), 'followers-only reply lekte naar de publieke thread');
+  assert.ok(!html.includes('DM reply'), 'DM lekte naar de publieke thread');
+  assert.equal(view.likeCount, 1, 'like hoort te blijven meetellen');
+});
+
+test('private reply blijft zichtbaar voor de eigenaar in notifications (met post-context)', () => {
+  const notes = AP.getNotifications('me', 50);
+  const dm = notes.find((n) => n.content && n.content.includes('DM reply'));
+  assert.ok(dm, 'DM-reply ontbreekt in notifications');
+  assert.equal(dm.post_slug, 'post');
+  assert.equal(dm.post_title, 'Post');
+});
