Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/ActivityPubService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -627,4 +627,19 @@
 // outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
 // one grouped item (actors list + count) so activity doesn't drown out conversations.
+/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
+ *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
+ *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
+function outboxMediaJson(attachments) {
+  if (!attachments) return null;
+  try {
+    const list = JSON.parse(attachments);
+    if (!Array.isArray(list) || !list.length) return null;
+    const media = list
+      .filter((a) => a && a.url)
+      .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
+    return media.length ? JSON.stringify(media) : null;
+  } catch { return null; }
+}
+
 export function getMessages(slug, limit, offset) {
   const off = Math.max(0, offset || 0);
@@ -638,9 +653,30 @@
     for (const m of listOutbox(slug).slice(0, need)) {
       items.push({
-        type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
-        content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
+        type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
+        in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
+        editable: m.editable, language: m.language, created_at: m.created_at,
+        // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
+        // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
+        // media uit media_json met een `type`, terwijl ap_outbox ze als
+        // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
+        // een foto die JIJ meestuurde als kale tekst binnen.
+        media_json: outboxMediaJson(m.attachments),
       });
     }
   } catch { /* ignore */ }
+  // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
+  // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
+  // zei alleen een slug, dus vullen we ze in één query aan.
+  try {
+    const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
+    if (missing.length) {
+      const rows = db.prepare(
+        `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
+           AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
+      ).all(...missing, slug);
+      const byslug = new Map(rows.map((r) => [r.slug, r.title]));
+      for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
+    }
+  } catch { /* zonder titel valt de draad terug op de slug */ }
   items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
   const out = [];
@@ -656,5 +692,112 @@
     out.push(it);
   }
-  return out.slice(off, off + lim);
+  // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
+  // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
+  // telt een draad als één item voor de paginering, wat klopt: je scrolt door
+  // gesprekken, niet door losse zinnen.
+  return groupConversations(out).slice(off, off + lim);
+}
+
+// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
+// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
+// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
+// moest dus tussen chips heen en weer, terwijl het één draad is.
+const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
+
+/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
+ *
+ *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
+ *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
+ *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
+ *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
+ *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
+ *     loopt per tegenpartij.
+ *
+ *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
+ *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
+ *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
+ *  door.
+ */
+export function threadKey(it) {
+  if (!it || !CONV_TYPES.has(it.type)) return null;
+  if (it.post_slug) return `post:${it.post_slug}`;
+  let who = it.handle || it.to_handle || '';
+  // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
+  // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
+  // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
+  // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
+  if (!who && it.to_actors) {
+    try {
+      const first = JSON.parse(it.to_actors)[0];
+      if (first) who = deriveHandle(first);
+    } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
+  }
+  const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
+  return norm ? `actor:${norm}` : null;
+}
+
+/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
+ *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
+ *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
+ *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
+ *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
+ */
+export function groupConversations(items) {
+  const threads = new Map();
+  const out = [];
+  for (const it of items || []) {
+    const key = threadKey(it);
+    if (!key) { out.push(it); continue; }
+    let t = threads.get(key);
+    if (!t) {
+      // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
+      // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
+      t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
+      threads.set(key, t);
+      out.push(t);
+    }
+    t.messages.push(it);
+    // De context bij de draad: gaat het over een post, dan hoort de link
+    // erbij, anders is een los antwoord in een lijst niet te plaatsen.
+    // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
+    // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
+    // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
+    // dan kan het ontvangen antwoord eronder de echte titel niet meer
+    // invullen. De terugval op de slug hoort in de weergave, niet in de data.
+    if (it.post_slug) {
+      if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
+      else if (!t.post.title && it.post_title) t.post.title = it.post_title;
+    }
+  }
+  for (const t of threads.values()) {
+    t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
+    t.count = t.messages.length;
+    // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
+    const seen = new Set();
+    for (const m of t.messages) {
+      if (m.type === 'sent') continue;
+      const h = m.handle || m.name;
+      if (!h || seen.has(h)) continue;
+      seen.add(h);
+      t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
+    }
+    // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
+    // onbeantwoord bericht leest.
+    t.mine = t.messages.some((m) => m.type === 'sent');
+    // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
+    // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
+    // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
+    // een direct bericht aan de tegenpartij.
+    const inkomend = t.messages.filter((m) => m.type !== 'sent');
+    const laatste = inkomend[inkomend.length - 1];
+    t.replyTo = {
+      interactionId: (laatste && laatste.interactionId) || null,
+      postSlug: (t.post && t.post.slug) || null,
+      actorUri: (laatste && (laatste.actorUri || laatste.url))
+        || (t.messages.find((m) => m.to_actor) || {}).to_actor
+        || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
+    };
+  }
+  return out;
 }
 
@@ -1043,7 +1186,20 @@
   const siteIcon = (site && site.profile_photo) || null;
 
+  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
+  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
+  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
+  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
+  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
+  const mijnSleutel = new Map();
+  for (const r of rows) {
+    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
+  }
+  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
+  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
+
   const nodes = [];
   for (const r of rows) {
     if (r.kind !== 'reply') continue;
+    const ik = mijnReactie(r.object_uri);
     nodes.push({
       noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
@@ -1052,5 +1208,5 @@
       actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
       emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
-      acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
+      acted_boost: ik.boosted, acted_like: ik.liked,
       children: [],
     });
@@ -1141,12 +1297,39 @@
 }
 
-export async function fetchActor(url) {
+export async function fetchActor(url, opts = {}) {
+  // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
+  // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
+  // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
+  // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
+  // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
+  // accounts eindeloos geweigerd, en precies die vier geven 401 op een
+  // onbetekende GET (shaer-afq).
+  //
+  // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
+  // actor-document op, en dat serveert Klonkt publiek.
+  //
+  // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
+  // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
+  // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
+  // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
+  // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
+  // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
+  // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
+  let doc = null;
   try {
     const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (!r.ok) return null;
-    const len = Number(r.headers.get('content-length') || 0);
-    if (len > 2_000_000) return null; // refuse oversized actor docs
-    return await r.json();
-  } catch { return null; }
+    if (r.ok) {
+      const len = Number(r.headers.get('content-length') || 0);
+      if (len > 2_000_000) return null; // refuse oversized actor docs
+      doc = await r.json();
+    }
+  } catch { /* val door naar de ondertekende poging */ }
+  // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
+  // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
+  // telt als mislukt.
+  if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
+  if (!opts.asSlug) return doc;
+  const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
+  return (signed && signed.id) ? signed : doc;
 }
 
@@ -1225,4 +1408,14 @@
 }
 
+/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
+ *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
+let _signSlug;
+function anySigningSlug() {
+  if (_signSlug !== undefined) return _signSlug;
+  try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
+  catch { _signSlug = null; }
+  return _signSlug;
+}
+
 // Best-effort verification of an incoming signed request. Returns the sender's
 // actor doc if the signature checks out, else null. (Not gating yet — MVP.)
@@ -1230,10 +1423,14 @@
 // federating servers with drifting clocks; an operator can widen it via env.
 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
-export async function verifyRequest(req) {
+export async function verifyRequest(req, asSlug = null) {
   const sigH = req.headers['signature'];
   if (!sigH) return null;
   const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
   if (!p.keyId || !p.signature) return null;
-  const actor = await fetchActor(p.keyId.split('#')[0]);
+  // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
+  // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
+  // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
+  // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
+  const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
   const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
   if (!pem) return null;
@@ -1495,4 +1692,157 @@
 }
 
+/**
+ * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
+ *
+ * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
+ * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
+ * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
+ * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
+ * binnen, zonder dat iemand een fout zag.
+ *
+ * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
+ * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
+ * bezorgde inhoud NIET en halen het object op bij de bron.
+ *
+ * Vier voorwaarden, en geen ervan is optioneel:
+ *
+ *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
+ *     dereferencen -- het object is weg -- dus die blijft geweigerd.
+ *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
+ *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
+ *     attributedTo alles kan beweren.
+ *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
+ *     levert een doorsturer een echt id met verdraaide inhoud.
+ *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
+ *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
+ */
+/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
+ *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
+ *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
+ *  doorstuurt; iets anders is dat niet. */
+function knownNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return false;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  try {
+    if (base && uri.startsWith(`${base}/ap/notes/`)) {
+      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
+      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
+      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
+    }
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
+    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
+    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
+    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
+  } catch { /* bij twijfel niet ophalen */ }
+  return false;
+}
+
+/**
+ * Onthoud dat we dit bericht al eens bezorgd kregen.
+ *
+ * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
+ * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
+ * stelt voordat er iets bij de bron wordt opgehaald.
+ *
+ * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
+ * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
+ * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
+ * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
+ * schrijvers die je zelf volgt.
+ */
+const SEEN_NOTES_DAYS = 30;
+let _seenSinceSnoei = 0;
+function rememberNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return;
+  try {
+    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
+    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
+    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
+    // ouder is dan een maand beantwoordt geen enkele vraag meer.
+    if (++_seenSinceSnoei >= 500) {
+      _seenSinceSnoei = 0;
+      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
+      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
+    }
+  } catch { /* niet fataal */ }
+}
+const isFollowedActor = (uri) => {
+  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
+};
+
+// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
+// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
+// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
+const _derefMiss = new Map();
+const DEREF_MISS_MS = 30 * 60 * 1000;
+function derefRecentlyFailed(uri) {
+  const t = _derefMiss.get(uri);
+  if (t === undefined) return false;
+  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
+  return true;
+}
+function noteDerefFailure(uri) {
+  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
+    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
+    for (const [k] of oud) _derefMiss.delete(k);
+  }
+  _derefMiss.set(uri, Date.now());
+}
+
+async function dereferenceForwarded(act, claimedActor, type, slugParam) {
+  // Every exit states its reason. Five of the six used to return silently, so a
+  // rejection count could not be told apart from a narrowing that closed too far
+  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
+  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
+  const skipped = (reason, detail) => {
+    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
+    return null;
+  };
+  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
+  const o = act && act.object;
+  const objId = typeof o === 'string' ? o : (o && o.id);
+  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
+  try {
+    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
+  } catch { return skipped('unparsable id', objId); }
+  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
+  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
+  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
+  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
+  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
+  // niets aan legitiem verkeer waarvan we de ouder kennen.
+  const parent = typeof o === 'object' && o
+    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
+    : null;
+  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
+  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
+  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
+  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
+  // reden als bij fetchActor sinds efe5633.
+  let fetched = await apGetJson(objId).catch(() => null);
+  if (!fetched || fetched.id !== objId) {
+    // The signer used to be slugParam, which is null on the shared inbox — and
+    // that is where forwarded traffic lands, because we advertise a sharedInbox.
+    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
+    // secure mode could never be dereferenced at all. Same fix verifyRequest got
+    // in shaer-afq: any local actor is a valid signer.
+    const asSlug = slugParam || anySigningSlug();
+    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
+  }
+  const attributed = fetched && (typeof fetched.attributedTo === 'string'
+    ? fetched.attributedTo
+    : (fetched.attributedTo && fetched.attributedTo.id));
+  if (!fetched || fetched.id !== objId) {
+    noteDerefFailure(objId);
+    return skipped('fetch failed', objId);
+  }
+  if (attributed !== claimedActor) {
+    // Not a transport hiccup: the source itself says someone else wrote this.
+    noteDerefFailure(objId);
+    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
+  }
+  return fetched;
+}
+
 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
 export async function handleInbox(req, slugParam, preVerified = null) {
@@ -1508,5 +1858,5 @@
   // keeps everything below identical, including the actor-versus-signer check,
   // which is exactly the check that must not be skipped for being local.
-  const verified = preVerified || await verifyRequest(req).catch(() => null);
+  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
 
   // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
@@ -1518,6 +1868,24 @@
   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
   if (GATED.includes(type)) {
-    if (!verified || !claimedActor || verified.id !== claimedActor) {
-      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
+    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
+    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
+    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
+    let forwarded = null;
+    if (verified && claimedActor && verified.id !== claimedActor) {
+      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
+      if (forwarded) {
+        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
+        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
+      }
+    }
+    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
+      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
+      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
+      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
+      // of geldig ondertekend door iemand anders.
+      const reden = verified ? '(signer mismatch)'
+        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
+        : '(no signature)';
+      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
       return 401;
     }
@@ -1809,8 +2177,31 @@
       }
     }
+    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
+    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
+    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
+    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
+    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
+    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
+    // moet iemand zijn die je zelf bent gaan volgen.
+    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
+      rememberNoteUri(o.id);
+    }
     // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
     // above): store a mention notification for each of our actors named in the Mention tags.
     // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
     // someone else's actor, not ours.
+    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
+    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
+    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
+    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
+    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
+    if (actorUri && !isLocalActor) {
+      const mark = Guardianship.help.parseMarker(o);
+      if (mark) {
+        const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
+        Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
+        console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
+      }
+    }
     if (actorUri && !isLocalActor && o.id) {
       const slugs = localMentionSlugs(o.tag, base);
@@ -2497,6 +2888,17 @@
         const kind = type === 'Announce' ? 'boost' : 'like';
         await sendInteraction(site, kind, objUri, authorUri);
-        setMyReaction(site.slug, targetUri, kind, true);
-        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
+        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
+        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
+        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
+        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
+        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
+        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
+        // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
+        // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
+        // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
+        // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
+        // Anders dan bij een boost geen upsert: een like hoort een post niet
+        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
+        // terecht een no-op.
         return { status: 202, url: objUri };
       }
@@ -2547,6 +2949,6 @@
           const objUri = (note && note.object_uri) || innerTarget;
           await sendInteraction(site, kind, objUri, note && note.actor_uri);
-          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
-          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
+          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
+          catch { /* non-fatal */ }
           return { status: 202, url: objUri };
         }
@@ -2913,5 +3315,9 @@
 }
 export function listOutbox(siteSlug) {
-  return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
+  // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
+  // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
+  // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
+  // elkaar in "Verzonden" en "Gesprekken".
+  return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
     .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
 }
@@ -3051,5 +3457,27 @@
   return { ins: _insTl, list: _listTl, del: _delTl };
 }
-export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
+/**
+ * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
+ *
+ * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
+ * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
+ * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
+ * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
+ * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
+ *
+ * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
+ * heten en hangt het van de driver af welke wint. Eén extra query per pagina
+ * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
+ */
+export function getTimeline(slug, limit, offset) {
+  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
+  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
+  for (const r of rows) {
+    const x = reacties.get(r.id);
+    r.liked = !!(x && x.liked);
+    r.boosted = !!(x && x.boosted);
+  }
+  return rows;
+}
 
 /**
@@ -3087,4 +3515,86 @@
 }
 
+/**
+ * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
+ * (shaer-n05).
+ *
+ * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
+ * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
+ * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
+ * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
+ *
+ * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
+ * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
+ * andere server niet te vertrouwen is.
+ *
+ * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
+ * mee; de vorm mag veranderen zonder dat dat iets breekt.
+ */
+export function feedCursor(slug) {
+  try {
+    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
+    return String((r && r.n) || 0);
+  } catch { return '0'; }
+}
+
+/**
+ * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
+ * bewerkt, of weg.
+ *
+ * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
+ * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
+ * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
+ * plek waar ze samen te lezen zijn.
+ */
+export function feedChangesSince(slug, rev, limit = 200) {
+  try {
+    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
+                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
+      .all(slug, parseInt(rev, 10) || 0, limit);
+  } catch { return []; }
+}
+
+// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
+// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
+// krijgen gewoon meteen antwoord in plaats van een fout.
+const FEED_WAIT_MAX = 4;
+const _wachters = new Map();
+
+/**
+ * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
+ *
+ * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
+ * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
+ * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
+ * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
+ * vertraging.
+ */
+export async function waitForFeedChange(slug, opts = {}) {
+  const tickMs = Math.max(50, opts.tickMs || 1000);
+  const waitMs = Math.max(0, opts.waitMs || 0);
+  const since = String(opts.since || '');
+  let cursor = feedCursor(slug);
+  // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
+  if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
+
+  const bezet = _wachters.get(slug) || 0;
+  if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
+  _wachters.set(slug, bezet + 1);
+  try {
+    const einde = Date.now() + waitMs;
+    while (Date.now() < einde) {
+      if (opts.signal && opts.signal.aborted) break;   // client hing op
+      const rest = Math.min(tickMs, einde - Date.now());
+      await new Promise((r) => setTimeout(r, rest));
+      cursor = feedCursor(slug);
+      if (cursor !== since) return { cursor, changed: true, waited: true };
+    }
+    return { cursor, changed: false, waited: true };
+  } finally {
+    const n = (_wachters.get(slug) || 1) - 1;
+    if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
+  }
+}
+
 export function getDirectMessages(slug, limit) {
   try {
@@ -3243,8 +3753,13 @@
     if (!_cirkelPosts) _cirkelPosts = db.prepare(`
       SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
-             t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
+             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
+             (rb.target_uri IS NOT NULL) AS boosted
       FROM ap_timeline t
       LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
-      WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
+      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
+      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
+      -- deze join kan geen rijen verdubbelen.
+      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
+      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
       ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
       LIMIT ? OFFSET ?`);
@@ -3255,5 +3770,14 @@
   try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
 }
-// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
+// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
+// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
+// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
+// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
+//
+// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
+// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
+// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
+// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
+// terugdraaien een code-revert blijft in plaats van dataherstel.
 let _markBoost, _unmarkBoost, _boostedCount;
 export function markBoosted(slug, noteId) {
@@ -3270,4 +3794,207 @@
   try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
 }
+/**
+ * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
+ * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
+ * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
+ * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
+ * dat leverde de shaer:liked-bug op (04aca12).
+ *
+ * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
+ * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
+ * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
+ * boost nergens -- ook niet in de Cirkel.
+ *
+ * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
+ * worden: de tussentabel op de URI die de client stuurde, de vlag op de
+ * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
+ * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
+ * werk voor fase 2, mét datamigratie.
+ */
+// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
+// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
+// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
+// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
+// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
+// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
+const REACTIONS_MIGRATION_VERSION = 2;
+
+/**
+ * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
+ *
+ * Twee stappen, en ze zijn allebei nodig:
+ *
+ *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
+ *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
+ *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
+ *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
+ *     wanneer je reageerde, bij aanvullen niet.
+ *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
+ *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
+ *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
+ *     fediverse in als gevolg.
+ *
+ * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
+ */
+export function migrateReactions(opts = {}) {
+  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
+  try {
+    if (!opts.force) {
+      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
+      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
+      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
+    }
+  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
+
+  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
+  const wees = `
+    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
+     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
+  const scheef = (kind, kolom) => `
+    FROM ap_timeline t
+     WHERE t.${kolom} = 1
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
+  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
+  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
+  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
+  //    dus die uitsluiting verliest per constructie niets.
+  const acted = (kind, kolom) => `
+    FROM ap_interactions i
+     JOIN posts p ON p.id = i.post_id
+     JOIN sites s ON s.id = p.site_id
+     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
+
+  if (opts.dryRun) {
+    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
+    uit.hersleuteld = tel(wees);
+    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
+    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
+    return uit;
+  }
+
+  try {
+    db.transaction(() => {
+      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
+      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
+      //    oplevert en nooit een verdwenen reactie.
+      uit.hersleuteld = db.prepare(`
+        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
+        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
+      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
+
+      // 2. Aanvullen vanuit de kolommen.
+      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
+        uit.aangevuld += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
+      }
+
+      // 3. En vanuit acted_* op de reacties onder je eigen posts.
+      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
+        uit.reacties += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
+      }
+    })();
+    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
+      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
+    }
+    if (!opts.force) {
+      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
+        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
+    }
+  } catch (e) {
+    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
+    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
+    console.warn('[AP] reaction migration failed:', e.message);
+  }
+  return uit;
+}
+
+/**
+ * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
+ *
+ * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
+ * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
+ * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
+ * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
+ * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
+ *
+ * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
+ * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
+ * highlight. Vandaar hier, en niet later.
+ *
+ * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
+ * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
+ * een reactie op iets buiten je tijdlijn moet gewoon werken.
+ */
+export function canonicalReactionUri(slug, uri) {
+  if (!slug || !uri) return uri;
+  try {
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
+    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
+    return (row && row.id) || uri;
+  } catch { return uri; }
+}
+
+/**
+ * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
+ * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
+ * dezelfde vraag beantwoordden uit twee verschillende bronnen.
+ */
+export function getReaction(slug, uri) {
+  try {
+    const key = canonicalReactionUri(slug, uri);
+    const rows = (slug && key)
+      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
+      : [];
+    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
+  } catch { return { liked: false, boosted: false }; }
+}
+
+/**
+ * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
+ * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
+ * een consistentiebug geruild voor een traagheidsbug.
+ */
+export function getReactionsFor(slug, uris) {
+  const out = new Map();
+  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
+  if (!slug || !list.length) return out;
+  try {
+    const rows = db.prepare(
+      `SELECT target_uri, kind FROM ap_my_reactions
+        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
+    ).all(slug, ...list);
+    for (const r of rows) {
+      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
+      if (r.kind === 'like') cur.liked = true;
+      if (r.kind === 'boost') cur.boosted = true;
+      out.set(r.target_uri, cur);
+    }
+  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
+  return out;
+}
+
+export function setReaction(slug, uri, kind, on, opts = {}) {
+  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
+  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
+  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
+  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
+  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
+  // maakte dezelfde like onvindbaar vanaf de andere pagina.
+  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
+  setMyReaction(slug, flagUri, kind, !!on);
+  if (kind === 'boost') {
+    if (!on) unmarkBoosted(slug, flagUri);
+    else if (opts.note) upsertBoostedNote(slug, opts.note);
+    else markBoosted(slug, flagUri);
+  } else if (on) markLiked(slug, flagUri);
+  else unmarkLiked(slug, flagUri);
+}
+
 export function getTimelineReaction(slug, noteId) {
   try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
@@ -3303,5 +4030,12 @@
 }
 export function boostedCount(slug) {
-  try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
+  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
+  // bevatten van iets dat er (nog) niet in staat.
+  try {
+    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
+      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
+      WHERE r.site_slug = ? AND r.kind = 'boost'`);
+    return _boostedCount.get(slug).n;
+  } catch { return 0; }
 }
 
@@ -4018,5 +4752,5 @@
     await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
   }
-  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
+  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
   return { ok: true, target: target.id, inboxes: inboxes.length };
 }
@@ -4198,5 +4932,18 @@
       const wardDoc = await fetchActor(wardUri).catch(() => null);
       const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
-      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
+      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
+      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
+      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
+      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
+      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
+      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
+      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
+      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
+      Guardianship.follows.recordReview(gslug, {
+        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
+        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
+        direction: uitgaand ? 'outgoing' : 'incoming',
+        target: doel || null, targetHandle: dai ? dai.handle : null,
+      });
       const L = pushLang(gslug);
       pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
@@ -4301,5 +5048,5 @@
   try {
     const rows = db.prepare(`
-      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
+      SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
              i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
              p.slug AS post_slug, p.title AS post_title
@@ -4310,4 +5057,7 @@
     for (const r of rows) out.push({
       type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
+      // Waar een antwoord uit de draad heen moet: het id is de parent voor
+      // deliverReply, de uri het adres voor een direct bericht.
+      interactionId: r.interaction_id, actorUri: r.actor_uri,
       content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
       // When the post was written, for display. created_at (when it reached us)
@@ -4543,4 +5293,8 @@
   getOutboxRow: (id) => iStmts().getO.get(id),
   buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
+  // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
+  // een antwoord uit Berichten door precies één poort gaat.
+  sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
+  htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
 });
 /**
@@ -4579,21 +5333,61 @@
   // Guardian PWA / Berichten push. The kid answers an incoming offer in its
   // own Berichten; an existing guardian and a commit land in the PWA.
+  //
+  // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
+  // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
   onEvent: (slug, ev) => {
-    const L = pushLang(slug);
-    const texts = {
-      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
-      offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
-      committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
-      // §3.2: a guardian ended the relation. The ward hears that someone who
-      // was looking after them has gone; a co-guardian hears they are one fewer.
-      guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
-      coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
-    }[ev.kind];
-    if (!texts) return;
-    const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
-    const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
-    pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
+    const p = guardianEventPush(slug, ev);
+    if (p) pushEvent(slug, p);
   },
 });
+
+/**
+ * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
+ *
+ * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
+ * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
+ * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
+ * erbij te halen.
+ */
+export function guardianEventPush(slug, ev) {
+  const L = pushLang(slug);
+  const texts = {
+    offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
+    offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
+    committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
+    // §3.2: a guardian ended the relation. The ward hears that someone who
+    // was looking after them has gone; a co-guardian hears they are one fewer.
+    guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
+    coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
+    // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
+    // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
+    // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
+    // geen drempel.
+    gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
+    gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
+  }[ev.kind];
+  if (!texts) return null;
+  const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
+  // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
+  // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
+  const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
+  const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
+  const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
+  const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
+  return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
+}
+
+// Van een gated feature naar het woord dat het Guardian-paneel er al voor
+// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
+// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
+const GATE_LABEL = {
+  'shaer:externalEmbeds': 'guardian.prop_embeds',
+  'shaer:externalPlayback': 'guardian.prop_play',
+};
+const GATE_OUTCOME = {
+  accepted: 'guardian.prop_st_accepted',
+  rejected: 'guardian.prop_st_rejected',
+  expired: 'guardian.prop_st_expired',
+};
 
 // The notification duty of FEP-633c 3.6.2, wired once for every place a
@@ -4625,5 +5419,6 @@
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
-  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
+  feedCursor, feedChangesSince, waitForFeedChange,
+  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,
@@ -4631,5 +5426,5 @@
   gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
-  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
+  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveExportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,386 @@
+/**
+ * Export van een draagbaar inhoudsarchief (shaer-1a6).
+ *
+ * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
+ * alleen wat de code doet, niet waarom het formaat zo is.
+ *
+ * Twee dingen zijn geen implementatiedetail maar eis:
+ *
+ *   REPRODUCEERBAAR  Twee exports van ongewijzigde inhoud horen byte-voor-byte
+ *                    gelijk te zijn, anders is een diff of een checksum nutteloos.
+ *                    Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
+ *                    in de postbestanden en een vaste mtime in de zip.
+ *   GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
+ *                    sleutel, sessie, hash of DM van een ander in.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+
+export const FORMAT_VERSION = 1;
+
+/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
+export function stableJson(value) {
+  const sorteer = (v) => {
+    if (Array.isArray(v)) return v.map(sorteer);
+    if (v && typeof v === 'object') {
+      const uit = {};
+      for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
+      return uit;
+    }
+    return v;
+  };
+  return `${JSON.stringify(sorteer(value), null, 2)}\n`;
+}
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const toISO = (d) => { const t = Date.parse(d); return isNaN(t) ? null : new Date(t).toISOString(); };
+
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
+  mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
+  mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * Van een media-URL naar een bestand op schijf.
+ *
+ * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
+ * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
+ * een pad -- de content slaat allebei op.
+ *
+ * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
+ * anders een willekeurig bestand van de schijf het archief in trekken.
+ */
+function localMediaPath(url, origin) {
+  let p = String(url || '');
+  if (!p) return null;
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (`${u.protocol}//${u.host}` !== origin) return null;   // andermans host: nooit van onze schijf
+      p = u.pathname;
+    } catch { return null; }
+  }
+  if (!p.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
+  return abs;
+}
+
+/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
+function mediaRefsOf(post, origin) {
+  const uit = [];
+  const zie = new Set();
+  const voegToe = (url, name, rol, extra = {}) => {
+    const u = String(url || '').trim();
+    if (!u || zie.has(u)) return;
+    zie.add(u);
+    uit.push({ url: u, name: name || null, rol, ...extra });
+  };
+  // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
+  // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
+  // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
+  // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
+  // handjevol.
+  voegToe(post.cover_image_url, post.cover_alt, 'cover');
+  voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
+  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
+  try {
+    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
+      voegToe(a && a.url, a && a.name, 'c2s');
+      // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
+      // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
+      // pas op bij de export van echte data.
+      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
+    }
+  } catch { /* kapotte kolom blokkeert de export niet */ }
+  // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
+      if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
+    } catch { /* geen audio-tabellen: niets te doen */ }
+  }
+  return uit;
+}
+
+/**
+ * De audio-metadata die alleen in de database staat en nergens anders uit te
+ * halen is: titel, artiest, credit, licentie, externe links.
+ *
+ * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
+ * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
+ * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
+ * herstel op niets terug.
+ */
+function audioOf(post, attachments) {
+  const uit = [];
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
+      if (!t) continue;
+      let bestand;
+      if (t.storage_path) {
+        const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
+        const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
+        bestand = bij ? bij.url : undefined;
+      }
+      uit.push({
+        'shaer:ref': `[[track:${t.id}]]`,
+        'shaer:media': bestand,
+        name: t.title, artist: t.artist || undefined, album: t.album || undefined,
+        duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
+        url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
+      });
+    } catch { /* idem */ }
+  }
+  return uit.length ? uit : undefined;
+}
+
+/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
+function postObject(post, site, origin, attachments) {
+  const heeftTitel = !!(post.title && String(post.title).trim());
+  const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
+  const updated = toISO(post.updated_at);
+  const poll = (() => {
+    try {
+      const d = JSON.parse(post.poll_json || 'null');
+      if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
+      const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
+      return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
+    } catch { return null; }
+  })();
+  const tags = [];
+  try {
+    for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
+      tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
+    }
+  } catch { /* tags zijn optioneel */ }
+
+  return {
+    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+    id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
+    type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
+    attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    name: heeftTitel ? post.title : undefined,
+    content: post.content || '',
+    contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
+    summary: post.content_warning || undefined,
+    sensitive: post.nsfw ? true : undefined,
+    published,
+    updated: (updated && updated !== published) ? updated : undefined,
+    url: `${origin}/${encodeURIComponent(post.slug)}`,
+    attachment: attachments.length ? attachments : undefined,
+    tag: tags.length ? tags : undefined,
+    ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
+    endTime: poll ? (poll.endTime || undefined) : undefined,
+    // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
+    // is gesloten na een herstel weer open -- gevonden op echte beta-data.
+    closed: (poll && poll.closed) ? true : undefined,
+    quoteUrl: post.quote_uri || undefined,
+    'shaer:quoteActor': post.quote_actor || undefined,
+    'shaer:slug': post.slug,
+    'shaer:status': post.status || 'draft',
+    'shaer:excerpt': post.excerpt || undefined,
+    'shaer:type': post.type || undefined,
+    'shaer:pinned': post.pinned ? true : undefined,
+    'shaer:noindex': post.noindex ? true : undefined,
+    'shaer:fanOnly': post.fan_only ? true : undefined,
+    'shaer:paid': post.paid ? true : undefined,
+    'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
+    'shaer:apVisibility': post.ap_visibility || undefined,
+    'shaer:publishAt': toISO(post.publish_at) || undefined,
+    'shaer:coverAlt': post.cover_alt || undefined,
+    'shaer:viewCount': post.view_count || undefined,
+    'shaer:audio': audioOf(post, attachments),
+  };
+}
+
+/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
+function readableMarkdown(post, obj) {
+  const fm = [
+    '---',
+    `title: ${JSON.stringify(post.title || post.slug)}`,
+    `slug: ${JSON.stringify(post.slug)}`,
+    `date: ${obj.published || ''}`,
+    `status: ${post.status || 'draft'}`,
+    post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
+    '---',
+  ].filter((l) => l !== null).join('\n');
+  return `${fm}\n${post.content || ''}\n`;
+}
+
+/**
+ * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
+ *
+ * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
+ * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
+ */
+export function buildArchive(slug, opts = {}) {
+  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+  if (!site) throw new Error(`onbekende site: ${slug}`);
+
+  const bestanden = new Map();       // pad -> Buffer
+  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
+  const ontbrekend = [];             // voor de rapportage van de beller
+
+  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
+  // is de volgorde van twee posts op dezelfde seconde niet bepaald.
+  const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
+                             ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
+
+  for (const post of posts) {
+    const attachments = [];
+    for (const ref of mediaRefsOf(post, origin)) {
+      const schijf = localMediaPath(ref.url, origin);
+      const mime = mimeOf(ref.url);
+      let bytes = null;
+      if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
+        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
+          'shaer:sha256': hash,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
+        });
+      } else {
+        // De derde staat uit het formaat: we weten DAT het bestond en waar het
+        // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
+        tellingen.mediaMissing += 1;
+        const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
+        ontbrekend.push({ post: post.slug, url: orig });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
+        });
+      }
+    }
+
+    const obj = postObject(post, site, origin, attachments);
+    bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
+    tellingen.posts += 1;
+
+    // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
+    let replies = [];
+    try {
+      replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
+                             ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
+    } catch { /* tabel kan ontbreken op een heel oude database */ }
+    if (replies.length) {
+      const coll = {
+        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
+        type: 'OrderedCollection',
+        'shaer:archive': true,
+        'shaer:inReplyTo': obj.id,
+        totalItems: replies.length,
+        orderedItems: replies.map((r) => ({
+          id: r.object_uri || undefined,
+          type: 'Note',
+          attributedTo: r.actor_uri || undefined,
+          inReplyTo: r.parent_uri || obj.id,
+          content: r.content || '',
+          published: toISO(r.published || r.created_at) || undefined,
+          'shaer:actorName': r.actor_name || undefined,
+          'shaer:actorHandle': r.actor_handle || undefined,
+        })),
+      };
+      bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
+      tellingen.replies += replies.length;
+    }
+  }
+
+  const files = {};
+  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    site: { slug: site.slug, title: site.title || site.slug },
+    counts: tellingen,
+    files,
+  };
+  bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
+}
+
+// ── Zip, store-only en deterministisch ────────────────────────────
+// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
+// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
+// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
+
+const _crcTabel = (() => {
+  const t = new Int32Array(256);
+  for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
+  return t;
+})();
+function crc32(buf) {
+  let c = 0 ^ -1;
+  for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
+  return (c ^ -1) >>> 0;
+}
+
+export function zipArchive(files) {
+  const paden = [...files.keys()].sort();
+  const lokaal = [];
+  const centraal = [];
+  let offset = 0;
+  for (const pad of paden) {
+    const naam = Buffer.from(pad, 'utf8');
+    const data = files.get(pad);
+    const crc = crc32(data);
+    const lh = Buffer.alloc(30);
+    lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
+    lh.writeUInt16LE(0, 8);                       // store, geen compressie
+    lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12);   // vaste tijd: 1980-01-01
+    lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
+    lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
+    lokaal.push(lh, naam, data);
+
+    const ch = Buffer.alloc(46);
+    ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
+    ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
+    ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
+    ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
+    ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
+    ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
+    ch.writeUInt32LE(offset, 42);
+    centraal.push(ch, naam);
+    offset += 30 + naam.length + data.length;
+  }
+  const cd = Buffer.concat(centraal);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0);
+  eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
+  eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
+  return Buffer.concat([...lokaal, cd, eocd]);
+}
+
+/** Schrijf het archief als losse bestanden naar een map. */
+export function writeArchiveDir(files, dir) {
+  for (const pad of [...files.keys()].sort()) {
+    const doel = path.join(dir, pad);
+    fs.mkdirSync(path.dirname(doel), { recursive: true });
+    fs.writeFileSync(doel, files.get(pad));
+  }
+}
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveImportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,318 @@
+/**
+ * Import van een draagbaar inhoudsarchief (shaer-pmr).
+ *
+ * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
+ * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
+ *
+ *   VERSIE EERST   Een hogere onbekende formatVersion wordt in zijn GEHEEL
+ *                  geweigerd. Een half begrepen herstel is erger dan geen
+ *                  herstel, want het ziet eruit alsof het gelukt is.
+ *   IDENTITEIT     De origin uit het manifest bepaalt of de AP-ids behouden
+ *                  blijven. Dat is geen vraag aan de gebruiker: een verkeerd
+ *                  antwoord publiceert objecten onder een id dat je niet beheert.
+ *   NIETS STILS    Ontbrekende media worden geteld en gemeld.
+ *   GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
+ *                  rechttrekken is een aparte, bewuste actie.
+ *
+ * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import zlib from 'zlib';
+import crypto from 'crypto';
+import { randomUUID } from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+import { FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
+// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
+// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
+const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
+
+// ── Inlezen ───────────────────────────────────────────────────────
+
+/** Lees een archiefmap in als pad -> Buffer. */
+export function readArchiveDir(dir) {
+  const files = new Map();
+  const loop = (sub) => {
+    for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+      const rel = sub ? `${sub}/${naam.name}` : naam.name;
+      if (naam.isDirectory()) loop(rel);
+      else files.set(rel, fs.readFileSync(path.join(dir, rel)));
+    }
+  };
+  loop('');
+  return files;
+}
+
+/**
+ * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
+ * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
+ */
+export function readArchiveZip(buf) {
+  const files = new Map();
+  const eocd = (() => {
+    for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
+    return -1;
+  })();
+  if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
+  const aantal = buf.readUInt16LE(eocd + 10);
+  let p = buf.readUInt32LE(eocd + 16);
+  for (let n = 0; n < aantal; n++) {
+    if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
+    const methode = buf.readUInt16LE(p + 10);
+    const gecomp = buf.readUInt32LE(p + 20);
+    const naamLen = buf.readUInt16LE(p + 28);
+    const extraLen = buf.readUInt16LE(p + 30);
+    const commentLen = buf.readUInt16LE(p + 32);
+    const lokaalOffset = buf.readUInt32LE(p + 42);
+    const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
+    const lNaam = buf.readUInt16LE(lokaalOffset + 26);
+    const lExtra = buf.readUInt16LE(lokaalOffset + 28);
+    const start = lokaalOffset + 30 + lNaam + lExtra;
+    const rauw = buf.subarray(start, start + gecomp);
+    if (!naam.endsWith('/')) {
+      files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
+    }
+    p += 46 + naamLen + extraLen + commentLen;
+  }
+  return files;
+}
+
+export function readArchive(bron) {
+  const st = fs.statSync(bron);
+  return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
+}
+
+// ── Importeren ────────────────────────────────────────────────────
+
+/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
+function veiligMediaPad(urlPad) {
+  if (!urlPad || !urlPad.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
+}
+
+/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
+function padVanOrigineel(u) {
+  const s = String(u || '');
+  if (s.startsWith('/media/')) return s;
+  try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
+}
+
+/**
+ * Waar deze bijlage komt te staan, als site-relatief pad.
+ *
+ * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
+ * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
+ * plek onder /media. Zonder een bestemming zou het bestand wel worden
+ * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
+ * en verwijzen de kolommen daarheen.
+ */
+function bestemming(a) {
+  return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
+}
+
+/**
+ * Zet een archief terug in een site.
+ *
+ * @param {Map<string,Buffer>} files  het ingelezen archief
+ * @param {object} opts  { slug, dryRun, overwrite, origin }
+ */
+export function importArchive(files, opts = {}) {
+  const rapport = {
+    formatVersion: null, origin: null, idsBehouden: null,
+    posts: 0, overgeslagen: 0, overschreven: 0,
+    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
+  };
+
+  const manifestBuf = files.get('manifest.json');
+  if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
+  const manifest = JSON.parse(manifestBuf.toString('utf8'));
+  rapport.formatVersion = manifest.formatVersion;
+
+  // VERSIE EERST, voordat er ook maar iets gelezen wordt.
+  if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
+  if (manifest.formatVersion > FORMAT_VERSION) {
+    throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
+  }
+
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
+  if (!site) throw new Error(`onbekende site: ${opts.slug}`);
+  const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  rapport.origin = manifest.origin || null;
+
+  // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
+  // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
+  // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
+  // servers halen het daar toch op.
+  const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
+  rapport.idsBehouden = idsBehouden;
+  if (!idsBehouden) {
+    rapport.waarschuwingen.push(
+      `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
+    );
+  }
+
+  const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
+  const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
+  const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
+  const idKaart = new Map();     // oud post-id -> nieuw post-id
+
+  const schrijf = [];            // alles eerst uitrekenen, dan in EEN transactie
+
+  for (const pad of postPaden) {
+    const o = JSON.parse(files.get(pad).toString('utf8'));
+    const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
+    const nieuwId = idsBehouden ? oudId : randomUUID();
+    idKaart.set(oudId, nieuwId);
+
+    const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
+    if (botsing && !opts.overwrite) {
+      // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
+      // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
+      // idempotent maakt.
+      rapport.overgeslagen += 1;
+      continue;
+    }
+    if (botsing) rapport.overschreven += 1;
+
+    // Media: terug naar hun oorspronkelijke plek onder /media, want de content
+    // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
+    // op een ander domein.
+    for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
+      if (a['shaer:availability'] === 'missing') {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
+        continue;
+      }
+      const bytes = files.get(a.url);
+      if (!bytes) {
+        // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
+        // archief, geen ontbrekende media -- apart melden, niet stil optellen.
+        rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
+        continue;
+      }
+      if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
+        rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
+        continue;
+      }
+      const doel = veiligMediaPad(bestemming(a));
+      if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
+      schrijf.push({ soort: 'media', doel, bytes });
+      rapport.media += 1;
+    }
+
+    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
+    rapport.posts += 1;
+  }
+
+  // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
+  for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
+    const coll = JSON.parse(files.get(pad).toString('utf8'));
+    if (coll['shaer:archive'] !== true) {
+      rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
+      continue;
+    }
+    const oudId = path.basename(pad, '.json');
+    const postId = idKaart.get(oudId);
+    if (!postId) continue;                       // post overgeslagen -> antwoorden ook
+    for (const it of (coll.orderedItems || [])) {
+      schrijf.push({ soort: 'reply', postId, it });
+      rapport.replies += 1;
+    }
+  }
+
+  if (opts.dryRun) return rapport;
+
+  // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
+  // uitkomst, want dan lijkt het gelukt.
+  const insPost = db.prepare(`INSERT OR REPLACE INTO posts
+    (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
+     pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
+     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
+    VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
+     @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
+     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
+  const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
+    (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
+    VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
+
+  db.transaction(() => {
+    for (const s of schrijf) {
+      if (s.soort === 'media') {
+        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
+        fs.writeFileSync(s.doel, s.bytes);
+        continue;
+      }
+      if (s.soort === 'reply') {
+        insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
+          s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
+        continue;
+      }
+      const o = s.obj;
+      const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
+      // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
+      // bestand er wel, maar komt de post zonder cover en zonder speler terug --
+      // en dat zie je pas als je alle kolommen vergelijkt.
+      const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
+      const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
+      const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
+      const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
+        const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
+        return {
+          url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
+          poster: poster ? padVan(poster) : undefined,
+        };
+      }).filter((a) => a.url);
+      insPost.run({
+        id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
+        title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
+        status: o['shaer:status'] || 'draft',
+        cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
+        cover_video_url: padVan(metRol('coverVideo')),
+        c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
+        pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
+        tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
+        published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
+        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
+        fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
+        language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
+        content_warning: o.summary || null,
+        poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
+        quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
+        ap_visibility: o['shaer:apVisibility'] || null,
+        paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
+        view_count: o['shaer:viewCount'] || 0,
+      });
+      // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
+      for (const t of (o['shaer:audio'] || [])) {
+        const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
+        if (!trackId) continue;
+        let mediaId = null;
+        const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
+        const doel = bij && veiligMediaPad(bestemming(bij));
+        if (doel) {
+          mediaId = randomUUID();
+          try {
+            db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+              .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
+          } catch { mediaId = null; }
+        }
+        try {
+          db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud)
+                      VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
+            .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
+              mediaId, t.credit || null, t.license || null,
+              ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
+        } catch { /* geen audio-tabellen op deze installatie */ }
+      }
+    }
+  })();
+
+  return rapport;
+}
Index: src/services/ArchiveRecoveryService.js
===================================================================
--- src/services/ArchiveRecoveryService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveRecoveryService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,250 @@
+/**
+ * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (shaer-l1v).
+ *
+ * De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
+ * ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
+ * OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
+ * verschil tussen herstellen en opnieuw posten: boosts, likes en antwoorden
+ * elders wijzen naar die ids.
+ *
+ * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit
+ * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone
+ * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een
+ * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat.
+ *
+ * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan
+ * voordat iemand eraan begint:
+ *
+ *   - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles
+ *     met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland.
+ *   - alles van VOOR het moment dat de bron ging volgen.
+ *   - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet.
+ *   - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de
+ *     bron, met de titel in de tekst gebakken en de afbeeldingen uit de body
+ *     gehaald. Waar ze in de tekst stonden is niet te herstellen.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import Database from 'better-sqlite3';
+import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
+  mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * De titel terugvissen uit de tekst.
+ *
+ * buildNote() zet de titel als eerste alinea in de content -- `<p><strong>...`
+ * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm,
+ * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte
+ * eerste regel in de body.
+ *
+ * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte
+ * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm
+ * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte
+ * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar
+ * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort
+ * een mens na te lopen, niet een script.
+ */
+export function splitsTitel(html) {
+  const m = String(html || '').match(/^\s*<p>\s*<strong>([^<>]+)<\/strong>\s*<\/p>/i);
+  if (!m) return { titel: null, rest: html || '' };
+  const titel = m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
+  if (!titel) return { titel: null, rest: html || '' };
+  return { titel, rest: String(html).slice(m[0].length) };
+}
+
+/**
+ * Van een URL naar een bestand op de geredde schijf.
+ *
+ * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op
+ * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/<bestandsnaam> op
+ * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen
+ * randgeval maar de helft van de bijlagen.
+ */
+function schijfPad(url, origin, mediaRoot, audioRoot) {
+  let p = String(url || '');
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (origin && `${u.protocol}//${u.host}` !== origin) return null;
+      p = u.pathname;
+    } catch { return null; }
+  }
+  const onder = (root, rest) => {
+    if (!root) return null;
+    const abs = path.resolve(root, decodeURIComponent(rest));
+    const r = path.resolve(root);
+    return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null;
+  };
+  if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length));
+  if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length));
+  return null;
+}
+
+const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } };
+
+/**
+ * Bouw een archief uit een of meer tijdlijn-caches.
+ *
+ * @param {object} opts
+ *   sources   paden naar de database(s) van instances die de verloren site volgen
+ *   actorUri  de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert
+ *   mediaRoot de geredde mediamap van de verloren site (optioneel)
+ *   houdTitelInTekst  laat de titel staan waar hij staat
+ */
+export function recoverFromCache(opts = {}) {
+  const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts;
+  // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de
+  // buurmap van de mediamap, want dat is de standaardindeling van storage/.
+  const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null);
+  if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden');
+  const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, '');
+  if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri');
+
+  const rapport = {
+    bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [],
+    overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [],
+  };
+
+  // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en
+  // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint.
+  const beste = new Map();
+  for (const bron of sources) {
+    let n = 0;
+    let sdb;
+    try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; }
+    let rijen = [];
+    try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); }
+    for (const r of rijen) {
+      // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus
+      // author_uri filtert die al weg. Een boost van ONZE post door een ander is
+      // wel van ons -- die houden we, maar zonder de booster.
+      const vorige = beste.get(r.id);
+      if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r);
+      n += 1;
+    }
+    sdb.close();
+    rapport.bronnen.push({ pad: bron, rijen: n });
+  }
+
+  const files = new Map();
+  const ids = [...beste.keys()].sort();
+
+  for (const apId of ids) {
+    const r = beste.get(apId);
+    const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || '');
+    if (!postId) { rapport.overgeslagen += 1; continue; }
+    let slug = postId;
+    try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ }
+
+    const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content);
+    if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel });
+
+    // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote,
+    // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt
+    // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij
+    // het federeren verloren gegaan en komt niet terug.
+    const attachments = [];
+    const lijst = parse(r.media_json, []) || [];
+    lijst.forEach((m, i) => {
+      const url = m && (m.url || m.href);
+      if (!url) return;
+      const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null);
+      const rol = i === 0 ? 'cover' : 'c2s';
+      const bestand = schijfPad(url, origin, mediaRoot, audioRoot);
+      let bytes = null;
+      if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
+        if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:sha256': hash, 'shaer:role': rol,
+        });
+      } else {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ slug, url });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:availability': 'missing',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:role': rol,
+        });
+      }
+    });
+
+    const poll = parse(r.poll_json);
+    const quote = parse(r.quote_json);
+    const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2
+      ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null;
+
+    const obj = {
+      '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+      id: apId,
+      type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'),
+      attributedTo: actorUri,
+      name: gesplitst.titel || undefined,
+      content: gesplitst.rest,
+      summary: r.cw || undefined,
+      sensitive: r.nsfw ? true : undefined,
+      published: r.published || undefined,
+      url: r.url || `${origin}/${encodeURIComponent(slug)}`,
+      attachment: attachments.length ? attachments : undefined,
+      ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}),
+      endTime: opties ? (poll.endTime || undefined) : undefined,
+      closed: (opties && poll.closed) ? true : undefined,
+      quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined,
+      'shaer:slug': slug,
+      'shaer:status': 'published',      // alles wat gefedereerd is, was gepubliceerd
+      'shaer:recoveredFrom': 'timeline-cache',
+    };
+    files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    files.set(`readable/${slug}.md`, Buffer.from(
+      `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8'));
+    rapport.posts += 1;
+    if (r.published) {
+      if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published;
+      if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published;
+    }
+  }
+
+  const bestandsHashes = {};
+  for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt-recovery/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: actorUri,
+    site: { slug: opts.slug || '', title: opts.title || '' },
+    counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing },
+    files: bestandsHashes,
+    // Zodat niemand dit later voor een gewone export aanziet: dit archief is
+    // gereconstrueerd uit andermans cache en is per definitie onvolledig.
+    'shaer:recovered': {
+      from: 'timeline-cache',
+      sources: rapport.bronnen.map((b) => path.basename(b.pad)),
+      window: { oldest: rapport.oudste, newest: rapport.nieuwste },
+      missing: ['replies by this actor', 'posts from before the source followed', 'drafts'],
+    },
+  };
+  files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files, manifest, rapport };
+}
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/delivery.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,5 +41,5 @@
 // guardian on any instance receives it as a private mention (the ward
 // call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
+export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
   const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
           getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
@@ -83,6 +83,16 @@
     return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
   }).join('');
+  // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
+  // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
+  // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
+  // een leeggepoetste editor mag geen leeg bericht versturen.
+  const richClean = html ? deps.sanitizeHtml(String(html)) : '';
+  const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
   const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
+  // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
+  // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
+  const content = rich
+    ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
+    : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
   const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
   // Attachments: same rules as deliverReply (own /media/ uploads only,
@@ -99,4 +109,11 @@
   const row = getOutboxRow(id);
   const note = buildReplyNote(base, site, row);
+  // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
+  // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
+  // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
+  // mede-guardians er staat aan over.
+  if (helpMark && helpMark.noteUri) {
+    note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
+  }
   const create = {
     '@context': AP_CONTEXT,
Index: src/services/guardianship/follows.js
===================================================================
--- src/services/guardianship/follows.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/follows.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -81,6 +81,7 @@
     _r = {
       ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
-        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
-        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
+        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
+         direction, target_uri, target_handle, created_at)
+        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
       get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
       bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
@@ -91,7 +92,55 @@
 }
 
+/**
+ * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
+ *
+ * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
+ * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
+ * follower en staat het doel in het Follow-object -- die werd hiervoor
+ * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
+ */
 export function recordReview(guardianSlug, r) {
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
+  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
+  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
+    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
   return rstmts().get.get(guardianSlug, r.id);
+}
+
+/**
+ * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
+ * lezen (offers en outgoing-follows doen het net zo).
+ */
+export function reviewQueueItem(r, me, guardianCount) {
+  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
+  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
+  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
+  const stemmen = (() => {
+    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
+    catch { return []; }
+  })();
+  const uitgaand = r.direction === 'outgoing';
+  return {
+    id: r.id,
+    type: 'Follow',
+    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
+    actor: uitgaand ? r.ward_uri : r.follower_uri,
+    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
+    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
+    'shaer:ward': r.ward_uri,
+    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
+    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
+    'shaer:follower': uitgaand ? undefined : r.follower_uri,
+    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
+    'shaer:quorum': 'all',
+    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
+    'shaer:guardianCount': guardianCount || undefined,
+    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
+    published: r.created_at,
+  };
+}
+
+/** De openstaande reviews van een guardian, per richting. */
+export function listReviewsByDirection(guardianSlug, direction) {
+  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
 }
 export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
@@ -102,3 +151,4 @@
   recordPending, getPending, listForWard, decide, remove,
   recordReview, getReview, listReviews, removeReview,
+  listReviewsByDirection, reviewQueueItem,
 };
Index: src/services/guardianship/gated.js
===================================================================
--- src/services/guardianship/gated.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/gated.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -60,4 +60,64 @@
   'shaer:externalPlayback': 'external_playback',
 };
+/**
+ * De gates die deze Klonkt kent, met hun SOORT.
+ *
+ * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
+ * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
+ * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
+ * te zijn en geen nieuw stuk scherm.
+ *
+ * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
+ * een guardian niet hoeven raden:
+ *
+ *   setting     een stand, aan of uit, terug te draaien
+ *   perRequest  geen stand maar een stroom beslissingen (5.3 volgverzoeken)
+ *   handover    draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
+ *
+ * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
+ * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
+ */
+export const GATE_CATALOGUE = [
+  { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
+  { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
+  // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
+  // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
+  { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
+];
+
+/**
+ * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
+ * getoetst kunnen worden zonder database of scherm.
+ *
+ * @param settings       {feature: true|false|null} -- null is ONBEKEND, niet uit
+ * @param guardianCount  aantal guardians, of null als we het niet weten
+ * @param proposals      [{feature, value, status}] lopende voorstellen
+ * @param waiting        {feature: aantal} wat er per gate op een besluit wacht
+ */
+export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
+  return GATE_CATALOGUE.map((g) => {
+    const value = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
+    // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
+    // bij een ward elders kennen we de stand niet, en verbergen betekende daar
+    // ooit dat een voorstel nooit geopend kon worden.
+    const blockedBy = g.needs && settings[g.needs] === false ? g.needs : null;
+    return {
+      feature: g.feature,
+      kind: g.kind,
+      reversible: !!g.reversible,
+      value,
+      // Vast staat vast: tonen mag, verzetten niet.
+      adjustable: !g.fixed && !blockedBy,
+      blockedBy: blockedBy || undefined,
+      // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
+      // leest als een feit, en dit is precies waar een guardian op afgaat.
+      threshold: (guardianCount && guardianCount > 0)
+        ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
+      proposal: proposals.find((p) => p.feature === g.feature) || undefined,
+      waiting: waiting[g.feature] || undefined,
+    };
+  });
+}
+
 export function featureColumn(feature) {
   return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
@@ -239,4 +299,5 @@
 
 export default {
+  GATE_CATALOGUE, gateRows,
   tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
   parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
Index: src/services/guardianship/help.js
===================================================================
--- src/services/guardianship/help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/guardianship/help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,125 @@
+/**
+ * Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
+ *
+ * Een hulpvraag (FEP-633c 5.2.1) gaat naar ALLE guardians van een kind, die op
+ * verschillende servers zitten. Zonder gedeelde staat denken er twee dat de
+ * ander het oppakt -- en dat is precies het scenario waar de reddingsboei voor
+ * bestaat.
+ *
+ * DE FAALSTAND IS HIER NIET VEILIG, en dat maakt dit anders dan elke gate. Bij
+ * een gate is "dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt
+ * dat het geregeld is", en dat is gevaarlijker dan geen markering. Daaruit volgt
+ * de regel die overal in dit bestand terugkomt: bij twijfel is een hulpvraag
+ * OPEN.
+ *
+ * Twee besluiten van Bart (7-8) zitten in de vorm:
+ *
+ *   OPGEPIKT mag stapelen en vervalt niet, maar VEROUDERT zichtbaar. Twee mensen
+ *   die tegelijk reageren op een kind is geen probleem; twee die allebei niets
+ *   doen omdat de ander het "geclaimd" had, wel. En een signaal dat vanzelf
+ *   verdwijnt laat een hulpvraag er onaangeroerd uitzien terwijl er iemand mee
+ *   bezig is.
+ *
+ *   AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
+ *   bevestiging, en leeft de vraag daarna nog, dan wordt hij OPNIEUW GESTELD --
+ *   een nieuwe hulpvraag. Er wordt niets herschreven, er wordt toegevoegd.
+ */
+
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_help_state (note_uri, guardian_uri, kind, guardian_handle)
+                       VALUES (?,?,?,?)`),
+      forNote: db.prepare('SELECT * FROM ap_help_state WHERE note_uri = ? ORDER BY created_at ASC'),
+      forNotes: db.prepare('SELECT * FROM ap_help_state WHERE note_uri IN (SELECT value FROM json_each(?))'),
+    };
+  }
+  return _s;
+}
+
+/** Leg vast dat iemand deze hulpvraag heeft opgepikt of afgesloten. */
+export function record(noteUri, guardianUri, kind, handle = null) {
+  if (!noteUri || !guardianUri) return;
+  const k = kind === 'handled' ? 'handled' : 'pickup';
+  try { stmts().ins.run(noteUri, guardianUri, k, handle); } catch { /* nooit dragend */ }
+}
+
+/**
+ * De staat van een hulpvraag, uit zijn rijen. Puur, zodat de regels te toetsen
+ * zijn zonder database of scherm.
+ *
+ * `ageMs` is de leeftijd van de OUDSTE oppik. Daar tekent het scherm mee dat een
+ * signaal oud wordt -- niets verdwijnt, maar je ziet wel dat er misschien niets
+ * meer gebeurt.
+ */
+export function helpStatus(rows, now = Date.now()) {
+  const list = rows || [];
+  const pickups = list.filter((r) => r.kind === 'pickup');
+  const done = list.find((r) => r.kind === 'handled') || null;
+  const stamp = (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? null : t; };
+  const oudste = pickups.map(stamp).filter((t) => t !== null).sort((a, b) => a - b)[0];
+  return {
+    // Namen erbij: "door wie" was de hele vraag. Zonder dat is het een vinkje.
+    pickedUpBy: pickups.map((r) => ({ uri: r.guardian_uri, handle: r.guardian_handle || null, at: r.created_at })),
+    handled: done ? { uri: done.guardian_uri, handle: done.guardian_handle || null, at: done.created_at } : null,
+    // Alleen betekenisvol zolang er niets is afgesloten.
+    ageMs: (!done && oudste) ? Math.max(0, now - oudste) : null,
+    // Waar het scherm op afgaat. Bij twijfel OPEN: een lege lijst, een rij die we
+    // niet kunnen lezen, wat dan ook -- alles wat geen expliciete afsluiting is,
+    // is een hulpvraag die nog op iemand wacht.
+    open: !done,
+  };
+}
+
+/** De staat van een hulpvraag zoals die nu is opgeslagen. */
+export function statusOf(noteUri, now = Date.now()) {
+  if (!noteUri) return helpStatus([], now);
+  try { return helpStatus(stmts().forNote.all(noteUri), now); } catch { return helpStatus([], now); }
+}
+
+/** Idem voor een hele lijst in een query, zodat een paneel geen N+1 wordt. */
+export function statusFor(noteUris, now = Date.now()) {
+  const uit = new Map();
+  const lijst = [...new Set((noteUris || []).filter(Boolean))];
+  if (!lijst.length) return uit;
+  let rijen = [];
+  try { rijen = stmts().forNotes.all(JSON.stringify(lijst)); } catch { rijen = []; }
+  const perNote = new Map();
+  for (const r of rijen) {
+    if (!perNote.has(r.note_uri)) perNote.set(r.note_uri, []);
+    perNote.get(r.note_uri).push(r);
+  }
+  for (const uri of lijst) uit.set(uri, helpStatus(perNote.get(uri) || [], now));
+  return uit;
+}
+
+/**
+ * De markering als bericht. Bewust een gewone directe note met een
+ * shaer:-markering, zoals de zwaai en de afwezigheidsmelding: dan reist het over
+ * de bestaande bezorging, en de WARD leest het als wat het is -- er komt iemand.
+ */
+export function markerNote({ id, me, noteUri, kind, to }) {
+  const k = kind === 'handled' ? 'handled' : 'pickup';
+  return {
+    id, type: 'Note', attributedTo: me, to,
+    [k === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup']: noteUri,
+    content: k === 'handled'
+      ? '<p>Deze hulpvraag is afgehandeld.</p>'
+      : '<p>Ik kijk hiernaar.</p>',
+  };
+}
+
+/** Leest een binnengekomen note als markering, of null als hij er geen is. */
+export function parseMarker(object) {
+  if (!object || typeof object !== 'object') return null;
+  const pickup = object['shaer:helpPickup'];
+  const handled = object['shaer:helpHandled'];
+  if (typeof handled === 'string' && handled) return { kind: 'handled', noteUri: handled };
+  if (typeof pickup === 'string' && pickup) return { kind: 'pickup', noteUri: pickup };
+  return null;
+}
+
+export default { record, helpStatus, statusOf, statusFor, markerNote, parseMarker };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/index.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -32,2 +32,4 @@
 // §5.6 gated settings (decided by the guardians, enforced by the ward's server)
 export * as gated from './gated.js';
+// 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
+export * as help from './help.js';
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/queues.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -6,5 +6,5 @@
  *  - offers:  pending handshake offers where I am a party (§3), with the full
  *             accept tally so the client shows the right action
- *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
+ *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
  *  - wards:   my committed wards
  */
@@ -13,4 +13,5 @@
 import * as availability from './availability.js';
 import * as outgoing from './outgoing.js';
+import * as follows from './follows.js';
 import * as handshake from './handshake.js';
 
@@ -35,12 +36,57 @@
 }
 
