Changeset f0a33e1 in Klonkt


Ignore:
Timestamp:
08/10/2026 03:32:22 PM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
41de7cb
Parents:
3bbf73d
Message:

De wachtende lezing antwoordt met het verschil, als je erom vraagt

shaer-pq4, tweede helft van stap 1. Samentrekken van de twee waiters bespaart
het RONDJE; dit bespaart de VRACHT. Beide paden eindigden tot nu toe in een
volledige inboxlezing -- vier legs van zestig met al hun media-, quote- en
embed-JSON -- voor een enkel nieuw bericht.

Het verschil lag al klaar en werd nooit uitgedeeld: ap_feed_state houdt per
object bij wat er wanneer veranderde, en feedChangesSince had geen enkele
aanroeper. Nu wel, achter ?changes=1.

OPT-IN, en dat is geen franje: een app in het veld stuurt since al mee en
vervangt haar hele feed door wat er terugkomt. Zou since opeens een verschil
betekenen, dan wist die app zichzelf leeg. Een toets bewaakt dat de oude lezing
zonder changes=1 exact hetzelfde blijft.

Het antwoord is een OrderedCollectionPage met partOf, want dat is wat het IS.
Een generieke lezer ziet dat verschil ook.

GRAFSTENEN GAAN MEE. Een verwijdering reisde tot nu toe als AFWEZIGHEID: de
volledige lezing bevatte hem simpelweg niet meer. Juist die volledigheid valt
hier weg, dus zonder Tombstone zou een weggehaalde post voor altijd in de app
blijven staan -- en dat faalt stil. Bart noemde de voorwaarde terecht niet
hard, maar de rijen lagen er al (kind='deleted') en AS2 heeft er een vorm voor.

