Changeset 952baf3 in Klonkt for src/services


Ignore:
Timestamp:
08/07/2026 05:15:52 PM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
ba76bf5
Parents:
f85b2c3 (diff), 0d5bd2c (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the (diff) links above to see all the changes relative to each parent.
Message:

Merge GitHub-main (1.7.0) met de VPS-lijn

De twee mains waren een dag gedivergeerd en bevatten elk echt werk. GitHub had 66
commits die nooit langs prutfolio.git zijn gekomen, omdat een parallelle sessie
rechtstreeks naar GitHub pushte vanaf een kloon in /tmp op de VPS. De VPS had twee
commits die GitHub niet had. Geen van beide bevatte de ander, en stable had geen van
de twee.

Bewust een merge en geen rebase: dan blijft beide historie intact en wordt er niets
herschreven waar iemand anders al op voortbouwt.

Drie bestanden raakten beide kanten. Alle drie zijn nagekeken, want dat een merge
automatisch slaagt zegt niets over of hij inhoudelijk klopt:

src/services/ActivityPubService.js

  • de sleutelbinding staat nu boven de nieuwe asSlug-aanroep van 1.7.0, dus de controle komt nog steeds voor de handtekeningcontrole

scripts/klonkt-refresh-updater.sh

  • alleen de opzij-aanpak overleefde; systemctl mask staat nergens meer als code

deploy/MULTI-INSTANCE.md

  • spreekt zichzelf niet tegen: beschrijft opzij zetten, met de reden waarom mask weigert

remarks: het gat dat in de review naar boven kwam staat hiermee ook op de 1.7.0-lijn.
De andere bevindingen uit die review staan nog open en zijn niet in deze merge
opgelost; die horen als beads. Ook nog te doen: dezelfde sleutelbinding op stable
als 1.6.1, want daar is het gat nog open bij self-hosters.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Location:
src/services
Files:
4 added
7 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rf85b2c3 r952baf3  
    627627// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
    628628// one grouped item (actors list + count) so activity doesn't drown out conversations.
     629/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
     630 *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
     631 *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
     632function outboxMediaJson(attachments) {
     633  if (!attachments) return null;
     634  try {
     635    const list = JSON.parse(attachments);
     636    if (!Array.isArray(list) || !list.length) return null;
     637    const media = list
     638      .filter((a) => a && a.url)
     639      .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
     640    return media.length ? JSON.stringify(media) : null;
     641  } catch { return null; }
     642}
     643
    629644export function getMessages(slug, limit, offset) {
    630645  const off = Math.max(0, offset || 0);
     
    638653    for (const m of listOutbox(slug).slice(0, need)) {
    639654      items.push({
    640         type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
    641         content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
     655        type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
     656        in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
     657        editable: m.editable, language: m.language, created_at: m.created_at,
     658        // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
     659        // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
     660        // media uit media_json met een `type`, terwijl ap_outbox ze als
     661        // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
     662        // een foto die JIJ meestuurde als kale tekst binnen.
     663        media_json: outboxMediaJson(m.attachments),
    642664      });
    643665    }
    644666  } catch { /* ignore */ }
     667  // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
     668  // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
     669  // zei alleen een slug, dus vullen we ze in één query aan.
     670  try {
     671    const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
     672    if (missing.length) {
     673      const rows = db.prepare(
     674        `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
     675           AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
     676      ).all(...missing, slug);
     677      const byslug = new Map(rows.map((r) => [r.slug, r.title]));
     678      for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
     679    }
     680  } catch { /* zonder titel valt de draad terug op de slug */ }
    645681  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
    646682  const out = [];
     
    656692    out.push(it);
    657693  }
    658   return out.slice(off, off + lim);
     694  // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
     695  // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
     696  // telt een draad als één item voor de paginering, wat klopt: je scrolt door
     697  // gesprekken, niet door losse zinnen.
     698  return groupConversations(out).slice(off, off + lim);
     699}
     700
     701// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
     702// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
     703// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
     704// moest dus tussen chips heen en weer, terwijl het één draad is.
     705const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
     706
     707/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
     708 *
     709 *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
     710 *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
     711 *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
     712 *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
     713 *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
     714 *     loopt per tegenpartij.
     715 *
     716 *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
     717 *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
     718 *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
     719 *  door.
     720 */
     721export function threadKey(it) {
     722  if (!it || !CONV_TYPES.has(it.type)) return null;
     723  if (it.post_slug) return `post:${it.post_slug}`;
     724  let who = it.handle || it.to_handle || '';
     725  // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
     726  // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
     727  // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
     728  // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
     729  if (!who && it.to_actors) {
     730    try {
     731      const first = JSON.parse(it.to_actors)[0];
     732      if (first) who = deriveHandle(first);
     733    } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
     734  }
     735  const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
     736  return norm ? `actor:${norm}` : null;
     737}
     738
     739/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
     740 *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
     741 *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
     742 *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
     743 *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
     744 */
     745export function groupConversations(items) {
     746  const threads = new Map();
     747  const out = [];
     748  for (const it of items || []) {
     749    const key = threadKey(it);
     750    if (!key) { out.push(it); continue; }
     751    let t = threads.get(key);
     752    if (!t) {
     753      // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
     754      // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
     755      t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
     756      threads.set(key, t);
     757      out.push(t);
     758    }
     759    t.messages.push(it);
     760    // De context bij de draad: gaat het over een post, dan hoort de link
     761    // erbij, anders is een los antwoord in een lijst niet te plaatsen.
     762    // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
     763    // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
     764    // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
     765    // dan kan het ontvangen antwoord eronder de echte titel niet meer
     766    // invullen. De terugval op de slug hoort in de weergave, niet in de data.
     767    if (it.post_slug) {
     768      if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
     769      else if (!t.post.title && it.post_title) t.post.title = it.post_title;
     770    }
     771  }
     772  for (const t of threads.values()) {
     773    t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
     774    t.count = t.messages.length;
     775    // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
     776    const seen = new Set();
     777    for (const m of t.messages) {
     778      if (m.type === 'sent') continue;
     779      const h = m.handle || m.name;
     780      if (!h || seen.has(h)) continue;
     781      seen.add(h);
     782      t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
     783    }
     784    // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
     785    // onbeantwoord bericht leest.
     786    t.mine = t.messages.some((m) => m.type === 'sent');
     787    // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
     788    // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
     789    // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
     790    // een direct bericht aan de tegenpartij.
     791    const inkomend = t.messages.filter((m) => m.type !== 'sent');
     792    const laatste = inkomend[inkomend.length - 1];
     793    t.replyTo = {
     794      interactionId: (laatste && laatste.interactionId) || null,
     795      postSlug: (t.post && t.post.slug) || null,
     796      actorUri: (laatste && (laatste.actorUri || laatste.url))
     797        || (t.messages.find((m) => m.to_actor) || {}).to_actor
     798        || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
     799    };
     800  }
     801  return out;
    659802}
    660803
     
    10431186  const siteIcon = (site && site.profile_photo) || null;
    10441187
     1188  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
     1189  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
     1190  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
     1191  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
     1192  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
     1193  const mijnSleutel = new Map();
     1194  for (const r of rows) {
     1195    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
     1196  }
     1197  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
     1198  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
     1199
    10451200  const nodes = [];
    10461201  for (const r of rows) {
    10471202    if (r.kind !== 'reply') continue;
     1203    const ik = mijnReactie(r.object_uri);
    10481204    nodes.push({
    10491205      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
     
    10521208      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
    10531209      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
    1054       acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
     1210      acted_boost: ik.boosted, acted_like: ik.liked,
    10551211      children: [],
    10561212    });
     
    11411297}
    11421298
    1143 export async function fetchActor(url) {
     1299export async function fetchActor(url, opts = {}) {
     1300  // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
     1301  // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
     1302  // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
     1303  // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
     1304  // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
     1305  // accounts eindeloos geweigerd, en precies die vier geven 401 op een
     1306  // onbetekende GET (shaer-afq).
     1307  //
     1308  // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
     1309  // actor-document op, en dat serveert Klonkt publiek.
     1310  //
     1311  // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
     1312  // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
     1313  // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
     1314  // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
     1315  // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
     1316  // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
     1317  // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
     1318  let doc = null;
    11441319  try {
    11451320    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
    1146     if (!r.ok) return null;
    1147     const len = Number(r.headers.get('content-length') || 0);
    1148     if (len > 2_000_000) return null; // refuse oversized actor docs
    1149     return await r.json();
    1150   } catch { return null; }
     1321    if (r.ok) {
     1322      const len = Number(r.headers.get('content-length') || 0);
     1323      if (len > 2_000_000) return null; // refuse oversized actor docs
     1324      doc = await r.json();
     1325    }
     1326  } catch { /* val door naar de ondertekende poging */ }
     1327  // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
     1328  // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
     1329  // telt als mislukt.
     1330  if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
     1331  if (!opts.asSlug) return doc;
     1332  const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
     1333  return (signed && signed.id) ? signed : doc;
    11511334}
    11521335
     
    12251408}
    12261409
     1410/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
     1411 *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
     1412let _signSlug;
     1413function anySigningSlug() {
     1414  if (_signSlug !== undefined) return _signSlug;
     1415  try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
     1416  catch { _signSlug = null; }
     1417  return _signSlug;
     1418}
     1419
    12271420// Best-effort verification of an incoming signed request. Returns the sender's
    12281421// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     
    12301423// federating servers with drifting clocks; an operator can widen it via env.
    12311424const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
    1232 export async function verifyRequest(req) {
     1425export async function verifyRequest(req, asSlug = null) {
    12331426  const sigH = req.headers['signature'];
    12341427  if (!sigH) return null;
    12351428  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
    12361429  if (!p.keyId || !p.signature) return null;
    1237   const actor = await fetchActor(p.keyId.split('#')[0]);
     1430  // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
     1431  // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
     1432  // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
     1433  // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
     1434  const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
    12381435  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
    12391436  if (!pem) return null;
     
    14951692}
    14961693
     1694/**
     1695 * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
     1696 *
     1697 * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
     1698 * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
     1699 * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
     1700 * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
     1701 * binnen, zonder dat iemand een fout zag.
     1702 *
     1703 * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
     1704 * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
     1705 * bezorgde inhoud NIET en halen het object op bij de bron.
     1706 *
     1707 * Vier voorwaarden, en geen ervan is optioneel:
     1708 *
     1709 *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
     1710 *     dereferencen -- het object is weg -- dus die blijft geweigerd.
     1711 *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
     1712 *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
     1713 *     attributedTo alles kan beweren.
     1714 *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
     1715 *     levert een doorsturer een echt id met verdraaide inhoud.
     1716 *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
     1717 *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
     1718 */
     1719/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
     1720 *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
     1721 *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
     1722 *  doorstuurt; iets anders is dat niet. */
     1723function knownNoteUri(uri) {
     1724  if (!uri || typeof uri !== 'string') return false;
     1725  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     1726  try {
     1727    if (base && uri.startsWith(`${base}/ap/notes/`)) {
     1728      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
     1729      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
     1730      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
     1731    }
     1732    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
     1733    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
     1734    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
     1735    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
     1736  } catch { /* bij twijfel niet ophalen */ }
     1737  return false;
     1738}
     1739
     1740/**
     1741 * Onthoud dat we dit bericht al eens bezorgd kregen.
     1742 *
     1743 * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
     1744 * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
     1745 * stelt voordat er iets bij de bron wordt opgehaald.
     1746 *
     1747 * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
     1748 * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
     1749 * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
     1750 * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
     1751 * schrijvers die je zelf volgt.
     1752 */
     1753const SEEN_NOTES_DAYS = 30;
     1754let _seenSinceSnoei = 0;
     1755function rememberNoteUri(uri) {
     1756  if (!uri || typeof uri !== 'string') return;
     1757  try {
     1758    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
     1759    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
     1760    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
     1761    // ouder is dan een maand beantwoordt geen enkele vraag meer.
     1762    if (++_seenSinceSnoei >= 500) {
     1763      _seenSinceSnoei = 0;
     1764      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
     1765      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
     1766    }
     1767  } catch { /* niet fataal */ }
     1768}
     1769const isFollowedActor = (uri) => {
     1770  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
     1771};
     1772
     1773// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
     1774// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
     1775// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
     1776const _derefMiss = new Map();
     1777const DEREF_MISS_MS = 30 * 60 * 1000;
     1778function derefRecentlyFailed(uri) {
     1779  const t = _derefMiss.get(uri);
     1780  if (t === undefined) return false;
     1781  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
     1782  return true;
     1783}
     1784function noteDerefFailure(uri) {
     1785  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
     1786    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
     1787    for (const [k] of oud) _derefMiss.delete(k);
     1788  }
     1789  _derefMiss.set(uri, Date.now());
     1790}
     1791
     1792async function dereferenceForwarded(act, claimedActor, type, slugParam) {
     1793  // Every exit states its reason. Five of the six used to return silently, so a
     1794  // rejection count could not be told apart from a narrowing that closed too far
     1795  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
     1796  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
     1797  const skipped = (reason, detail) => {
     1798    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
     1799    return null;
     1800  };
     1801  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
     1802  const o = act && act.object;
     1803  const objId = typeof o === 'string' ? o : (o && o.id);
     1804  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
     1805  try {
     1806    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
     1807  } catch { return skipped('unparsable id', objId); }
     1808  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
     1809  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
     1810  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
     1811  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
     1812  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
     1813  // niets aan legitiem verkeer waarvan we de ouder kennen.
     1814  const parent = typeof o === 'object' && o
     1815    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
     1816    : null;
     1817  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
     1818  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
     1819  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
     1820  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
     1821  // reden als bij fetchActor sinds efe5633.
     1822  let fetched = await apGetJson(objId).catch(() => null);
     1823  if (!fetched || fetched.id !== objId) {
     1824    // The signer used to be slugParam, which is null on the shared inbox — and
     1825    // that is where forwarded traffic lands, because we advertise a sharedInbox.
     1826    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
     1827    // secure mode could never be dereferenced at all. Same fix verifyRequest got
     1828    // in shaer-afq: any local actor is a valid signer.
     1829    const asSlug = slugParam || anySigningSlug();
     1830    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
     1831  }
     1832  const attributed = fetched && (typeof fetched.attributedTo === 'string'
     1833    ? fetched.attributedTo
     1834    : (fetched.attributedTo && fetched.attributedTo.id));
     1835  if (!fetched || fetched.id !== objId) {
     1836    noteDerefFailure(objId);
     1837    return skipped('fetch failed', objId);
     1838  }
     1839  if (attributed !== claimedActor) {
     1840    // Not a transport hiccup: the source itself says someone else wrote this.
     1841    noteDerefFailure(objId);
     1842    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
     1843  }
     1844  return fetched;
     1845}
     1846
    14971847// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    14981848export async function handleInbox(req, slugParam, preVerified = null) {
     
    15081858  // keeps everything below identical, including the actor-versus-signer check,
    15091859  // which is exactly the check that must not be skipped for being local.
    1510   const verified = preVerified || await verifyRequest(req).catch(() => null);
     1860  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
    15111861
    15121862  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
     
    15181868  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
    15191869  if (GATED.includes(type)) {
    1520     if (!verified || !claimedActor || verified.id !== claimedActor) {
    1521       console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
     1870    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
     1871    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
     1872    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
     1873    let forwarded = null;
     1874    if (verified && claimedActor && verified.id !== claimedActor) {
     1875      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
     1876      if (forwarded) {
     1877        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
     1878        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
     1879      }
     1880    }
     1881    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
     1882      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
     1883      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
     1884      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
     1885      // of geldig ondertekend door iemand anders.
     1886      const reden = verified ? '(signer mismatch)'
     1887        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
     1888        : '(no signature)';
     1889      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
    15221890      return 401;
    15231891    }
     
    18092177      }
    18102178    }
     2179    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
     2180    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
     2181    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
     2182    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
     2183    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
     2184    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
     2185    // moet iemand zijn die je zelf bent gaan volgen.
     2186    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
     2187      rememberNoteUri(o.id);
     2188    }
    18112189    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
    18122190    // above): store a mention notification for each of our actors named in the Mention tags.
    18132191    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
    18142192    // someone else's actor, not ours.
     2193    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
     2194    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
     2195    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
     2196    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
     2197    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
     2198    if (actorUri && !isLocalActor) {
     2199      const mark = Guardianship.help.parseMarker(o);
     2200      if (mark) {
     2201        const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
     2202        Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
     2203        console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
     2204      }
     2205    }
    18152206    if (actorUri && !isLocalActor && o.id) {
    18162207      const slugs = localMentionSlugs(o.tag, base);
     
    24972888        const kind = type === 'Announce' ? 'boost' : 'like';
    24982889        await sendInteraction(site, kind, objUri, authorUri);
    2499         setMyReaction(site.slug, targetUri, kind, true);
    2500         if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
     2890        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
     2891        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
     2892        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
     2893        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
     2894        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
     2895        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
     2896        // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
     2897        // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
     2898        // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
     2899        // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
     2900        // Anders dan bij een boost geen upsert: een like hoort een post niet
     2901        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
     2902        // terecht een no-op.
    25012903        return { status: 202, url: objUri };
    25022904      }
     
    25472949          const objUri = (note && note.object_uri) || innerTarget;
    25482950          await sendInteraction(site, kind, objUri, note && note.actor_uri);
    2549           setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
    2550           if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
     2951          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
     2952          catch { /* non-fatal */ }
    25512953          return { status: 202, url: objUri };
    25522954        }
     
    29133315}
    29143316export function listOutbox(siteSlug) {
    2915   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')
     3317  // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
     3318  // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
     3319  // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
     3320  // elkaar in "Verzonden" en "Gesprekken".
     3321  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')
    29163322    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
    29173323}
     
    30513457  return { ins: _insTl, list: _listTl, del: _delTl };
    30523458}
    3053 export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
     3459/**
     3460 * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
     3461 *
     3462 * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
     3463 * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
     3464 * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
     3465 * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
     3466 * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
     3467 *
     3468 * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
     3469 * heten en hangt het van de driver af welke wint. Eén extra query per pagina
     3470 * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
     3471 */
     3472export function getTimeline(slug, limit, offset) {
     3473  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
     3474  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
     3475  for (const r of rows) {
     3476    const x = reacties.get(r.id);
     3477    r.liked = !!(x && x.liked);
     3478    r.boosted = !!(x && x.boosted);
     3479  }
     3480  return rows;
     3481}
    30543482
    30553483/**
     
    30873515}
    30883516
     3517/**
     3518 * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
     3519 * (shaer-n05).
     3520 *
     3521 * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
     3522 * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
     3523 * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
     3524 * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
     3525 *
     3526 * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
     3527 * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
     3528 * andere server niet te vertrouwen is.
     3529 *
     3530 * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
     3531 * mee; de vorm mag veranderen zonder dat dat iets breekt.
     3532 */
     3533export function feedCursor(slug) {
     3534  try {
     3535    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
     3536    return String((r && r.n) || 0);
     3537  } catch { return '0'; }
     3538}
     3539
     3540/**
     3541 * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
     3542 * bewerkt, of weg.
     3543 *
     3544 * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
     3545 * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
     3546 * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
     3547 * plek waar ze samen te lezen zijn.
     3548 */
     3549export function feedChangesSince(slug, rev, limit = 200) {
     3550  try {
     3551    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
     3552                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
     3553      .all(slug, parseInt(rev, 10) || 0, limit);
     3554  } catch { return []; }
     3555}
     3556
     3557// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
     3558// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
     3559// krijgen gewoon meteen antwoord in plaats van een fout.
     3560const FEED_WAIT_MAX = 4;
     3561const _wachters = new Map();
     3562
     3563/**
     3564 * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
     3565 *
     3566 * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
     3567 * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
     3568 * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
     3569 * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
     3570 * vertraging.
     3571 */
     3572export async function waitForFeedChange(slug, opts = {}) {
     3573  const tickMs = Math.max(50, opts.tickMs || 1000);
     3574  const waitMs = Math.max(0, opts.waitMs || 0);
     3575  const since = String(opts.since || '');
     3576  let cursor = feedCursor(slug);
     3577  // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
     3578  if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
     3579
     3580  const bezet = _wachters.get(slug) || 0;
     3581  if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
     3582  _wachters.set(slug, bezet + 1);
     3583  try {
     3584    const einde = Date.now() + waitMs;
     3585    while (Date.now() < einde) {
     3586      if (opts.signal && opts.signal.aborted) break;   // client hing op
     3587      const rest = Math.min(tickMs, einde - Date.now());
     3588      await new Promise((r) => setTimeout(r, rest));
     3589      cursor = feedCursor(slug);
     3590      if (cursor !== since) return { cursor, changed: true, waited: true };
     3591    }
     3592    return { cursor, changed: false, waited: true };
     3593  } finally {
     3594    const n = (_wachters.get(slug) || 1) - 1;
     3595    if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
     3596  }
     3597}
     3598
    30893599export function getDirectMessages(slug, limit) {
    30903600  try {
     
    32433753    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
    32443754      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
    3245              t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
     3755             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
     3756             (rb.target_uri IS NOT NULL) AS boosted
    32463757      FROM ap_timeline t
    32473758      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
    3248       WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
     3759      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
     3760      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
     3761      -- deze join kan geen rijen verdubbelen.
     3762      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
     3763      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
    32493764      ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
    32503765      LIMIT ? OFFSET ?`);
     
    32553770  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 []; }
    32563771}
    3257 // Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
     3772// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
     3773// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
     3774// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
     3775// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
     3776//
     3777// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
     3778// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
     3779// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
     3780// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
     3781// terugdraaien een code-revert blijft in plaats van dataherstel.
    32583782let _markBoost, _unmarkBoost, _boostedCount;
    32593783export function markBoosted(slug, noteId) {
     
    32703794  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
    32713795}
     3796/**
     3797 * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
     3798 * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
     3799 * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
     3800 * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
     3801 * dat leverde de shaer:liked-bug op (04aca12).
     3802 *
     3803 * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
     3804 * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
     3805 * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
     3806 * boost nergens -- ook niet in de Cirkel.
     3807 *
     3808 * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
     3809 * worden: de tussentabel op de URI die de client stuurde, de vlag op de
     3810 * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
     3811 * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
     3812 * werk voor fase 2, mét datamigratie.
     3813 */
     3814// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
     3815// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
     3816// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
     3817// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
     3818// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
     3819// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
     3820const REACTIONS_MIGRATION_VERSION = 2;
     3821
     3822/**
     3823 * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
     3824 *
     3825 * Twee stappen, en ze zijn allebei nodig:
     3826 *
     3827 *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
     3828 *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
     3829 *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
     3830 *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
     3831 *     wanneer je reageerde, bij aanvullen niet.
     3832 *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
     3833 *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
     3834 *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
     3835 *     fediverse in als gevolg.
     3836 *
     3837 * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
     3838 */
     3839export function migrateReactions(opts = {}) {
     3840  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
     3841  try {
     3842    if (!opts.force) {
     3843      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
     3844      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
     3845      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
     3846    }
     3847  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
     3848
     3849  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
     3850  const wees = `
     3851    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
     3852     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
     3853  const scheef = (kind, kolom) => `
     3854    FROM ap_timeline t
     3855     WHERE t.${kolom} = 1
     3856       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
     3857                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
     3858  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
     3859  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
     3860  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
     3861  //    dus die uitsluiting verliest per constructie niets.
     3862  const acted = (kind, kolom) => `
     3863    FROM ap_interactions i
     3864     JOIN posts p ON p.id = i.post_id
     3865     JOIN sites s ON s.id = p.site_id
     3866     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
     3867       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
     3868                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
     3869
     3870  if (opts.dryRun) {
     3871    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
     3872    uit.hersleuteld = tel(wees);
     3873    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
     3874    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
     3875    return uit;
     3876  }
     3877
     3878  try {
     3879    db.transaction(() => {
     3880      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
     3881      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
     3882      //    oplevert en nooit een verdwenen reactie.
     3883      uit.hersleuteld = db.prepare(`
     3884        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
     3885        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
     3886      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
     3887
     3888      // 2. Aanvullen vanuit de kolommen.
     3889      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
     3890        uit.aangevuld += db.prepare(`
     3891          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
     3892          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
     3893      }
     3894
     3895      // 3. En vanuit acted_* op de reacties onder je eigen posts.
     3896      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
     3897        uit.reacties += db.prepare(`
     3898          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
     3899          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
     3900      }
     3901    })();
     3902    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
     3903      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
     3904    }
     3905    if (!opts.force) {
     3906      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
     3907        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
     3908    }
     3909  } catch (e) {
     3910    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
     3911    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
     3912    console.warn('[AP] reaction migration failed:', e.message);
     3913  }
     3914  return uit;
     3915}
     3916
     3917/**
     3918 * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
     3919 *
     3920 * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
     3921 * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
     3922 * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
     3923 * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
     3924 * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
     3925 *
     3926 * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
     3927 * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
     3928 * highlight. Vandaar hier, en niet later.
     3929 *
     3930 * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
     3931 * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
     3932 * een reactie op iets buiten je tijdlijn moet gewoon werken.
     3933 */
     3934export function canonicalReactionUri(slug, uri) {
     3935  if (!slug || !uri) return uri;
     3936  try {
     3937    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
     3938    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
     3939    return (row && row.id) || uri;
     3940  } catch { return uri; }
     3941}
     3942
     3943/**
     3944 * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
     3945 * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
     3946 * dezelfde vraag beantwoordden uit twee verschillende bronnen.
     3947 */
     3948export function getReaction(slug, uri) {
     3949  try {
     3950    const key = canonicalReactionUri(slug, uri);
     3951    const rows = (slug && key)
     3952      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
     3953      : [];
     3954    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
     3955  } catch { return { liked: false, boosted: false }; }
     3956}
     3957
     3958/**
     3959 * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
     3960 * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
     3961 * een consistentiebug geruild voor een traagheidsbug.
     3962 */
     3963export function getReactionsFor(slug, uris) {
     3964  const out = new Map();
     3965  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
     3966  if (!slug || !list.length) return out;
     3967  try {
     3968    const rows = db.prepare(
     3969      `SELECT target_uri, kind FROM ap_my_reactions
     3970        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
     3971    ).all(slug, ...list);
     3972    for (const r of rows) {
     3973      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
     3974      if (r.kind === 'like') cur.liked = true;
     3975      if (r.kind === 'boost') cur.boosted = true;
     3976      out.set(r.target_uri, cur);
     3977    }
     3978  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
     3979  return out;
     3980}
     3981
     3982export function setReaction(slug, uri, kind, on, opts = {}) {
     3983  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
     3984  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
     3985  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
     3986  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
     3987  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
     3988  // maakte dezelfde like onvindbaar vanaf de andere pagina.
     3989  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
     3990  setMyReaction(slug, flagUri, kind, !!on);
     3991  if (kind === 'boost') {
     3992    if (!on) unmarkBoosted(slug, flagUri);
     3993    else if (opts.note) upsertBoostedNote(slug, opts.note);
     3994    else markBoosted(slug, flagUri);
     3995  } else if (on) markLiked(slug, flagUri);
     3996  else unmarkLiked(slug, flagUri);
     3997}
     3998
    32723999export function getTimelineReaction(slug, noteId) {
    32734000  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 }; }
     
    33034030}
    33044031export function boostedCount(slug) {
    3305   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; }
     4032  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
     4033  // bevatten van iets dat er (nog) niet in staat.
     4034  try {
     4035    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
     4036      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
     4037      WHERE r.site_slug = ? AND r.kind = 'boost'`);
     4038    return _boostedCount.get(slug).n;
     4039  } catch { return 0; }
    33064040}
    33074041
     
    40184752    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
    40194753  }
    4020   console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
     4754  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
    40214755  return { ok: true, target: target.id, inboxes: inboxes.length };
    40224756}
     
    41984932      const wardDoc = await fetchActor(wardUri).catch(() => null);
    41994933      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
    4200       Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
     4934      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
     4935      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
     4936      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
     4937      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
     4938      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
     4939      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
     4940      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
     4941      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
     4942      Guardianship.follows.recordReview(gslug, {
     4943        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
     4944        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
     4945        direction: uitgaand ? 'outgoing' : 'incoming',
     4946        target: doel || null, targetHandle: dai ? dai.handle : null,
     4947      });
    42014948      const L = pushLang(gslug);
    42024949      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` });
     
    43015048  try {
    43025049    const rows = db.prepare(`
    4303       SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
     5050      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,
    43045051             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
    43055052             p.slug AS post_slug, p.title AS post_title
     
    43105057    for (const r of rows) out.push({
    43115058      type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
     5059      // Waar een antwoord uit de draad heen moet: het id is de parent voor
     5060      // deliverReply, de uri het adres voor een direct bericht.
     5061      interactionId: r.interaction_id, actorUri: r.actor_uri,
    43125062      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
    43135063      // When the post was written, for display. created_at (when it reached us)
     
    45435293  getOutboxRow: (id) => iStmts().getO.get(id),
    45445294  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
     5295  // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
     5296  // een antwoord uit Berichten door precies één poort gaat.
     5297  sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
     5298  htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
    45455299});
    45465300/**
     
    45795333  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
    45805334  // own Berichten; an existing guardian and a commit land in the PWA.
     5335  //
     5336  // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
     5337  // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
    45815338  onEvent: (slug, ev) => {
    4582     const L = pushLang(slug);
    4583     const texts = {
    4584       offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
    4585       offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
    4586       committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
    4587       // §3.2: a guardian ended the relation. The ward hears that someone who
    4588       // was looking after them has gone; a co-guardian hears they are one fewer.
    4589       guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
    4590       coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
    4591     }[ev.kind];
    4592     if (!texts) return;
    4593     const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
    4594     const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
    4595     pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
     5339    const p = guardianEventPush(slug, ev);
     5340    if (p) pushEvent(slug, p);
    45965341  },
    45975342});
     5343
     5344/**
     5345 * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
     5346 *
     5347 * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
     5348 * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
     5349 * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
     5350 * erbij te halen.
     5351 */
     5352export function guardianEventPush(slug, ev) {
     5353  const L = pushLang(slug);
     5354  const texts = {
     5355    offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
     5356    offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
     5357    committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
     5358    // §3.2: a guardian ended the relation. The ward hears that someone who
     5359    // was looking after them has gone; a co-guardian hears they are one fewer.
     5360    guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
     5361    coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
     5362    // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
     5363    // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
     5364    // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
     5365    // geen drempel.
     5366    gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
     5367    gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
     5368  }[ev.kind];
     5369  if (!texts) return null;
     5370  const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
     5371  // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
     5372  // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
     5373  const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
     5374  const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
     5375  const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
     5376  const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
     5377  return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
     5378}
     5379
     5380// Van een gated feature naar het woord dat het Guardian-paneel er al voor
     5381// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
     5382// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
     5383const GATE_LABEL = {
     5384  'shaer:externalEmbeds': 'guardian.prop_embeds',
     5385  'shaer:externalPlayback': 'guardian.prop_play',
     5386};
     5387const GATE_OUTCOME = {
     5388  accepted: 'guardian.prop_st_accepted',
     5389  rejected: 'guardian.prop_st_rejected',
     5390  expired: 'guardian.prop_st_expired',
     5391};
    45985392
    45995393// The notification duty of FEP-633c 3.6.2, wired once for every place a
     
    46255419  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    46265420  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    4627   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
     5421  feedCursor, feedChangesSince, waitForFeedChange,
     5422  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
    46285423  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    46295424  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     
    46315426  gateOutgoingFollow, performApprovedFollow,
    46325427  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    4633   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
     5428  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    46345429  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    46355430  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
  • src/services/guardianship/delivery.js

    rf85b2c3 r952baf3  
    4141// guardian on any instance receives it as a private mention (the ward
    4242// call-for-help path).
    43 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
     43export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
    4444  const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
    4545          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
     
    8383    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
    8484  }).join('');
     85  // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
     86  // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
     87  // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
     88  // een leeggepoetste editor mag geen leeg bericht versturen.
     89  const richClean = html ? deps.sanitizeHtml(String(html)) : '';
     90  const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
    8591  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
    86   const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
     92  // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
     93  // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
     94  const content = rich
     95    ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
     96    : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
    8797  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
    8898  // Attachments: same rules as deliverReply (own /media/ uploads only,
     
    99109  const row = getOutboxRow(id);
    100110  const note = buildReplyNote(base, site, row);
     111  // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
     112  // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
     113  // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
     114  // mede-guardians er staat aan over.
     115  if (helpMark && helpMark.noteUri) {
     116    note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
     117  }
    101118  const create = {
    102119    '@context': AP_CONTEXT,
  • src/services/guardianship/follows.js

    rf85b2c3 r952baf3  
    8181    _r = {
    8282      ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
    83         (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
    84         VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
     83        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
     84         direction, target_uri, target_handle, created_at)
     85        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
    8586      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
    8687      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
     
    9192}
    9293
     94/**
     95 * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
     96 *
     97 * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
     98 * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
     99 * follower en staat het doel in het Follow-object -- die werd hiervoor
     100 * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
     101 */
    93102export function recordReview(guardianSlug, r) {
    94   rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
     103  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
     104  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
     105    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
    95106  return rstmts().get.get(guardianSlug, r.id);
     107}
     108
     109/**
     110 * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
     111 * lezen (offers en outgoing-follows doen het net zo).
     112 */
     113export function reviewQueueItem(r, me, guardianCount) {
     114  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
     115  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
     116  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
     117  const stemmen = (() => {
     118    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
     119    catch { return []; }
     120  })();
     121  const uitgaand = r.direction === 'outgoing';
     122  return {
     123    id: r.id,
     124    type: 'Follow',
     125    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
     126    actor: uitgaand ? r.ward_uri : r.follower_uri,
     127    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
     128    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
     129    'shaer:ward': r.ward_uri,
     130    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
     131    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
     132    'shaer:follower': uitgaand ? undefined : r.follower_uri,
     133    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
     134    'shaer:quorum': 'all',
     135    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
     136    'shaer:guardianCount': guardianCount || undefined,
     137    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
     138    published: r.created_at,
     139  };
     140}
     141
     142/** De openstaande reviews van een guardian, per richting. */
     143export function listReviewsByDirection(guardianSlug, direction) {
     144  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
    96145}
    97146export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
     
    102151  recordPending, getPending, listForWard, decide, remove,
    103152  recordReview, getReview, listReviews, removeReview,
     153  listReviewsByDirection, reviewQueueItem,
    104154};
  • src/services/guardianship/gated.js

    rf85b2c3 r952baf3  
    6060  'shaer:externalPlayback': 'external_playback',
    6161};
     62/**
     63 * De gates die deze Klonkt kent, met hun SOORT.
     64 *
     65 * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
     66 * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
     67 * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
     68 * te zijn en geen nieuw stuk scherm.
     69 *
     70 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
     71 * een guardian niet hoeven raden:
     72 *
     73 *   setting     een stand, aan of uit, terug te draaien
     74 *   perRequest  geen stand maar een stroom beslissingen (5.3 volgverzoeken)
     75 *   handover    draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
     76 *
     77 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
     78 * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
     79 */
     80export const GATE_CATALOGUE = [
     81  { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
     82  { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
     83  // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
     84  // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
     85  { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
     86];
     87
     88/**
     89 * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
     90 * getoetst kunnen worden zonder database of scherm.
     91 *
     92 * @param settings       {feature: true|false|null} -- null is ONBEKEND, niet uit
     93 * @param guardianCount  aantal guardians, of null als we het niet weten
     94 * @param proposals      [{feature, value, status}] lopende voorstellen
     95 * @param waiting        {feature: aantal} wat er per gate op een besluit wacht
     96 */
     97export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
     98  return GATE_CATALOGUE.map((g) => {
     99    const value = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
     100    // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
     101    // bij een ward elders kennen we de stand niet, en verbergen betekende daar
     102    // ooit dat een voorstel nooit geopend kon worden.
     103    const blockedBy = g.needs && settings[g.needs] === false ? g.needs : null;
     104    return {
     105      feature: g.feature,
     106      kind: g.kind,
     107      reversible: !!g.reversible,
     108      value,
     109      // Vast staat vast: tonen mag, verzetten niet.
     110      adjustable: !g.fixed && !blockedBy,
     111      blockedBy: blockedBy || undefined,
     112      // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
     113      // leest als een feit, en dit is precies waar een guardian op afgaat.
     114      threshold: (guardianCount && guardianCount > 0)
     115        ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
     116      proposal: proposals.find((p) => p.feature === g.feature) || undefined,
     117      waiting: waiting[g.feature] || undefined,
     118    };
     119  });
     120}
     121
    62122export function featureColumn(feature) {
    63123  return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
     
    239299
    240300export default {
     301  GATE_CATALOGUE, gateRows,
    241302  tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
    242303  parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
  • src/services/guardianship/index.js

    rf85b2c3 r952baf3  
    3232// §5.6 gated settings (decided by the guardians, enforced by the ward's server)
    3333export * as gated from './gated.js';
     34// 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
     35export * as help from './help.js';
  • src/services/guardianship/queues.js

    rf85b2c3 r952baf3  
    66 *  - offers:  pending handshake offers where I am a party (§3), with the full
    77 *             accept tally so the client shows the right action
    8  *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
     8 *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
    99 *  - wards:   my committed wards
    1010 */
     
    1313import * as availability from './availability.js';
    1414import * as outgoing from './outgoing.js';
     15import * as follows from './follows.js';
    1516import * as handshake from './handshake.js';
    1617
     
    3536}
    3637
    37 /** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
    38 export function followsCollection(id) {
    39   return collection(id, []);
     38/**
     39 * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
     40 * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
     41 * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
     42 *
     43 * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
     44 * op deze:
     45 *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
     46 *   - ap_pending_follows: een ward op deze instance
     47 * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
     48 */
     49export function followsCollection(id, slug, me) {
     50  const items = follows.listReviewsByDirection(slug, 'incoming')
     51    .map((r) => follows.reviewQueueItem(r, me));
     52  for (const w of relations.listWards(slug)) {
     53    const wardSlug = slugOf(w.other_uri);
     54    if (!wardSlug) continue;
     55    for (const p of follows.listForWard(wardSlug)) {
     56      items.push({
     57        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
     58        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
     59        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
     60        'shaer:quorum': p.quorum || 'any', published: p.created_at,
     61      });
     62    }
     63  }
     64  return collection(id, items);
    4065}
    4166
    42 /** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
     67/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
     68function slugOf(uri) {
     69  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     70  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
     71  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
     72}
     73
     74/**
     75 * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
     76 * guardian wederzijds uitsluit: je bent het een of het ander.
     77 *
     78 *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
     79 *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
     80 *
     81 * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
     82 * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
     83 * follow" kon nooit iets tonen.
     84 */
    4385export function outgoingFollowsCollection(id, slug, me) {
    44   return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
     86  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
     87  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
     88    items.push(follows.reviewQueueItem(r, me));
     89  }
     90  return collection(id, items);
    4591}
    4692
  • src/services/i18n.js

    rf85b2c3 r952baf3  
    6767    'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
    6868    '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',
    69     '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.',
     69    '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.',
    7070    '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',
    7171    '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.',
     
    10171017    'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
    10181018    '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',
    1019     '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.',
     1019    '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.',
    10201020    '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',
    10211021    '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.',
     
    19611961    'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
    19621962    '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',
    1963     '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.',
     1963    '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.',
    19641964    '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',
    19651965    '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.',
Note: See TracChangeset for help on using the changeset viewer.