-/** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
-export function followsCollection(id) {
-  return collection(id, []);
+/**
+ * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
+ * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
+ * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
+ *
+ * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
+ * op deze:
+ *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
+ *   - ap_pending_follows: een ward op deze instance
+ * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
+ */
+export function followsCollection(id, slug, me) {
+  const items = follows.listReviewsByDirection(slug, 'incoming')
+    .map((r) => follows.reviewQueueItem(r, me));
+  for (const w of relations.listWards(slug)) {
+    const wardSlug = slugOf(w.other_uri);
+    if (!wardSlug) continue;
+    for (const p of follows.listForWard(wardSlug)) {
+      items.push({
+        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
+        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
+        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
+        'shaer:quorum': p.quorum || 'any', published: p.created_at,
+      });
+    }
+  }
+  return collection(id, items);
 }
 
-/** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
+/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
+function slugOf(uri) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
+  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
+}
+
+/**
+ * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
+ * guardian wederzijds uitsluit: je bent het een of het ander.
+ *
+ *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
+ *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
+ *
+ * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
+ * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
+ * follow" kon nooit iets tonen.
+ */
 export function outgoingFollowsCollection(id, slug, me) {
-  return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
+  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
+  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
+    items.push(follows.reviewQueueItem(r, me));
+  }
+  return collection(id, items);
 }
 
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/i18n.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -67,5 +67,5 @@
     'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
     'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
-    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
+    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
     'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
     'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
@@ -1017,5 +1017,5 @@
     'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
     'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
-    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
+    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_unknown': 'unknown', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
     'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
     'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
@@ -1961,5 +1961,5 @@
     'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
     'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
-    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
+    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
     'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
     'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
