Ignore:
Timestamp:
07/28/2026 06:17:36 PM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
a7bcf66
Parents:
2708282
Message:

Een hulpvraag hoort in Berichten, niet in de Krant

Een 🛟 van een ward kwam bij de guardian op twee plekken binnen: als mention in
Berichten en de Guardian PWA, maar ook als gewone post in de Krant. Op
sound-fabrics.com stonden vijf van de zes hulpvragen in allebei.

De oorzaak zat in de inbox: de tijdlijn-insert vroeg alleen "is dit een
top-level post van iemand die ik volg" en keek niet naar wie de post geadresseerd
was. Dat is nu belongsInTimeline: een directe note is aan iemand persoonlijk
gericht, dus een bericht en geen post. Dat dekt meteen de wave en de gewone DM,
die om dezelfde reden in de Krant terechtkwamen. De self-heal ruimt de al
opgeslagen exemplaren op, beperkt tot de twee soorten die achteraf nog te
herkennen zijn; een publieke mention van iemand die je volgt is wel een post en
blijft staan.

Tweede helft: de weergave gelijkgetrokken. De Krant rendert een post met
emoji's, een quote- of linkkaart en de media; Berichten liet daar niks van zien
(zelfs de shortcodes bleven staan, want ap_mentions had geen emoji_json) en de
Guardian PWA plakte de kale content in een div. Die opmaak zat bovendien in de
<style> van de Krant zelf, dus een post buiten de Krant kwam sowieso ongestyled
binnen.

Nu is er één partial, note-body, met de opmaak in shared-styles ernaast. Alle
drie de oppervlakken gebruiken hem: de PWA bouwt zijn kaarten in de browser en
krijgt de body server-side gerenderd mee. ap_mentions en ap_interactions kregen
de kolommen die daarvoor nodig zijn, gevuld bij binnenkomst, met de quote- of
linkkaart out of band zoals de tijdlijn dat al deed.

En passant: de embed-gate stond alleen op de C2S-read, dus een ward zag in de
web-Krant nog steeds linkvoorbeelden die de guardians hadden uitgezet. Die gate
zit nu ook op /news en /messages.

Changed files:
src/services/ActivityPubService.js

  • belongsInTimeline: een directe note is geen tijdlijn-post
  • self-heal v21 verwijdert al opgeslagen hulpvragen en waves uit ap_timeline
  • resolveCard: quote of linkvoorbeeld, één kaart, out of band opgelost
  • mentions en replies slaan emoji's, media en die kaart op
  • getNotifications geeft die kolommen door aan Berichten

src/config/database.js

  • kolommen op ap_mentions en ap_interactions voor emoji's, media, quote, embed

src/middleware/render.js

  • renderNoteBody: dezelfde partial als string, voor niet-EJS oppervlakken

src/routes/posts.js

  • gateEmbeds op /news en /messages (FEP-633c gated feature)

src/routes/guardian.js

  • hulpvragen krijgen body_html en name_html mee

src/assets/js/guardian.js

  • kaart rendert die body in plaats van de kale content

src/assets/css/guardian.css

  • opmaak voor de gedeelde post-body in de kleuren van de PWA

src/views/partials/tl-item.ejs

  • body vervangen door de gedeelde partial

src/views/partials/msg-item.ejs

  • idem, plus een 🛟-markering bij een hulpvraag

src/views/partials/shared-styles.ejs

  • .tl-content, .tl-quote* en .tl-media* hierheen verhuisd

src/views/pages/news.ejs

  • die regels weggehaald, alleen Krant-eigen opmaak blijft

src/services/i18n.js

  • msg.help_request in nl, en, de

New file:
src/views/partials/note-body.ejs

  • de body van een post: content, quote/linkkaart, media

test/help-request-timeline.test.js

  • een 🛟 blijft uit de Krant en in Berichten

test/note-body-shared.test.js

  • één renderer, en beide views gaan er doorheen

