Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
+++ src/services/ActivityPubService.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
@@ -2496,81 +2496,20 @@
 
 /**
- * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
- *
- * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
- * kringfilter (de dichte stand van shaer:externalThreads) woont in
- * filterThreadToCircle en de beeld/muziek/emoji-poorten in
- * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
- * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
- * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
- * een blokkade is onzichtbaar, ook als getal.
+ * Eén vreemde note in de vorm die de C2S-lezer krijgt: geschoonde HTML, de
+ * byline ingesloten in attributedTo, alleen emoji-tags. Gedeeld door de thread
+ * (antwoorden) en getObject (één note), zodat die twee niet uit elkaar lopen.
+ *
+ * `parentFallback` is voor de thread: een antwoord in de replies-collectie van
+ * X dat zelf geen inReplyTo noemt, hangt aantoonbaar onder X. Voor één losse
+ * note is er zo'n bewijs niet, en dan blijft inReplyTo leeg.
  */
-export async function getThread(slug, objectUri) {
-  const key = `${slug}|${objectUri}`;
-  const hit = threadViewCache.get(key);
-  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
-
-  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
-  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
-  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
-  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
-  let sourceStatus = 0;
-  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
-  const note = await get(objectUri);
-  const repliesRef = note && note.replies;
-  let coll = null;
-  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
-  else if (repliesRef && typeof repliesRef === 'object') {
-    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
-      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
-  }
-  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
-  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
-  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
-  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
-  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
-  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
-  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
-  let items = [];
-  let node = coll;
-  if (node && node.first && !collectionItems(node).length) {
-    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
-  }
-  let pages = 0;
-  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
-    items.push(...collectionItems(node));
-    if (!node.next) break;
-    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
-  }
-  items = items.slice(0, THREAD_VIEW_LIMIT);
-
-  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
-  // afwachten is een halve minuut kijken naar een spinner.
-  const objs = await Promise.all(items.map(async (it) => {
-    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
-    return (o && o.id && o.attributedTo) ? o : null;
-  }));
-
-  const kept = [];
-  for (const o of objs) {
-    if (!o) continue;
-    const actorUri = actorUriOf(o.attributedTo);
-    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
-    kept.push({ o, actorUri });
-  }
-
-  // Bylines: één fetch per unieke auteur, niet één per antwoord.
-  const authors = new Map();
-  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
-    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
-  }));
-
-  const notes = kept.map(({ o, actorUri }) => ({
+function readerNote(o, actorUri, authorDoc, parentFallback = null) {
+  return {
     id: o.id,
     type: 'Note',
     // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar
     // elke AP-lezer hem zoekt, en niet in een eigen property ernaast.
-    attributedTo: actorObject(actorUri, actorInfo(authors.get(actorUri), actorUri)),
-    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
+    attributedTo: actorObject(actorUri, actorInfo(authorDoc, actorUri)),
+    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || parentFallback || undefined,
     content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
     url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
@@ -2597,5 +2536,80 @@
       return out.length ? out : undefined;
     })(),
-  })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
+  };
+}
+
+/**
+ * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
+ *
+ * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
+ * kringfilter (de dichte stand van shaer:externalThreads) woont in
+ * filterThreadToCircle en de beeld/muziek/emoji-poorten in
+ * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
+ * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
+ * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
+ * een blokkade is onzichtbaar, ook als getal.
+ */
+export async function getThread(slug, objectUri) {
+  const key = `${slug}|${objectUri}`;
+  const hit = threadViewCache.get(key);
+  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
+
+  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
+  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
+  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
+  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
+  let sourceStatus = 0;
+  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
+  const note = await get(objectUri);
+  const repliesRef = note && note.replies;
+  let coll = null;
+  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
+  else if (repliesRef && typeof repliesRef === 'object') {
+    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
+      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
+  }
+  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
+  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
+  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
+  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
+  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
+  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
+  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
+  let items = [];
+  let node = coll;
+  if (node && node.first && !collectionItems(node).length) {
+    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
+  }
+  let pages = 0;
+  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
+    items.push(...collectionItems(node));
+    if (!node.next) break;
+    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
+  }
+  items = items.slice(0, THREAD_VIEW_LIMIT);
+
+  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
+  // afwachten is een halve minuut kijken naar een spinner.
+  const objs = await Promise.all(items.map(async (it) => {
+    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
+    return (o && o.id && o.attributedTo) ? o : null;
+  }));
+
+  const kept = [];
+  for (const o of objs) {
+    if (!o) continue;
+    const actorUri = actorUriOf(o.attributedTo);
+    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
+    kept.push({ o, actorUri });
+  }
+
+  // Bylines: één fetch per unieke auteur, niet één per antwoord.
+  const authors = new Map();
+  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
+    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
+  }));
+
+  const notes = kept.map(({ o, actorUri }) => readerNote(o, actorUri, authors.get(actorUri), objectUri))
+    .sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
 
   const out = { notes, found: !!note, sourceStatus };
@@ -2606,4 +2620,27 @@
   }
   return out;
+}
+
+/**
+ * Eén note, ondertekend opgehaald, in dezelfde vorm als een thread-antwoord.
+ *
+ * De thread hierboven geeft alleen wat ONDER een post hangt. Wie een gesprek
+ * naar boven wil volgen -- een antwoord kwam binnen, de ouder nooit -- kan die
+ * ouder zonder de sleutel die hier woont niet halen bij een server in secure
+ * mode. Niets wordt bewaard, net als bij de thread.
+ *
+ * inReplyTo staat er alleen als de bron hem gaf: een verzonnen ouder is erger
+ * dan geen. Een geblokkeerde auteur maakt de note onvindbaar, met dezelfde
+ * status als een bron die nee zegt -- een blokkade hoort onzichtbaar te zijn.
+ */
+export async function getObject(slug, objectUri) {
+  let sourceStatus = 0;
+  const o = localNoteObject(objectUri, slug)
+    || await signedGetJson(slug, objectUri, (st) => { sourceStatus = st; });
+  if (!o || !o.id || !o.attributedTo) return { note: null, found: false, sourceStatus };
+  const actorUri = actorUriOf(o.attributedTo);
+  if (!actorUri || isBlockedAny(actorUri)) return { note: null, found: false, sourceStatus: 404 };
+  const author = localActorObject(actorUri) || await signedGetJson(slug, actorUri).catch(() => null);
+  return { note: readerNote(o, actorUri, author), found: true, sourceStatus };
 }
 
@@ -4346,5 +4383,5 @@
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
-  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
+  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, getObject, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
   noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