Om de kaartvorm niet twee keer te beschrijven zijn ook de tijdlijn- en
antwoordvorm uit de handler gehesen (timelineItem/replyItem), net als eerder
messageItem/sentItem. Zelfde gedrag, 855/855 groen.

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

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r3bbf73d rf0a33e1  
    283283});
    284284
    285 // ── De poorten van een lezer, op EEN plek (FEP-633c) ─────────────
     285/** De byline-gegevens uit een tijdlijnrij, langs de emoji-poort. */
     286function authorInfoFrom(r, prefix, gates) {
     287  const info = {
     288    name: r[`${prefix}name`] || undefined, handle: r[`${prefix}handle`] || undefined,
     289    icon: r[`${prefix}icon`] || undefined, url: r[`${prefix}url`] || undefined,
     290    emojis: (() => { try { return r[`${prefix}emoji_json`] ? JSON.parse(r[`${prefix}emoji_json`]) : undefined; } catch { return undefined; } })(),
     291  };
     292  return (info.name || info.handle || info.icon) ? gates.gateAuthor(info) : undefined;
     293}
     294
     295// ── Een tijdlijnpost als AS2-item: EEN beschrijving van de kaartvorm ──
    286296//
    287 // De inbox-lezing rekende ze inline uit. Nu er meer lezingen zijn die
    288 // dezelfde poorten moeten eerbiedigen (de gesprekken, de geschiedenis), zou
    289 // dat evenveel kopieen worden -- en een poort die op een van die plekken
    290 // vergeten wordt, levert stil iets uit dat dicht hoorde te staan.
    291 function gatesFor(site) {
    292   const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
    293   const embeds = Guardianship.externalEmbedsAllowed(site.external_embeds, isWard);
    294   const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
    295   const emoji = gate('gate_custom_emoji');
    296   return {
    297     isWard,
    298     embedsAllowed: embeds,
    299     playbackAllowed: embeds && Guardianship.externalPlaybackAllowed(site.external_playback, isWard),
    300     imagesAllowed: gate('gate_images'),
    301     musicAllowed: gate('gate_music'),
    302     quotesAllowed: gate('gate_quote_cards'),
    303     emojiAllowed: emoji,
    304     messagesAllowed: gate('gate_messages'),
    305     composeAllowed: gate('gate_compose'),
    306     repliesAllowed: gate('gate_replies'),
    307     threadsAllowed: gate('external_threads'),
    308     followingAllowed: gate('gate_following'),
    309     // Emoji dicht raakt ook de bylines: de plaatjes in een naam komen net zo
    310     // goed van een vreemde server. De naam zelf blijft, met :shortcode: als tekst.
    311     gateAuthor: (a) => (a && !emoji ? { ...a, emojis: undefined } : a),
    312   };
    313 }
    314 
    315 /** De naam waaronder deze lezer zichzelf herkent in een Mention. */
    316 function ownHandle(base, slug) {
    317   try { return `@${slug}@${new URL(base).host}`; } catch { return `@${slug}`; }
    318 }
    319 
    320 // ── Een bericht als AS2-item: EEN beschrijving van de kaartvorm ──
    321 //
    322 // Gebruikt door de inbox-lezing en door de gesprekslezingen. Twee keer
    323 // opschrijven is twee vormen die uit de pas kunnen lopen, en dat merk je pas
    324 // als een kaart ergens anders rendert dan waar je keek.
    325 function messageItem(m, { base, me, myHandle, p }) {
    326   return {
    327     id: `${m.object_uri}#create`,
    328     type: 'Create',
    329     actor: m.actor_uri,
    330     published: AP.isoStamp(m.published || m.created_at),
    331     object: {
    332       id: m.object_uri,
    333       type: 'Note',
    334       attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
    335         name: m.actor_name || undefined, handle: m.actor_handle || undefined,
    336         icon: m.actor_icon || undefined, url: m.actor_url || undefined,
    337         emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
    338       }) : undefined),
    339       content: AP.stripLeadingMentions(m.content),
    340       url: m.note_url || undefined,
    341       published: AP.isoStamp(m.published || m.created_at),
    342       // Addressed to us and to nobody we know of: the other recipients of a
    343       // note to several people are not ours to see, so we serve what we know.
    344       to: [me],
    345       // The Mention is how the client recognises itself as the addressee and
    346       // groups the note into a conversation. No FEP-e232 link tags here: a
    347       // mention row keeps the resolved quote, not the raw tags.
    348       tag: [{ type: 'Mention', href: me, name: myHandle }, ...(p.emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
    349       attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: p.imagesAllowed, audio: p.musicAllowed }),
    350       // FEP-633c: what kind of message this is. The wave is a gentle nudge from
    351       // a guardian; the help request is the buoy. Both render differently.
    352       'shaer:wave': m.wave ? true : undefined,
    353       'shaer:helpRequest': m.help_request ? true : undefined,
    354       quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
    355       preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
    356     },
    357   };
    358 }
    359 
    360 /** Een eigen verzonden note als AS2-item, zelfde vorm als de inbox-leg. */
    361 function sentItem(n, { me, mine }) {
    362   return {
    363     id: `${n.id}#create`,
    364     type: 'Create',
    365     actor: me,
    366     published: n.published,
    367     // The leading mention anchor is addressing, not prose (the DM leg strips
    368     // it the same way); the Mention tags built from the full content stay.
    369     object: {
    370       ...n, content: AP.stripLeadingMentions(n.content),
    371       attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
    372     },
    373   };
    374 }
    375 
    376 // ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────
    377 //
    378 // Twee lezingen naast de bestaande inbox-lezing, niet in de plaats ervan: de
    379 // apps in het veld lezen die nog. /conversations geeft EEN rij per tegenpartij
    380 // -- compleet van vorm, dus de avatarhemel kan niemand kwijtraken doordat een
    381 // ander druk was -- en /messages geeft een gesprek met een cursor, zodat een
    382 // 'load more' eerlijk kan verschijnen in plaats van dat de geschiedenis stil
    383 // ophoudt.
    384 //
    385 // Beide lopen langs dezelfde poorten als de inbox-lezing (gatesFor) en
    386 // dezelfde kaartvorm (messageItem/sentItem). Messages dicht sluit ook
    387 // hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
    388 function conversationItems(req, auth, refs) {
    389   const base = baseUrl(req);
    390   const P = gatesFor(auth.site);
    391   const me = AP.actorId(base, auth.site.slug);
    392   const ctx = { base, me, myHandle: ownHandle(base, auth.site.slug), p: P };
    393   const mine = AP.selfAuthor(base, auth.site);
    394   const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
    395 
    396   const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
    397     .map((m) => [m.object_uri, m]));
    398   const items = [];
    399   for (const r of refs) {
    400     if (r.direction === 'in') {
    401       const m = incoming.get(r.ref);
    402       if (!m) continue;
    403       if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
    404       items.push(messageItem(m, ctx));
    405     } else {
    406       const n = AP.getOutboxNote(base, r.ref);
    407       // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
    408       // niet wat je zelf gezegd hebt.
    409       if (n) items.push(sentItem(n, { me, mine }));
    410     }
    411   }
    412   return items;
    413 }
    414 
    415 router.get('/ap/users/:slug/conversations', (req, res) => {
    416   const auth = OAuth.verifyBearer(req.headers.authorization);
    417   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    418   const heads = AP.conversationHeads(auth.site.slug);
    419   const items = conversationItems(req, auth, heads);
    420   AP.sendAP(res, {
    421     '@context': AP.AP_CONTEXT,
    422     id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/conversations`,
    423     type: 'OrderedCollection',
    424     totalItems: items.length,
    425     orderedItems: items,
    426     'shaer:cursor': AP.feedCursor(auth.site.slug),
    427   }, 'private, no-store');
    428 });
    429 
    430 router.get('/ap/users/:slug/messages', (req, res) => {
    431   const auth = OAuth.verifyBearer(req.headers.authorization);
    432   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    433   const other = String(req.query.with || '');
    434   if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
    435   const page = AP.conversationHistory(auth.site.slug, other, {
    436     before: req.query.before ? String(req.query.before) : null,
    437     limit: req.query.limit,
    438   });
    439   const items = conversationItems(req, auth, page.rows);
    440   // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
    441   // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
    442   // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
    443   const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
    444   const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
    445   AP.sendAP(res, {
    446     '@context': AP.AP_CONTEXT,
    447     id: req.query.before ? `${self}${size}&before=${encodeURIComponent(String(req.query.before))}` : `${self}${size}`,
    448     type: 'OrderedCollectionPage',
    449     partOf: self,
    450     orderedItems: items,
    451     // De volgende pagina is de standaardvorm van 'er is meer' (AS2). Ontbreekt
    452     // hij, dan is het gesprek op -- en dat mag de client weten zonder gokken,
    453     // want anders kan een 'load more' niet eerlijk verschijnen.
    454     next: page.more && page.oldest ? `${self}${size}&before=${encodeURIComponent(page.oldest)}` : undefined,
    455   }, 'private, no-store');
    456 });
    457 
    458 // ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
    459 // Hold the request until something push-worthy lands for this account, then
    460 // answer 200 (news: re-read your feed) or 204 after ~25s (nothing: re-arm).
    461 // The thread in the app stays live without interval polling.
    462 router.get('/ap/users/:slug/inbox/wait', (req, res) => {
    463   const auth = OAuth.verifyBearer(req.headers.authorization);
    464   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    465   let settled = false;
    466   const done = (code) => {
    467     if (settled) return;
    468     settled = true;
    469     clearTimeout(timer);
    470     off();
    471     if (!res.headersSent) res.status(code).end();
    472   };
    473   const off = AP.onNews(auth.site.slug, () => done(200));
    474   const timer = setTimeout(() => done(204), 25_000);
    475   req.on('close', () => done(204));
    476 });
    477 
    478 // ── Blocked collection (owner only, AP §5.6) ──────────────────────
    479 // The server blocklist is the source of truth for Shaer's "in Orbit":
    480 // clients read it here instead of keeping their own state. Actor-kind
    481 // blocks only (domain blocks are instance policy, not an Orbit member).
    482 router.get('/ap/users/:slug/blocked', (req, res) => {
    483   const auth = OAuth.verifyBearer(req.headers.authorization);
    484   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    485   const base = baseUrl(req);
    486   const items = AP.listBlocks(auth.site.slug)
    487     .filter((b) => b.kind === 'actor')
    488     .map((b) => b.target);
    489   AP.sendAP(res, {
    490     '@context': AP.AP_CONTEXT,
    491     id: `${base}/ap/users/${auth.site.slug}/blocked`,
    492     type: 'OrderedCollection',
    493     totalItems: items.length,
    494     orderedItems: items,
    495   });
    496 });
    497 
    498 // ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
    499 // The dashboard collections the Shaer clients read: pending adoption offers,
    500 // gated follows (empty in Klonkt for now) and the guardian's wards. Same
    501 // contract as the Shaer test daemon.
    502 function queueRoute(name, build) {
    503   router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
    504     const auth = OAuth.verifyBearer(req.headers.authorization);
    505     if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    506     const base = baseUrl(req);
    507     const me = `${base}/ap/users/${auth.site.slug}`;
    508     // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
    509     // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
    510     // honderd wards inclusief poorten terug.
    511     AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
    512   });
    513 }
    514 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
    515 queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
    516 // §5.3 turned around (shaer-p729): what this ward has asked to follow, still
    517 // waiting on its guardians. Owner-only like the rest — who a child wants to
    518 // follow is nobody else's business.
    519 queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
    520 queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
    521 // Availability (FEP-633c 3.6.1) is never public: the ward reads its
    522 // guardians' real states here and nowhere else.
    523 queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
    524 
    525 // ── Het logboek (FEP-633c §4.2, shaer:log) ────────────────────────────
    526 // NAAST de wachtrijen en niet erin: alles onder shaer:queues wacht op een
    527 // antwoord, dit is wat er al besloten is. Eigen pad, dezelfde eigenaar-only
    528 // bearer. Het bestaat omdat een weigering anders alleen te merken viel doordat
    529 // er iets uit een lijst verdween, en "het is weg" is geen reden.
    530 router.get('/ap/users/:slug/log', (req, res) => {
    531   const auth = OAuth.verifyBearer(req.headers.authorization);
    532   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    533   const me = `${baseUrl(req)}/ap/users/${auth.site.slug}`;
    534   AP.sendAP(res, {
    535     '@context': AP.AP_CONTEXT,
    536     ...Guardianship.logCollection(`${me}/log`, auth.site.slug, (s) => AP.listGuardianEvents(s, 50)),
    537   }, 'private, no-store');
    538 });
    539 // De hulpvragen MET hun staat (5.2.1, shaer-lgo). De apps lazen ze uit de feed
    540 // en wisten dus niet of er al iemand op af was -- daarom bleef een afgehandeld
    541 // verzoek daar staan (Barts melding, 8-8).
    542 queueRoute('help', (id, slug) => Guardianship.helpCollection(id, slug));
    543 
    544 // ── Inbox read (owner only, AP C2S) ───────────────────────────────
    545 // GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
    546 // scoped to this site) reads recent inbound posts (the timeline: accounts
    547 // they follow) as Create(Note) items, so an app (Shaer) can build a unified
    548 // feed. Anyone else gets 403; the inbox stays write-only for the public.
    549 router.get('/ap/users/:slug/inbox', async (req, res) => {
    550   const auth = OAuth.verifyBearer(req.headers.authorization);
    551   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    552   const base = baseUrl(req);
    553   // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
    554   // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
    555   // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
    556   // gedraagt de route zich exact zoals altijd.
    557   //
    558   // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
    559   // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
    560   // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
    561   // ronde.
    562   const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
    563   if (req.query.since && wachtS > 0) {
    564     const afbreken = new AbortController();
    565     res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
    566     const uit = await AP.waitForFeedChange(auth.site.slug, {
    567       since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
    568     });
    569     if (res.writableEnded || afbreken.signal.aborted) return undefined;
    570     // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
    571     // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
    572     // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
    573     // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
    574     // dat voor nieuws twee rondjes nodig heeft.
    575     //
    576     // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
    577     // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
    578     // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
    579     // een lege merksteen sturen we dus gewoon de collectie.
    580     if (!uit.changed && uit.cursor !== '0') {
    581       res.set('Vary', 'Authorization');
    582       return res.status(304).end();
    583     }
    584   }
    585   // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
    586   // world outside the fediverse is the guardians' call. The gate is applied
    587   // here, at serialisation: a blocked embed is never sent, because an embed the
    588   // client merely hides has still been delivered to the device.
    589   // De poorten van deze lezer (gatesFor): een plek waar ze berekend worden,
    590   // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
    591   // kopie krijgen die kan gaan afwijken.
    592   const P = gatesFor(auth.site);
     297// Zelfde reden als messageItem hieronder: de volledige lezing en de
     298// verschil-lezing bouwen dezelfde kaart, en twee beschrijvingen lopen uit de
     299// pas zonder dat iemand het merkt.
     300function timelineItem(t, { p, reactions }) {
     301  const authorInfo = (r, prefix) => authorInfoFrom(r, prefix, p);
    593302  const {
    594     embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed,
    595     emojiAllowed, messagesAllowed, composeAllowed, repliesAllowed, threadsAllowed,
    596     followingAllowed, gateAuthor,
    597   } = P;
    598   // De rechten-lijst hieronder vraagt er nog een paar rechtstreeks op.
    599   const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], P.isWard);
    600   // ── Standaardvormen naast het dialect (shaer-nmw) ────────────────
    601   //
    602   // Een lezer die AS2 kent heeft nu genoeg aan attributedTo (ingesloten
    603   // actor), quote (FEP-044f als object), preview (AS2 core) en de
    604   // Announce-wrapper. De shaer:-velden blijven er nog naast staan voor apps
    605   // in het veld; die gaan eruit als de clients om zijn.
    606   const authorInfo = (r, p) => {
    607     const info = {
    608       name: r[`${p}name`] || undefined, handle: r[`${p}handle`] || undefined,
    609       icon: r[`${p}icon`] || undefined, url: r[`${p}url`] || undefined,
    610       emojis: (() => { try { return r[`${p}emoji_json`] ? JSON.parse(r[`${p}emoji_json`]) : undefined; } catch { return undefined; } })(),
    611     };
    612     return (info.name || info.handle || info.icon) ? gateAuthor(info) : undefined;
    613   };
    614   const rows = AP.getTimeline(auth.site.slug, 60);
    615   // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
    616   // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
    617   // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
    618   const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
    619   const posts = rows.map((t) => {
     303    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed, emojiAllowed,
     304  } = p;
     305  const reacties = reactions || new Map();
    620306    const auteur = authorInfo(t, 'author_');
    621307    const booster = authorInfo(t, 'reblog_');
     
    664350    },
    665351  };
    666   });
    667   // The direct notes addressed to this account: a plain DM, a guardian's wave
    668   // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
    669   // they are not in the timeline; without them the app's Berichten shows only
    670   // what you said yourself. Same shape as a post, so one parser handles both.
    671   const me = AP.actorId(base, auth.site.slug);
    672   const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
    673   // Messages dicht (shaer-3ow) sluit vreemden en vrienden, maar NOOIT het
    674   // guardian-kanaal: de zwaai en het gesprek na een hulpvraag zijn precies
    675   // het kanaal dat het kind veilig houdt, en een poort die dat afsnijdt
    676   // beschermt niemand. De hulpvraag zelf gaat aan de innamekant al altijd voor.
    677   const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
    678   const messageCtx = { base, me, myHandle, p: P };
    679   const messages = AP.getDirectMessages(auth.site.slug, 60)
    680     .filter((m) => messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))
    681     .map((m) => messageItem(m, messageCtx));
    682   // Inbound REPLIES on your own posts: stored as interactions (the web's
    683   // comment machinery), never as mentions, so this read missed them and a
    684   // friend's reply arrived everywhere except in your app (Robins melding,
    685   // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
    686   const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => ({
     352}
     353
     354/** Een inkomend antwoord op je eigen post als AS2-item. */
     355function replyItem(m, { base, me, myHandle, p }) {
     356  return {
    687357    id: `${m.object_uri}#create`,
    688358    type: 'Create',
     
    692362      id: m.object_uri,
    693363      type: 'Note',
    694       attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? gateAuthor({
     364      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
    695365        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
    696366        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
     
    703373      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
    704374      attachment: AP.timelineAttachments(m.media_json),
    705       quote: quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
    706       preview: embedsAllowed ? AP.previewObject(m.embed_json, { playback: playbackAllowed }) : undefined,
     375      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
     376      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
    707377    },
    708   }));
     378  };
     379}
     380
     381// ── De poorten van een lezer, op EEN plek (FEP-633c) ─────────────
     382//
     383// De inbox-lezing rekende ze inline uit. Nu er meer lezingen zijn die
     384// dezelfde poorten moeten eerbiedigen (de gesprekken, de geschiedenis), zou
     385// dat evenveel kopieen worden -- en een poort die op een van die plekken
     386// vergeten wordt, levert stil iets uit dat dicht hoorde te staan.
     387function gatesFor(site) {
     388  const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
     389  const embeds = Guardianship.externalEmbedsAllowed(site.external_embeds, isWard);
     390  const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
     391  const emoji = gate('gate_custom_emoji');
     392  return {
     393    isWard,
     394    embedsAllowed: embeds,
     395    playbackAllowed: embeds && Guardianship.externalPlaybackAllowed(site.external_playback, isWard),
     396    imagesAllowed: gate('gate_images'),
     397    musicAllowed: gate('gate_music'),
     398    quotesAllowed: gate('gate_quote_cards'),
     399    emojiAllowed: emoji,
     400    messagesAllowed: gate('gate_messages'),
     401    composeAllowed: gate('gate_compose'),
     402    repliesAllowed: gate('gate_replies'),
     403    threadsAllowed: gate('external_threads'),
     404    followingAllowed: gate('gate_following'),
     405    // Emoji dicht raakt ook de bylines: de plaatjes in een naam komen net zo
     406    // goed van een vreemde server. De naam zelf blijft, met :shortcode: als tekst.
     407    gateAuthor: (a) => (a && !emoji ? { ...a, emojis: undefined } : a),
     408  };
     409}
     410
     411/** De naam waaronder deze lezer zichzelf herkent in een Mention. */
     412function ownHandle(base, slug) {
     413  try { return `@${slug}@${new URL(base).host}`; } catch { return `@${slug}`; }
     414}
     415
     416// ── Een bericht als AS2-item: EEN beschrijving van de kaartvorm ──
     417//
     418// Gebruikt door de inbox-lezing en door de gesprekslezingen. Twee keer
     419// opschrijven is twee vormen die uit de pas kunnen lopen, en dat merk je pas
     420// als een kaart ergens anders rendert dan waar je keek.
     421function messageItem(m, { base, me, myHandle, p }) {
     422  return {
     423    id: `${m.object_uri}#create`,
     424    type: 'Create',
     425    actor: m.actor_uri,
     426    published: AP.isoStamp(m.published || m.created_at),
     427    object: {
     428      id: m.object_uri,
     429      type: 'Note',
     430      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
     431        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
     432        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
     433        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
     434      }) : undefined),
     435      content: AP.stripLeadingMentions(m.content),
     436      url: m.note_url || undefined,
     437      published: AP.isoStamp(m.published || m.created_at),
     438      // Addressed to us and to nobody we know of: the other recipients of a
     439      // note to several people are not ours to see, so we serve what we know.
     440      to: [me],
     441      // The Mention is how the client recognises itself as the addressee and
     442      // groups the note into a conversation. No FEP-e232 link tags here: a
     443      // mention row keeps the resolved quote, not the raw tags.
     444      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(p.emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
     445      attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: p.imagesAllowed, audio: p.musicAllowed }),
     446      // FEP-633c: what kind of message this is. The wave is a gentle nudge from
     447      // a guardian; the help request is the buoy. Both render differently.
     448      'shaer:wave': m.wave ? true : undefined,
     449      'shaer:helpRequest': m.help_request ? true : undefined,
     450      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
     451      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
     452    },
     453  };
     454}
     455
     456/** Een eigen verzonden note als AS2-item, zelfde vorm als de inbox-leg. */
     457function sentItem(n, { me, mine }) {
     458  return {
     459    id: `${n.id}#create`,
     460    type: 'Create',
     461    actor: me,
     462    published: n.published,
     463    // The leading mention anchor is addressing, not prose (the DM leg strips
     464    // it the same way); the Mention tags built from the full content stay.
     465    object: {
     466      ...n, content: AP.stripLeadingMentions(n.content),
     467      attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
     468    },
     469  };
     470}
     471
     472// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────
     473//
     474// Twee lezingen naast de bestaande inbox-lezing, niet in de plaats ervan: de
     475// apps in het veld lezen die nog. /conversations geeft EEN rij per tegenpartij
     476// -- compleet van vorm, dus de avatarhemel kan niemand kwijtraken doordat een
     477// ander druk was -- en /messages geeft een gesprek met een cursor, zodat een
     478// 'load more' eerlijk kan verschijnen in plaats van dat de geschiedenis stil
     479// ophoudt.
     480//
     481// Beide lopen langs dezelfde poorten als de inbox-lezing (gatesFor) en
     482// dezelfde kaartvorm (messageItem/sentItem). Messages dicht sluit ook
     483// hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
     484function conversationItems(req, auth, refs) {
     485  const base = baseUrl(req);
     486  const P = gatesFor(auth.site);
     487  const me = AP.actorId(base, auth.site.slug);
     488  const ctx = { base, me, myHandle: ownHandle(base, auth.site.slug), p: P };
     489  const mine = AP.selfAuthor(base, auth.site);
     490  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
     491
     492  const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
     493    .map((m) => [m.object_uri, m]));
     494  const items = [];
     495  for (const r of refs) {
     496    if (r.direction === 'in') {
     497      const m = incoming.get(r.ref);
     498      if (!m) continue;
     499      if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
     500      items.push(messageItem(m, ctx));
     501    } else {
     502      const n = AP.getOutboxNote(base, r.ref);
     503      // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
     504      // niet wat je zelf gezegd hebt.
     505      if (n) items.push(sentItem(n, { me, mine }));
     506    }
     507  }
     508  return items;
     509}
     510
     511router.get('/ap/users/:slug/conversations', (req, res) => {
     512  const auth = OAuth.verifyBearer(req.headers.authorization);
     513  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     514  const heads = AP.conversationHeads(auth.site.slug);
     515  const items = conversationItems(req, auth, heads);
     516  AP.sendAP(res, {
     517    '@context': AP.AP_CONTEXT,
     518    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/conversations`,
     519    type: 'OrderedCollection',
     520    totalItems: items.length,
     521    orderedItems: items,
     522    'shaer:cursor': AP.feedCursor(auth.site.slug),
     523  }, 'private, no-store');
     524});
     525
     526router.get('/ap/users/:slug/messages', (req, res) => {
     527  const auth = OAuth.verifyBearer(req.headers.authorization);
     528  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     529  const other = String(req.query.with || '');
     530  if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
     531  const page = AP.conversationHistory(auth.site.slug, other, {
     532    before: req.query.before ? String(req.query.before) : null,
     533    limit: req.query.limit,
     534  });
     535  const items = conversationItems(req, auth, page.rows);
     536  // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
     537  // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
     538  // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
     539  const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
     540  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
     541  AP.sendAP(res, {
     542    '@context': AP.AP_CONTEXT,
     543    id: req.query.before ? `${self}${size}&before=${encodeURIComponent(String(req.query.before))}` : `${self}${size}`,
     544    type: 'OrderedCollectionPage',
     545    partOf: self,
     546    orderedItems: items,
     547    // De volgende pagina is de standaardvorm van 'er is meer' (AS2). Ontbreekt
     548    // hij, dan is het gesprek op -- en dat mag de client weten zonder gokken,
     549    // want anders kan een 'load more' niet eerlijk verschijnen.
     550    next: page.more && page.oldest ? `${self}${size}&before=${encodeURIComponent(page.oldest)}` : undefined,
     551  }, 'private, no-store');
     552});
     553
     554// ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
     555// Hold the request until something push-worthy lands for this account, then
     556// answer 200 (news: re-read your feed) or 204 after ~25s (nothing: re-arm).
     557// The thread in the app stays live without interval polling.
     558router.get('/ap/users/:slug/inbox/wait', (req, res) => {
     559  const auth = OAuth.verifyBearer(req.headers.authorization);
     560  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     561  let settled = false;
     562  const done = (code) => {
     563    if (settled) return;
     564    settled = true;
     565    clearTimeout(timer);
     566    off();
     567    if (!res.headersSent) res.status(code).end();
     568  };
     569  const off = AP.onNews(auth.site.slug, () => done(200));
     570  const timer = setTimeout(() => done(204), 25_000);
     571  req.on('close', () => done(204));
     572});
     573
     574// ── Blocked collection (owner only, AP §5.6) ──────────────────────
     575// The server blocklist is the source of truth for Shaer's "in Orbit":
     576// clients read it here instead of keeping their own state. Actor-kind
     577// blocks only (domain blocks are instance policy, not an Orbit member).
     578router.get('/ap/users/:slug/blocked', (req, res) => {
     579  const auth = OAuth.verifyBearer(req.headers.authorization);
     580  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     581  const base = baseUrl(req);
     582  const items = AP.listBlocks(auth.site.slug)
     583    .filter((b) => b.kind === 'actor')
     584    .map((b) => b.target);
     585  AP.sendAP(res, {
     586    '@context': AP.AP_CONTEXT,
     587    id: `${base}/ap/users/${auth.site.slug}/blocked`,
     588    type: 'OrderedCollection',
     589    totalItems: items.length,
     590    orderedItems: items,
     591  });
     592});
     593
     594// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
     595// The dashboard collections the Shaer clients read: pending adoption offers,
     596// gated follows (empty in Klonkt for now) and the guardian's wards. Same
     597// contract as the Shaer test daemon.
     598function queueRoute(name, build) {
     599  router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
     600    const auth = OAuth.verifyBearer(req.headers.authorization);
     601    if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     602    const base = baseUrl(req);
     603    const me = `${base}/ap/users/${auth.site.slug}`;
     604    // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
     605    // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
     606    // honderd wards inclusief poorten terug.
     607    AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
     608  });
     609}
     610queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
     611queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
     612// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
     613// waiting on its guardians. Owner-only like the rest — who a child wants to
     614// follow is nobody else's business.
     615queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
     616queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
     617// Availability (FEP-633c 3.6.1) is never public: the ward reads its
     618// guardians' real states here and nowhere else.
     619queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
     620
     621// ── Het logboek (FEP-633c §4.2, shaer:log) ────────────────────────────
     622// NAAST de wachtrijen en niet erin: alles onder shaer:queues wacht op een
     623// antwoord, dit is wat er al besloten is. Eigen pad, dezelfde eigenaar-only
     624// bearer. Het bestaat omdat een weigering anders alleen te merken viel doordat
     625// er iets uit een lijst verdween, en "het is weg" is geen reden.
     626router.get('/ap/users/:slug/log', (req, res) => {
     627  const auth = OAuth.verifyBearer(req.headers.authorization);
     628  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     629  const me = `${baseUrl(req)}/ap/users/${auth.site.slug}`;
     630  AP.sendAP(res, {
     631    '@context': AP.AP_CONTEXT,
     632    ...Guardianship.logCollection(`${me}/log`, auth.site.slug, (s) => AP.listGuardianEvents(s, 50)),
     633  }, 'private, no-store');
     634});
     635// De hulpvragen MET hun staat (5.2.1, shaer-lgo). De apps lazen ze uit de feed
     636// en wisten dus niet of er al iemand op af was -- daarom bleef een afgehandeld
     637// verzoek daar staan (Barts melding, 8-8).
     638queueRoute('help', (id, slug) => Guardianship.helpCollection(id, slug));
     639
     640// ── Inbox read (owner only, AP C2S) ───────────────────────────────
     641// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
     642// scoped to this site) reads recent inbound posts (the timeline: accounts
     643// they follow) as Create(Note) items, so an app (Shaer) can build a unified
     644// feed. Anyone else gets 403; the inbox stays write-only for the public.
     645router.get('/ap/users/:slug/inbox', async (req, res) => {
     646  const auth = OAuth.verifyBearer(req.headers.authorization);
     647  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     648  const base = baseUrl(req);
     649  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
     650  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
     651  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
     652  // gedraagt de route zich exact zoals altijd.
     653  //
     654  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
     655  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
     656  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
     657  // ronde.
     658  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
     659  if (req.query.since && wachtS > 0) {
     660    const afbreken = new AbortController();
     661    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
     662    const uit = await AP.waitForFeedChange(auth.site.slug, {
     663      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
     664    });
     665    if (res.writableEnded || afbreken.signal.aborted) return undefined;
     666    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
     667    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
     668    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
     669    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
     670    // dat voor nieuws twee rondjes nodig heeft.
     671    //
     672    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
     673    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
     674    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
     675    // een lege merksteen sturen we dus gewoon de collectie.
     676    if (!uit.changed && uit.cursor !== '0') {
     677      res.set('Vary', 'Authorization');
     678      return res.status(304).end();
     679    }
     680  }
     681  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
     682  // world outside the fediverse is the guardians' call. The gate is applied
     683  // here, at serialisation: a blocked embed is never sent, because an embed the
     684  // client merely hides has still been delivered to the device.
     685  // De poorten van deze lezer (gatesFor): een plek waar ze berekend worden,
     686  // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
     687  // kopie krijgen die kan gaan afwijken.
     688  const P = gatesFor(auth.site);
     689  const {
     690    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed,
     691    emojiAllowed, messagesAllowed, composeAllowed, repliesAllowed, threadsAllowed,
     692    followingAllowed, gateAuthor,
     693  } = P;
     694  // De rechten-lijst hieronder vraagt er nog een paar rechtstreeks op.
     695  const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], P.isWard);
     696  // ── Standaardvormen naast het dialect (shaer-nmw) ────────────────
     697  //
     698  // Een lezer die AS2 kent heeft nu genoeg aan attributedTo (ingesloten
     699  // actor), quote (FEP-044f als object), preview (AS2 core) en de
     700  // Announce-wrapper. De shaer:-velden blijven er nog naast staan voor apps
     701  // in het veld; die gaan eruit als de clients om zijn.
     702  // Wie ik ben en wie mijn guardians zijn: allebei de lezingen hieronder
     703  // hebben ze nodig, dus een keer, hierboven.
     704  const me = AP.actorId(base, auth.site.slug);
     705  const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
     706  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
     707  // ── Alleen het VERSCHIL, als de client daarom vraagt (shaer-pq4) ──
     708  //
     709  // De wachtende lezing zei tot nu toe alleen DAT er iets veranderde, waarna de
     710  // client alles opnieuw las: vier legs van zestig met al hun media-, quote- en
     711  // embed-JSON, voor een enkel nieuw bericht. ap_feed_state houdt per object al
     712  // bij wat er wanneer veranderde, dus het verschil lag er klaar en werd alleen
     713  // nooit uitgedeeld (feedChangesSince had geen enkele aanroeper).
     714  //
     715  // OPT-IN met ?changes=1, en dat is geen franje: een app in het veld stuurt
     716  // `since` al mee en vervangt haar hele feed door wat er terugkomt. Zou
     717  // `since` opeens een verschil betekenen, dan wist die app zichzelf leeg.
     718  //
     719  // Het antwoord is een OrderedCollectionPage met partOf, want dat is wat het
     720  // IS -- een deel, geen collectie. Een generieke lezer ziet dat verschil ook.
     721  if (req.query.changes && req.query.since) {
     722    const veranderd = AP.feedChangesSince(auth.site.slug, String(req.query.since));
     723    const levend = veranderd.filter((c) => c.kind !== 'deleted').map((c) => c.object_uri);
     724    const tl = new Map(AP.timelineRowsByIds(auth.site.slug, levend).map((r) => [r.id, r]));
     725    const mn = new Map(AP.messageRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u))).map((r) => [r.object_uri, r]));
     726    const rp = new Map(AP.replyRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u) && !mn.has(u))).map((r) => [r.object_uri, r]));
     727    const reacties = AP.getReactionsFor(auth.site.slug, [...tl.keys()]);
     728    const ctx = { base, me, myHandle, p: P };
     729    const items = [];
     730    for (const c of veranderd) {
     731      if (c.kind === 'deleted') {
     732        // Een verwijdering reisde tot nu toe als AFWEZIGHEID mee: de volledige
     733        // lezing bevatte hem simpelweg niet meer. Die volledigheid is precies
     734        // wat hier wegvalt, dus zonder grafsteen zou een weggehaalde post voor
     735        // altijd in de app blijven staan -- en dat faalt stil. AS2 heeft er een
     736        // vorm voor, en de rij lag er al.
     737        items.push({ type: 'Delete', actor: me, object: { id: c.object_uri, type: 'Tombstone' } });
     738        continue;
     739      }
     740      const t = tl.get(c.object_uri);
     741      if (t) { items.push(timelineItem(t, { p: P, reactions: reacties })); continue; }
     742      const m = mn.get(c.object_uri);
     743      if (m) {
     744        if (messagesAllowed || m.help_request || guardianUris.has(m.actor_uri)) items.push(messageItem(m, ctx));
     745        continue;
     746      }
     747      const r = rp.get(c.object_uri);
     748      if (r) { items.push(replyItem(r, ctx)); continue; }
     749      const n = AP.getOutboxNote(base, c.object_uri);
     750      if (n) items.push(sentItem(n, { me, mine: AP.selfAuthor(base, auth.site) }));
     751    }
     752    return AP.sendAP(res, {
     753      '@context': AP.AP_CONTEXT,
     754      id: `${base}/ap/users/${encodeURIComponent(auth.site.slug)}/inbox?changes=1&since=${encodeURIComponent(String(req.query.since))}`,
     755      type: 'OrderedCollectionPage',
     756      partOf: `${base}/ap/users/${auth.site.slug}/inbox`,
     757      orderedItems: items,
     758      'shaer:cursor': AP.feedCursor(auth.site.slug),
     759    }, 'private, no-store');
     760  }
     761  const rows = AP.getTimeline(auth.site.slug, 60);
     762  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
     763  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
     764  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
     765  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
     766  const posts = rows.map((t) => timelineItem(t, { p: P, reactions: reacties }));
     767  // The direct notes addressed to this account: a plain DM, a guardian's wave
     768  // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
     769  // they are not in the timeline; without them the app's Berichten shows only
     770  // what you said yourself. Same shape as a post, so one parser handles both.
     771  // Messages dicht (shaer-3ow) sluit vreemden en vrienden, maar NOOIT het
     772  // guardian-kanaal: de zwaai en het gesprek na een hulpvraag zijn precies
     773  // het kanaal dat het kind veilig houdt, en een poort die dat afsnijdt
     774  // beschermt niemand. De hulpvraag zelf gaat aan de innamekant al altijd voor.
     775  const messageCtx = { base, me, myHandle, p: P };
     776  const messages = AP.getDirectMessages(auth.site.slug, 60)
     777    .filter((m) => messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))
     778    .map((m) => messageItem(m, messageCtx));
     779  // Inbound REPLIES on your own posts: stored as interactions (the web's
     780  // comment machinery), never as mentions, so this read missed them and a
     781  // friend's reply arrived everywhere except in your app (Robins melding,
     782  // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
     783  const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => replyItem(m, messageCtx));
    709784  // Your OWN sent notes (replies and direct messages, ap_outbox): without
    710785  // them a reply existed everywhere except in your own app, Messages showed
  • src/services/ActivityPubService.js

    r3bbf73d rf0a33e1  
    40954095// everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet
    40964096// binnen bij de ander").
     4097const REPLY_COLUMNS = `
     4098      i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
     4099      i.content, i.published, i.created_at, i.parent_uri, i.post_id,
     4100      i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json`;
     4101
     4102/** Dezelfde antwoordrijen, maar op object-uri -- voor de verschil-lezing. */
     4103export function replyRowsByUri(slug, uris) {
     4104  const list = (uris || []).filter((u) => typeof u === 'string' && u);
     4105  if (!list.length) return [];
     4106  try {
     4107    const holes = list.map(() => '?').join(',');
     4108    return db.prepare(`SELECT ${REPLY_COLUMNS} FROM ap_interactions i
     4109                        JOIN posts p ON p.id = i.post_id
     4110                        JOIN sites s ON s.id = p.site_id
     4111                       WHERE s.slug = ? AND i.kind = 'reply' AND i.object_uri IN (${holes})`)
     4112      .all(slug, ...list);
     4113  } catch { return []; }
     4114}
     4115
     4116/** Tijdlijnrijen op id, met dezelfde afgeleide liked/boosted als getTimeline. */
     4117export function timelineRowsByIds(slug, ids) {
     4118  const list = (ids || []).filter((u) => typeof u === 'string' && u);
     4119  if (!list.length) return [];
     4120  try {
     4121    const holes = list.map(() => '?').join(',');
     4122    const rows = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? AND id IN (${holes})`).all(slug, ...list);
     4123    const reacties = getReactionsFor(slug, rows.map((r) => r.id));
     4124    for (const r of rows) {
     4125      const x = reacties.get(r.id);
     4126      r.liked = !!(x && x.liked);
     4127      r.boosted = !!(x && x.boosted);
     4128    }
     4129    return rows;
     4130  } catch { return []; }
     4131}
     4132
    40974133export function getReplyMessages(slug, limit) {
    40984134  try {
    40994135    return db.prepare(`
    4100       SELECT i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
    4101              i.content, i.published, i.created_at, i.parent_uri, i.post_id,
    4102              i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json
     4136      SELECT ${REPLY_COLUMNS}
    41034137      FROM ap_interactions i
    41044138      JOIN posts p ON p.id = i.post_id
     
    63916425  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
    63926426  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    6393   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, messageRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     6427  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, getDirectMessages, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
    63946428  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
    63956429  gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
Note: See TracChangeset for help on using the changeset viewer.