Changeset 3778ddb in Klonkt


Ignore:
Timestamp:
07/17/2026 12:20:48 AM (8 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
67c1f24
Parents:
9dbb175
git-author:
Robin <roboburr@…> (07/17/2026 12:20:29 AM)
git-committer:
Robin <roboburr@…> (07/17/2026 12:20:48 AM)
Message:

Fix: private replies (followers-only/DM) no longer render on the public post page

handleInbox ignored a reply's to/cc, and getInteractions rendered every stored
reply, so a followers-only or direct Note replying to a post was shown publicly.
Incoming interactions now record their AP addressing (new ap_interactions.visibility:
public/unlisted/followers/direct, derived via noteVisibility() from to/cc, also in
crawlThread); the public thread filters to public/unlisted (legacy rows count as
public). Likes/boosts stay count-only. Private replies are NOT shown on the post
page at all (no admin badge, per design decision): they reach the owner via
notifications, which already carry post context (slug+title) and the reference.
Covered by test/reply-visibility.test.js; verified in a browser that a seeded DM
reply no longer renders while a public one does. Beads: klonkt-demo-jct.

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

Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    r9dbb175 r3778ddb  
    1515
    1616### Behoben
     17- **Private Antworten erscheinen nicht mehr auf der öffentlichen Beitragsseite.**
     18  Eine Nur-Follower- oder Direkt-(DM-)Antwort auf deinen Beitrag wurde für alle
     19  im öffentlichen Thread gezeigt. Eingehende Antworten speichern jetzt ihre
     20  Fediverse-Adressierung; der öffentliche Thread zeigt nur öffentliche und
     21  ungelistete Antworten. Private erreichen dich weiterhin über Meldungen, mit
     22  dem zugehörigen Beitrag.
    1723- **Nackte Video/Audio-Embeds laufen nicht mehr über die Spalte hinaus.** Ein
    1824  `.webm` / `.mp4` / `.mp3`-Player passt jetzt in die Inhaltsbreite wie die
  • CHANGELOG.md

    r9dbb175 r3778ddb  
    1515
    1616### Fixed
     17- **Private replies no longer show on the public post page.** A followers-only
     18  or direct (DM) reply to your post was rendered in the public thread for
     19  everyone. Incoming replies now record their fediverse addressing; the public
     20  thread only shows public and unlisted replies. Private ones still reach you in
     21  notifications, with the post they belong to.
    1722- **Bare video/audio embeds no longer overflow their column.** A `.webm` /
    1823  `.mp4` / `.mp3` player now fits the content width like the iframe embeds do;
  • CHANGELOG.nl.md

    r9dbb175 r3778ddb  
    1515
    1616### Opgelost
     17- **Privéreacties staan niet meer op de publieke postpagina.** Een followers-only
     18  of directe (DM-)reactie op je post werd voor iedereen in de publieke thread
     19  getoond. Inkomende reacties slaan nu hun fediverse-adressering op; de publieke
     20  thread toont alleen publieke en unlisted reacties. Privéreacties bereiken je
     21  nog steeds via meldingen, mét de post waar ze bij horen.
    1722- **Kale video/audio-embeds lopen niet meer buiten de kolom.** Een `.webm` /
    1823  `.mp4` / `.mp3`-speler past nu netjes in de kolombreedte, net als de
  • src/config/database.js

    r9dbb175 r3778ddb  
    392392  // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
    393393  ensureColumn('posts', 'content_rendered', 'TEXT');
     394
     395  // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
     396  // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
     397  // replies; followers/direct replies surface in notifications (and later Messages) with post
     398  // context instead. Existing rows default to 'public' (historically almost all were).
     399  ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
    394400}
    395401
  • src/services/ActivityPubService.js

    r9dbb175 r3778ddb  
    596596// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
    597597let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
     598// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
     599// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
     600// followers-collectie zonder Public = followers-only, anders direct (DM). Public
     601// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
     602export function noteVisibility(o) {
     603  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
     604  const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
     605  const to = arr(o && o.to).map(String);
     606  const cc = arr(o && o.cc).map(String);
     607  if (to.some(isPub)) return 'public';
     608  if (cc.some(isPub)) return 'unlisted';
     609  if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
     610  return 'direct';
     611}
     612
    598613function iStmts() {
    599614  if (!_insI) {
    600     _insI = db.prepare('INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     615    _insI = db.prepare('INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, visibility, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
    601616    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
    602617    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
    603     _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
     618    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like, visibility FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
    604619    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
    605620    _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, created_at) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     
    678693export function getInteractions(postId, base, site) {
    679694  const s = iStmts();
    680   const rows = s.list.all(postId);
     695  // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
     696  // public web, so it must NOT render in the public thread. It still reaches the owner
     697  // via notifications (post context + reference included there). Legacy rows without a
     698  // visibility value are treated as public. Likes/boosts stay counted (count-only).
     699  const rows = s.list.all(postId).filter((r) =>
     700    r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
    681701  const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    682702  const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
     
    11451165      const ai = actorInfo(await resolveActor(actorUri), actorUri);
    11461166      const html = HtmlSanitizerService.sanitize(o.content || '');
    1147       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);
     1167      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));
    11481168      console.log('[AP] reply', actorUri, '→', tgt.post_id);
    11491169      return 202;
     
    12351255    if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
    12361256      const ai = actorInfo(await resolveActor(actorUri), actorUri);
    1237       iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
     1257      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
    12381258      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
    12391259    } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
     
    21372157        const html = HtmlSanitizerService.sanitize(child.content || '');
    21382158        // The child replies to `note` by construction (it's in note's replies collection).
    2139         try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri); added++; } catch { /* ignore */ }
     2159        try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri, noteVisibility(child)); added++; } catch { /* ignore */ }
    21402160        nextFrontier.push(child.id); // expand this reply's own replies next depth
    21412161      }
     
    25242544  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    25252545  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
     2546  noteVisibility,
    25262547};
Note: See TracChangeset for help on using the changeset viewer.