Changeset f0a33e1 in Klonkt
- Timestamp:
- 08/10/2026 03:32:22 PM (4 weeks ago)
- Branches:
- main
- Children:
- 41de7cb
- Parents:
- 3bbf73d
- Files:
-
- 1 added
- 2 edited
-
src/routes/activitypub.js (modified) (4 diffs)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
test/feed-changes.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r3bbf73d rf0a33e1 283 283 }); 284 284 285 // ── De poorten van een lezer, op EEN plek (FEP-633c) ───────────── 285 /** De byline-gegevens uit een tijdlijnrij, langs de emoji-poort. */ 286 function 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 ── 286 296 // 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. 300 function timelineItem(t, { p, reactions }) { 301 const authorInfo = (r, prefix) => authorInfoFrom(r, prefix, p); 593 302 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(); 620 306 const auteur = authorInfo(t, 'author_'); 621 307 const booster = authorInfo(t, 'reblog_'); … … 664 350 }, 665 351 }; 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. */ 355 function replyItem(m, { base, me, myHandle, p }) { 356 return { 687 357 id: `${m.object_uri}#create`, 688 358 type: 'Create', … … 692 362 id: m.object_uri, 693 363 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({ 695 365 name: m.actor_name || undefined, handle: m.actor_handle || undefined, 696 366 icon: m.actor_icon || undefined, url: m.actor_url || undefined, … … 703 373 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])], 704 374 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, 707 377 }, 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. 387 function 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. */ 412 function 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. 421 function 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. */ 457 function 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. 484 function 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 511 router.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 526 router.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. 558 router.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). 578 router.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. 598 function 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 } 610 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me)); 611 queueRoute('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. 615 queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me)); 616 queueRoute('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. 619 queueRoute('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. 626 router.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). 638 queueRoute('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. 645 router.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)); 709 784 // Your OWN sent notes (replies and direct messages, ap_outbox): without 710 785 // them a reply existed everywhere except in your own app, Messages showed -
src/services/ActivityPubService.js
r3bbf73d rf0a33e1 4095 4095 // everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet 4096 4096 // binnen bij de ander"). 4097 const 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. */ 4103 export 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. */ 4117 export 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 4097 4133 export function getReplyMessages(slug, limit) { 4098 4134 try { 4099 4135 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} 4103 4137 FROM ap_interactions i 4104 4138 JOIN posts p ON p.id = i.post_id … … 6391 6425 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote, 6392 6426 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, 6394 6428 acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision, 6395 6429 gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)