Changeset f0a33e1 in Klonkt for src/routes/activitypub.js
- Timestamp:
- 08/10/2026 03:32:22 PM (4 weeks ago)
- Branches:
- main
- Children:
- 41de7cb
- Parents:
- 3bbf73d
- File:
-
- 1 edited
-
src/routes/activitypub.js (modified) (4 diffs)
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
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)