Changeset c16e0a5 in Klonkt


Ignore:
Timestamp:
06/24/2026 11:44:23 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
47a0d29
Parents:
eb852c5
Message:

feat(activitypub): inbound interactions — replies, likes, boosts (Phase 2)

Inbox now stores incoming Create(reply to our note), Like and Announce (boost),
plus Undo(Like/Announce) and Delete(reply). New ap_interactions table; the post
page shows a 'From the fediverse' section (counts + replies with avatar/handle,
sanitized). Actor name/icon resolved best-effort; reply HTML sanitized.

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

Location:
src
Files:
7 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    reb852c5 rc16e0a5  
    44004400    }
    44014401}
     4402
     4403/* === Fediverse interactions (inbound AP replies/likes/boosts) === */
     4404.post-fediverse { margin: 2rem 0; }
     4405.post-fediverse .fedi-heading { font-size: 1.15rem; margin: 0 0 .5rem; }
     4406.post-fediverse .fedi-stats { display: flex; gap: 1.1rem; color: var(--ink-soft, #777); font-size: .95rem; margin: 0 0 1rem; }
     4407.post-fediverse .fedi-replies { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1rem; }
     4408.post-fediverse .fedi-reply { display: flex; gap: .75rem; }
     4409.post-fediverse .fedi-handle { color: var(--ink-soft, #888); font-size: .85rem; }
     4410.post-fediverse .comment-content { margin-top: .15rem; }
  • src/config/database.js

    reb852c5 rc16e0a5  
    316316    );
    317317    CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
     318    CREATE TABLE IF NOT EXISTS ap_interactions (
     319      id INTEGER PRIMARY KEY AUTOINCREMENT,
     320      kind TEXT NOT NULL,                   -- 'reply' | 'like' | 'announce'
     321      post_id TEXT NOT NULL,
     322      object_uri TEXT NOT NULL DEFAULT '',  -- remote note id (reply) or '' (like/announce)
     323      actor_uri TEXT NOT NULL,
     324      actor_name TEXT,
     325      actor_handle TEXT,
     326      actor_url TEXT,
     327      actor_icon TEXT,
     328      content TEXT,                         -- sanitized HTML (reply)
     329      published TEXT,
     330      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     331      UNIQUE(kind, post_id, actor_uri, object_uri)
     332    );
     333    CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
    318334  `);
    319335}
  • src/routes/posts.js

    reb852c5 rc16e0a5  
    748748    db.prepare('SELECT 1 FROM post_likes WHERE post_id = ? AND user_id = ?').get(post.id, req.session.user.id));
    749749
     750  // Inbound fediverse activity (replies/likes/boosts) for this post.
     751  let fediverse = { replies: [], likeCount: 0, announceCount: 0, total: 0 };
     752  try { fediverse = ActivityPubService.getInteractions(post.id); } catch { /* non-fatal */ }
     753
    750754  renderPage(req, res, 'pages/post', {
    751755    post,
     
    755759    comments: topLevel,
    756760    totalComments,
     761    fediverse,
    757762    likeCount,
    758763    likedByMe,
  • src/services/ActivityPubService.js

    reb852c5 rc16e0a5  
    1818import crypto from 'crypto';
    1919import db from '../config/database.js';
     20import HtmlSanitizerService from './HtmlSanitizerService.js';
    2021
    2122const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
     
    190191export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
    191192
     193// ── inbound interactions store (replies / likes / boosts), lazy stmts ──
     194let _insI, _delLA, _delReply, _listI;
     195function iStmts() {
     196  if (!_insI) {
     197    _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, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     198    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
     199    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
     200    _listI = db.prepare('SELECT kind, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
     201  }
     202  return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI };
     203}
     204
     205const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
     206// Extract our local post id from a note URL, but only if it's ours (base match).
     207function postIdFromNoteUrl(url, base) {
     208  const s = String(url || '');
     209  if (base && !s.startsWith(base)) return null;
     210  const m = s.match(/\/ap\/notes\/([^/?#]+)/);
     211  return m ? decodeURIComponent(m[1]) : null;
     212}
     213function deriveHandle(actorUri) {
     214  try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
     215}
     216function actorInfo(doc, actorUri) {
     217  let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
     218  const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
     219  const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
     220  return {
     221    name: (doc && (doc.name || doc.preferredUsername)) || handle,
     222    handle,
     223    url: (doc && (doc.url || doc.id)) || actorUri,
     224    icon: icon || null,
     225  };
     226}
     227
     228// Stored, view-ready summary of a post's inbound fediverse activity.
     229export function getInteractions(postId) {
     230  const rows = iStmts().list.all(postId);
     231  return {
     232    replies: rows.filter((r) => r.kind === 'reply'),
     233    likeCount: rows.filter((r) => r.kind === 'like').length,
     234    announceCount: rows.filter((r) => r.kind === 'announce').length,
     235    total: rows.length,
     236  };
     237}
     238
    192239// ── HTTP Signatures + delivery ────────────────────────────────────
    193240const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
     
    263310    return 202;
    264311  }
    265   if (type === 'Undo' && act.object && act.object.type === 'Follow') {
     312  if (type === 'Undo' && act.object) {
    266313    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    267     const obj = act.object.object;
    268     const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
    269     if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
     314    const ot = act.object.type;
     315    if (ot === 'Follow') {
     316      const obj = act.object.object;
     317      const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
     318      if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
     319      return 202;
     320    }
     321    if (ot === 'Like' || ot === 'Announce') {
     322      const tgt = act.object.object;
     323      const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
     324      if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
     325      return 202;
     326    }
    270327    return 202;
    271328  }
     329
     330  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     331  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
     332
     333  // Inbound reply: a Create whose object replies to one of our notes.
     334  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
     335    const o = act.object;
     336    const pid = postIdFromNoteUrl(o.inReplyTo, base);
     337    if (pid && actorUri && localPostExists(pid)) {
     338      const ai = actorInfo(await resolveActor(actorUri), actorUri);
     339      const html = HtmlSanitizerService.sanitize(o.content || '');
     340      iStmts().ins.run('reply', pid, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null);
     341      console.log('[AP] reply', actorUri, '→', pid);
     342    }
     343    return 202;
     344  }
     345  if (type === 'Like' || type === 'Announce') {
     346    const tgt = act.object;
     347    const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
     348    if (pid && actorUri && localPostExists(pid)) {
     349      const ai = actorInfo(await resolveActor(actorUri), actorUri);
     350      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null);
     351      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
     352    }
     353    return 202;
     354  }
     355  if (type === 'Delete') {
     356    // A remote reply was deleted upstream → drop it if we stored it.
     357    const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
     358    if (oid) iStmts().delReply.run(oid);
     359    return 202;
     360  }
     361
    272362  console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
    273363  return 202;
     
    313403  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
    314404  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
     405  getInteractions,
    315406};
  • src/services/i18n.js

    reb852c5 rc16e0a5  
    107107    'comments.heading_one': '{n} reactie', 'comments.heading_other': '{n} reacties',
    108108    'comments.empty': 'Nog geen reacties.',
     109    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
    109110    'comments.to_start': 'om de conversatie te starten.',
    110111    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
     
    10991100    'comments.heading_one': '{n} comment', 'comments.heading_other': '{n} comments',
    11001101    'comments.empty': 'No comments yet.',
     1102    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
    11011103    'comments.to_start': 'to start the conversation.',
    11021104    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
     
    20892091    'comments.heading_one': '{n} Kommentar', 'comments.heading_other': '{n} Kommentare',
    20902092    'comments.empty': 'Noch keine Kommentare.',
     2093    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
    20912094    'comments.to_start': 'um das Gespräch zu starten.',
    20922095    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
  • src/views/pages/post.ejs

    reb852c5 rc16e0a5  
    7070      <% } %>
    7171    </aside>
     72  <% } %>
     73
     74  <!-- Fediverse interactions (inbound replies / likes / boosts via ActivityPub) -->
     75  <% if (typeof fediverse !== 'undefined' && fediverse && fediverse.total > 0) { %>
     76    <section class="post-fediverse" id="fediverse">
     77      <h2 class="fedi-heading"><%= t('fedi.heading') %></h2>
     78      <p class="fedi-stats">
     79        <span title="<%= t('fedi.likes') %>">⭐ <%= fediverse.likeCount %></span>
     80        <span title="<%= t('fedi.boosts') %>">🔁 <%= fediverse.announceCount %></span>
     81        <span title="<%= t('fedi.replies') %>">💬 <%= fediverse.replies.length %></span>
     82      </p>
     83      <% if (fediverse.replies.length) { %>
     84        <ol class="fedi-replies">
     85          <% fediverse.replies.forEach(function(r) { %>
     86            <li class="fedi-reply">
     87              <div class="comment-avatar">
     88                <% if (r.actor_icon) { %><img src="<%= r.actor_icon %>" alt="" loading="lazy">
     89                <% } else { %><span class="comment-avatar-fallback"><%= (r.actor_name || '?').charAt(0).toUpperCase() %></span><% } %>
     90              </div>
     91              <div class="comment-body">
     92                <div class="comment-meta">
     93                  <a class="comment-author" href="<%= r.actor_url %>" rel="nofollow noopener" target="_blank"><%= r.actor_name %></a>
     94                  <span class="fedi-handle"><%= r.actor_handle %></span>
     95                  <% if (r.published || r.created_at) { %><span class="comment-time"><%= formatDateTime(r.published || r.created_at) %></span><% } %>
     96                </div>
     97                <div class="comment-content"><%- r.content %></div>
     98              </div>
     99            </li>
     100          <% }); %>
     101        </ol>
     102      <% } %>
     103    </section>
    72104  <% } %>
    73105
  • src/views/shell.ejs

    reb852c5 rc16e0a5  
    175175
    176176<!-- v9 stylesheet (full palette system) -->
    177 <link rel="stylesheet" href="/assets/css/style.css?v=34">
     177<link rel="stylesheet" href="/assets/css/style.css?v=35">
    178178
    179179<!-- Audio player styles: loaded on every page so the mini-player works
Note: See TracChangeset for help on using the changeset viewer.