Changeset 7d932ce in Klonkt


Ignore:
Timestamp:
06/24/2026 01:24:23 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
a885afc
Parents:
cc24fa1
Message:

feat(activitypub): nested threading + delete your own replies

Inbox now stores replies-to-comments nested (findThreadTarget resolves the post
via the parent comment); getInteractions builds a thread tree shown on the post
(fedi-node partial). deliverReply also cc's the post author's inbox so their
Klonkt threads it. Owners can delete their outbound replies (Delete+Tombstone)
inline + via /fediverse manage list. Schema: ap_interactions.parent_uri.

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

Location:
src
Files:
1 added
6 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rcc24fa1 r7d932ce  
    328328      content TEXT,                         -- sanitized HTML (reply)
    329329      published TEXT,
     330      parent_uri TEXT,                      -- the note this reply replies to (for nesting)
    330331      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    331332      UNIQUE(kind, post_id, actor_uri, object_uri)
     
    345346    CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
    346347  `);
     348  ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
    347349}
    348350
  • src/routes/posts.js

    rcc24fa1 r7d932ce  
    8686  'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
    8787  'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
    88   'authorize_interaction',
     88  'authorize_interaction', 'fediverse',
    8989]);
    9090
     
    542542  }
    543543  res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
     544});
     545
     546// Manage / delete your own outbound fediverse replies (site owner only).
     547router.get('/fediverse', requireSiteManager, (req, res) => {
     548  const site = res.locals.site;
     549  const items = site ? ActivityPubService.listOutbox(site.slug) : [];
     550  renderPage(req, res, 'pages/authorize-interaction', {
     551    pageTitle: 'Mijn fediverse-reacties', bodyClass: 'on-special',
     552    manage: items, uri: '', target: null, sent: false, siteTitle: site ? site.title : '',
     553  });
     554});
     555
     556router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
     557  const site = res.locals.site;
     558  if (site) {
     559    try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
     560    catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
     561  }
     562  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
    544563});
    545564
     
    783802    db.prepare('SELECT 1 FROM post_likes WHERE post_id = ? AND user_id = ?').get(post.id, req.session.user.id));
    784803
    785   // Inbound fediverse activity (replies/likes/boosts) for this post.
    786   let fediverse = { replies: [], outReplies: [], likeCount: 0, announceCount: 0, total: 0 };
    787   try { fediverse = ActivityPubService.getInteractions(post.id); } catch { /* non-fatal */ }
     804  // Inbound fediverse activity (threaded) for this post.
     805  let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
     806  try {
     807    const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
     808    fediverse = ActivityPubService.getInteractions(post.id, _apBase);
     809  } catch { /* non-fatal */ }
    788810  // Owner/admin of this site may reply back to a fediverse interaction.
    789811  const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
  • src/services/ActivityPubService.js

    rcc24fa1 r7d932ce  
    198198function iStmts() {
    199199  if (!_insI) {
    200     _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)');
     200    _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)');
    201201    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
    202202    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
    203     _listI = db.prepare('SELECT id, kind, object_uri, 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');
     203    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, 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');
    204204    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
    205205    _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)');
     
    235235}
    236236
    237 // Stored, view-ready summary of a post's inbound fediverse activity + our replies.
    238 export function getInteractions(postId) {
     237// Given an inReplyTo note URL, find which local post the thread belongs to + the
     238// note being replied to (parent), so a reply-to-a-comment can be nested.
     239function findThreadTarget(inReplyTo, base) {
     240  if (!inReplyTo) return null;
     241  const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
     242  if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
     243  if (seg) {
     244    try { const o = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(seg); if (o && o.post_id) return { post_id: o.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
     245  }
     246  try { const row = db.prepare("SELECT post_id FROM ap_interactions WHERE object_uri = ? AND kind = 'reply' LIMIT 1").get(inReplyTo); if (row && row.post_id) return { post_id: row.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
     247  return null;
     248}
     249
     250// View-ready threaded view of a post's fediverse activity (inbound replies +
     251// our outbound replies, nested), plus like/boost counts.
     252export function getInteractions(postId, base) {
    239253  const s = iStmts();
    240254  const rows = s.list.all(postId);
    241   const outReplies = s.listO.all(postId).map((o) => ({
    242     id: o.id, content: o.content, in_reply_to: o.in_reply_to, to_handle: o.to_handle,
    243     created_at: o.created_at, mine: true,
    244   }));
     255  const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     256  const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
     257
     258  const nodes = [];
     259  for (const r of rows) {
     260    if (r.kind !== 'reply') continue;
     261    nodes.push({
     262      noteId: r.object_uri, parent: r.parent_uri || null, mine: false,
     263      actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
     264      actor_icon: r.actor_icon, content: r.content, created_at: r.published || r.created_at,
     265      children: [],
     266    });
     267  }
     268  for (const o of s.listO.all(postId)) {
     269    nodes.push({
     270      noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
     271      mine: true, outboxId: o.id, content: o.content, created_at: o.created_at, children: [],
     272    });
     273  }
     274
     275  const byId = new Map(nodes.map((n) => [n.noteId, n]));
     276  const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
     277  const tops = [];
     278  for (const n of nodes) {
     279    if (isTop(n)) { tops.push(n); continue; }
     280    let anc = n, guard = 0;
     281    while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
     282    anc.children.push(n);
     283  }
     284  const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
     285  tops.sort(byTime).forEach((t) => t.children.sort(byTime));
     286
    245287  return {
    246     replies: rows.filter((r) => r.kind === 'reply'),
    247     outReplies,
     288    thread: tops,
    248289    likeCount: rows.filter((r) => r.kind === 'like').length,
    249290    announceCount: rows.filter((r) => r.kind === 'announce').length,
    250     total: rows.length + outReplies.length,
     291    total: nodes.length,
    251292  };
    252293}
     
    346387  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
    347388
    348   // Inbound reply: a Create whose object replies to one of our notes.
     389  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
    349390  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
    350391    const o = act.object;
    351     const pid = postIdFromNoteUrl(o.inReplyTo, base);
    352     if (pid && actorUri && localPostExists(pid)) {
     392    const tgt = findThreadTarget(o.inReplyTo, base);
     393    if (tgt && actorUri) {
    353394      const ai = actorInfo(await resolveActor(actorUri), actorUri);
    354395      const html = HtmlSanitizerService.sanitize(o.content || '');
    355       iStmts().ins.run('reply', pid, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null);
    356       console.log('[AP] reply', actorUri, '→', pid);
     396      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);
     397      console.log('[AP] reply', actorUri, '→', tgt.post_id);
    357398    }
    358399    return 202;
     
    363404    if (pid && actorUri && localPostExists(pid)) {
    364405      const ai = actorInfo(await resolveActor(actorUri), actorUri);
    365       iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null);
     406      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
    366407      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
    367408    }
     
    475516    if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
    476517  }
     518  if (parent.threadInbox) inboxes.add(parent.threadInbox); // post author's server (nesting)
    477519  for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
    478520  let delivered = 0;
     
    495537  const actor = await fetchActor(actorUri).catch(() => null);
    496538  const ai = actorInfo(actor, actorUri);
     539  // If this note is itself a reply (a comment), also reach the original post's
     540  // author so THEIR server threads our reply under the comment.
     541  let threadInbox = null;
     542  if (note.inReplyTo) {
     543    const parentUrl = typeof note.inReplyTo === 'string' ? note.inReplyTo : (note.inReplyTo && note.inReplyTo.id);
     544    const parentNote = parentUrl ? await fetchActor(parentUrl).catch(() => null) : null;
     545    const pAtt = parentNote && (typeof parentNote.attributedTo === 'string' ? parentNote.attributedTo : (parentNote.attributedTo && parentNote.attributedTo.id));
     546    if (pAtt && pAtt !== actorUri) {
     547      const pa = await fetchActor(pAtt).catch(() => null);
     548      threadInbox = pa && ((pa.endpoints && pa.endpoints.sharedInbox) || pa.inbox);
     549    }
     550  }
    497551  const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
    498552  const images = (Array.isArray(note.attachment) ? note.attachment : [])
     
    509563    content: HtmlSanitizerService.sanitize(rawHtml),       // full, sanitized
    510564    images,
     565    threadInbox,                                            // post author's inbox (if a comment)
    511566    preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
    512567  };
     568}
     569
     570// List a site's own outbound fediverse replies (for the manage/delete view).
     571export function listOutbox(siteSlug) {
     572  return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC').all(siteSlug);
     573}
     574
     575// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
     576export async function deliverOutboxDelete(site, outboxId) {
     577  const row = iStmts().getO.get(outboxId);
     578  if (!row || row.site_slug !== site.slug) return false;
     579  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     580  if (base) {
     581    const me = actorId(base, site.slug);
     582    const nid = noteId(base, row.id);
     583    const del = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nid}#delete-${Date.now()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
     584    const keys = getOrCreateKeys(site.slug);
     585    const inboxes = new Set();
     586    if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
     587    for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
     588    for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
     589  }
     590  db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
     591  return true;
    513592}
    514593
     
    518597  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
    519598  getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
     599  listOutbox, deliverOutboxDelete,
    520600};
  • src/services/i18n.js

    rcc24fa1 r7d932ce  
    109109    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
    110110    'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij',
    111     'fedi.remote_title': 'Reageer via de fediverse', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres (bv. @jij@mastodon.social):', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is onderweg naar de fediverse en verschijnt zo bij de ontvanger.', 'fedi.remote_back': '← Terug naar je site',
     111    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres (bv. @jij@mastodon.social):', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is onderweg naar de fediverse en verschijnt zo bij de ontvanger.', 'fedi.remote_back': '← Terug naar je site', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.',
    112112    'comments.to_start': 'om de conversatie te starten.',
    113113    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
     
    11041104    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
    11051105    'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You',
    1106     'fedi.remote_title': 'Reply via the fediverse', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address (e.g. @you@mastodon.social):', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply is on its way to the fediverse and will appear for the recipient shortly.', 'fedi.remote_back': '← Back to your site',
     1106    'fedi.remote_title': 'Reply via the fediverse', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address (e.g. @you@mastodon.social):', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply is on its way to the fediverse and will appear for the recipient shortly.', 'fedi.remote_back': '← Back to your site', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.',
    11071107    'comments.to_start': 'to start the conversation.',
    11081108    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
     
    20972097    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
    20982098    'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du',
    2099     'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse (z.B. @du@mastodon.social):', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort ist auf dem Weg ins Fediverse und erscheint gleich beim Empfänger.', 'fedi.remote_back': '← Zurück zu deiner Seite',
     2099    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse (z.B. @du@mastodon.social):', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort ist auf dem Weg ins Fediverse und erscheint gleich beim Empfänger.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.',
    21002100    'comments.to_start': 'um das Gespräch zu starten.',
    21012101    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
  • src/views/pages/authorize-interaction.ejs

    rcc24fa1 r7d932ce  
    11<div class="auth-interact">
    2   <% if (typeof sent !== 'undefined' && sent) { %>
     2  <% if (typeof manage !== 'undefined' && manage) { %>
     3    <h1 class="auth-interact-title"><%= t('fedi.manage_title') %></h1>
     4    <% if (!manage.length) { %><p class="auth-interact-note"><%= t('fedi.manage_empty') %></p><% } %>
     5    <ol class="comments-list">
     6      <% manage.forEach(function(m){ %>
     7        <li class="comment">
     8          <div class="comment-body">
     9            <div class="comment-content"><%- m.content %></div>
     10            <div class="comment-actions">
     11              <% if (m.to_handle) { %><span class="fedi-handle">→ <%= m.to_handle %></span><% } %>
     12              <form method="post" action="/fediverse/<%= m.id %>/delete" onsubmit="return confirm('<%= t('fedi.delete_confirm') %>')">
     13                <button type="submit" class="comment-delete-btn"><%= t('comments.delete') %></button>
     14              </form>
     15            </div>
     16          </div>
     17        </li>
     18      <% }); %>
     19    </ol>
     20  <% } else if (typeof sent !== 'undefined' && sent) { %>
    321    <h1 class="auth-interact-title"><%= t('fedi.remote_sent_title') %></h1>
    422    <p class="auth-interact-note"><%= t('fedi.remote_sent') %></p>
  • src/views/pages/post.ejs

    rcc24fa1 r7d932ce  
    7272  <% } %>
    7373
    74   <!-- Fediverse interactions (inbound replies / likes / boosts via ActivityPub) -->
    75   <!-- Mirrors the native comment markup/classes so it looks identical. -->
     74  <!-- Fediverse interactions (threaded: inbound replies/likes/boosts + our replies) -->
    7675  <% if (typeof fediverse !== 'undefined' && fediverse && fediverse.total > 0) { %>
    7776    <section class="post-fediverse post-comments" id="fediverse">
     
    8079        <span title="<%= t('fedi.likes') %>">⭐ <%= fediverse.likeCount %></span>
    8180        <span title="<%= t('fedi.boosts') %>">🔁 <%= fediverse.announceCount %></span>
    82         <span title="<%= t('fedi.replies') %>">💬 <%= fediverse.replies.length %></span>
     81        <span title="<%= t('fedi.replies') %>">💬 <%= (fediverse.thread || []).length %></span>
    8382      </p>
    84       <% if (fediverse.replies.length) { %>
     83      <% if (fediverse.thread && fediverse.thread.length) { %>
    8584        <ol class="comments-list">
    86           <% fediverse.replies.forEach(function(r) { %>
    87             <li class="comment">
    88               <div class="comment-avatar">
    89                 <% if (r.actor_icon) { %><img src="<%= r.actor_icon %>" alt="" loading="lazy">
    90                 <% } else { %><span><%= (r.actor_name || '?').charAt(0).toUpperCase() %></span><% } %>
    91               </div>
    92               <div class="comment-body">
    93                 <div class="comment-meta">
    94                   <a class="comment-author" href="<%= r.actor_url %>" rel="nofollow noopener" target="_blank"><%= r.actor_name %></a>
    95                   <span class="fedi-handle"><%= r.actor_handle %></span>
    96                   <% if (r.published || r.created_at) { %><span class="comment-time"><%= formatDateTime(r.published || r.created_at) %></span><% } %>
    97                 </div>
    98                 <div class="comment-content"><%- r.content %></div>
    99 
    100                 <% if (typeof canManageSite !== 'undefined' && canManageSite) { %>
    101                   <details class="fedi-replybox">
    102                     <summary class="comment-reply-btn fedi-reply-toggle"><%= t('fedi.reply') %></summary>
    103                     <form method="post" action="<%= _base %>/posts/<%= post.slug %>/fedi-reply" class="comment-reply-form">
    104                       <input type="hidden" name="interaction_id" value="<%= r.id %>">
    105                       <textarea name="text" rows="3" required placeholder="<%= t('fedi.reply_ph') %>"></textarea>
    106                       <div class="comment-reply-form-actions">
    107                         <button type="submit" class="btn btn-primary"><%= t('fedi.send') %></button>
    108                       </div>
    109                     </form>
    110                   </details>
    111                 <% } else if (r.object_uri) { %>
    112                   <div class="comment-actions">
    113                     <button type="button" class="comment-reply-btn fedi-remote-reply-btn" data-fedi-uri="<%= r.object_uri %>" data-fedi-prompt="<%= t('fedi.remote_prompt') %>">
    114                       <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
    115                       <%= t('fedi.remote_reply_short') %>
    116                     </button>
    117                   </div>
    118                 <% } %>
    119 
    120                 <% var mine = (typeof fediverse.outReplies !== 'undefined' ? fediverse.outReplies : []).filter(function(o){ return o.in_reply_to && o.in_reply_to === r.object_uri; }); %>
    121                 <% if (mine.length) { %>
    122                   <ol class="comment-replies">
    123                     <% mine.forEach(function(o){ %>
    124                       <li class="comment comment-reply">
    125                         <div class="comment-avatar">
    126                           <% if (typeof siteAvatar !== 'undefined' && siteAvatar) { %><img src="<%= siteAvatar %>" alt="">
    127                           <% } else { %><span><%= t('fedi.you').charAt(0).toUpperCase() %></span><% } %>
    128                         </div>
    129                         <div class="comment-body">
    130                           <div class="comment-meta"><span class="comment-author"><%= t('fedi.you') %></span></div>
    131                           <div class="comment-content"><%- o.content %></div>
    132                         </div>
    133                       </li>
    134                     <% }); %>
    135                   </ol>
    136                 <% } %>
    137               </div>
    138             </li>
     85          <% fediverse.thread.forEach(function(n){ %>
     86            <li class="comment"><%- include('../partials/fedi-node', { n: n }) %></li>
    13987          <% }); %>
    14088        </ol>
Note: See TracChangeset for help on using the changeset viewer.