remarks: geverifieerd op een wegwerp-database in de browser: de hulpvraag
verdwijnt bij het opstarten uit de Krant en staat mét quote en capture in
Berichten en de PWA. Nog niet uitgerold.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r2708282 rd9ad6c5  
    832832}
    833833
     834/**
     835 * Does this note belong in the home timeline (de Krant)?
     836 *
     837 * Only if it is a POST. A direct note is addressed to named people, so it is a
     838 * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
     839 * guardian's wave. Those are stored as mentions instead and surface in
     840 * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
     841 */
     842export function belongsInTimeline(o) {
     843  if (!o || !o.id || o.inReplyTo) return false;
     844  return noteVisibility(o) !== 'direct';
     845}
     846
    834847function iStmts() {
    835848  if (!_insI) {
     
    15241537      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
    15251538      console.log('[AP] reply', actorUri, '→', tgt.post_id);
     1539      // A reply is a post too: Berichten renders it the way de Krant renders a
     1540      // timeline row, so it needs the same media and the same quote/preview card.
     1541      {
     1542        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
     1543        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
     1544        const mj = mediaFromNote(o);
     1545        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
     1546        resolveCard(o).then((c) => {
     1547          if (!c) return;
     1548          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
     1549          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
     1550        }).catch(() => { /* best-effort */ });
     1551      }
    15261552      {
    15271553        // Private (followers/direct) replies push as a DM ping WITHOUT content
     
    15411567    }
    15421568    // Home timeline (client): a top-level post from an account we follow.
    1543     if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
     1569    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
    15441570      let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
    15451571      if (subs.length) {
     
    16101636        for (const slug of slugs) {
    16111637          try {
    1612             const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
    1613               .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0);
     1638            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
     1639                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
     1640              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
     1641                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
    16141642            if (r.changes) {
     1643              // The quote / link-preview card resolves out of band (a remote
     1644              // fetch), exactly as it does for a timeline post, so the inbox
     1645              // answer is never blocked on it.
     1646              resolveCard(o).then((c) => {
     1647                if (!c) return;
     1648                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
     1649                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
     1650              }).catch(() => { /* best-effort */ });
    16151651              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
    16161652              const vis = noteVisibility(o);
     
    28492885// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
    28502886// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
    2851 const SELFHEAL_VERSION = 20; // v20: oEmbed provider registry, so platforms that hide their tags from a server still resolve
     2887const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
    28522888async function fetchNoteAP(url) {
    28532889  try {
     
    29913027  return JSON.stringify(snapshot);
    29923028}
     3029
     3030/**
     3031 * The card under a post: a fediverse quote (FEP-044f) when the note has one,
     3032 * otherwise an external link preview. Both render as the SAME card, so only one
     3033 * of the two is ever stored. Returns {column, json} or null.
     3034 *
     3035 * Both halves reach out over the network, which is why every caller runs this
     3036 * out of band: an inbox answer must never wait on a third party.
     3037 */
     3038async function resolveCard(o) {
     3039  if (quoteHrefOf(o)) {
     3040    const qj = await resolveQuote(o);
     3041    return qj ? { column: 'quote_json', json: qj } : null;
     3042  }
     3043  const ej = await resolveExternalEmbed(o && o.content);
     3044  return ej ? { column: 'embed_json', json: ej } : null;
     3045}
     3046
    29933047// A generic SSRF-safe AP GET (collections / pages).
    29943048async function apGetJson(url) {
     
    31583212    try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
    31593213    if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
     3214    // v21: direct notes used to land in the timeline as if they were posts, so a
     3215    // ward's 🛟 help request showed up in the guardian's Krant. The insert now
     3216    // refuses them; drop the ones already cached. Scoped to the two kinds we can
     3217    // still recognise afterwards (help request, wave) — a plain public mention
     3218    // from someone you follow IS a timeline post and must stay.
     3219    try {
     3220      const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
     3221        SELECT 1 FROM ap_mentions m
     3222         WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
     3223           AND (m.help_request = 1 OR m.wave = 1))`).run();
     3224      if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
     3225    } catch { /* table may predate the columns */ }
    31603226    let rows = [];
    31613227    try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     
    34563522    const rows = db.prepare(`
    34573523      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility,
    3458              i.emoji_json, i.actor_emoji_json,
     3524             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
    34593525             p.slug AS post_slug, p.title AS post_title
    34603526      FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
     
    34663532      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
    34673533      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (messages render)
     3534      media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json,   // rendered like a Krant post
    34683535      // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
    34693536      visibility: r.visibility || 'public',
     
    34883555  } catch { /* ignore */ }
    34893556  try {
    3490     for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
    3491       out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at });
     3557    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at,
     3558                                       emoji_json, actor_emoji_json, media_json, quote_json, embed_json
     3559                                FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
     3560      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at,
     3561        // Same trimmings a Krant row has, so Berichten renders the post identically.
     3562        emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
    34923563    }
    34933564  } catch { /* ignore */ }
     
    37163787  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    37173788  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    3718   noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
     3789  noteVisibility, belongsInTimeline, isRejectedObject, rejectInteraction, interactionReportTarget,
    37193790  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
    37203791};
Note: See TracChangeset for help on using the changeset viewer.