Changeset 7d932ce in Klonkt
- Timestamp:
- 06/24/2026 01:24:23 PM (3 months ago)
- Branches:
- main
- Children:
- a885afc
- Parents:
- cc24fa1
- Location:
- src
- Files:
-
- 1 added
- 6 edited
-
config/database.js (modified) (2 diffs)
-
routes/posts.js (modified) (3 diffs)
-
services/ActivityPubService.js (modified) (8 diffs)
-
services/i18n.js (modified) (3 diffs)
-
views/pages/authorize-interaction.ejs (modified) (1 diff)
-
views/pages/post.ejs (modified) (2 diffs)
-
views/partials/fedi-node.ejs (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
rcc24fa1 r7d932ce 328 328 content TEXT, -- sanitized HTML (reply) 329 329 published TEXT, 330 parent_uri TEXT, -- the note this reply replies to (for nesting) 330 331 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 331 332 UNIQUE(kind, post_id, actor_uri, object_uri) … … 345 346 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id); 346 347 `); 348 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs) 347 349 } 348 350 -
src/routes/posts.js
rcc24fa1 r7d932ce 86 86 'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml', 87 87 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets', 88 'authorize_interaction', 88 'authorize_interaction', 'fediverse', 89 89 ]); 90 90 … … 542 542 } 543 543 res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri)); 544 }); 545 546 // Manage / delete your own outbound fediverse replies (site owner only). 547 router.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 556 router.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`); 544 563 }); 545 564 … … 783 802 db.prepare('SELECT 1 FROM post_likes WHERE post_id = ? AND user_id = ?').get(post.id, req.session.user.id)); 784 803 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 */ } 788 810 // Owner/admin of this site may reply back to a fediverse interaction. 789 811 const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site)); -
src/services/ActivityPubService.js
rcc24fa1 r7d932ce 198 198 function iStmts() { 199 199 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)'); 201 201 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?'); 202 202 _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'); 204 204 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?'); 205 205 _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)'); … … 235 235 } 236 236 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. 239 function 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. 252 export function getInteractions(postId, base) { 239 253 const s = iStmts(); 240 254 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 245 287 return { 246 replies: rows.filter((r) => r.kind === 'reply'), 247 outReplies, 288 thread: tops, 248 289 likeCount: rows.filter((r) => r.kind === 'like').length, 249 290 announceCount: rows.filter((r) => r.kind === 'announce').length, 250 total: rows.length + outReplies.length,291 total: nodes.length, 251 292 }; 252 293 } … … 346 387 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null)); 347 388 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). 349 390 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) { 350 391 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) { 353 394 const ai = actorInfo(await resolveActor(actorUri), actorUri); 354 395 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); 357 398 } 358 399 return 202; … … 363 404 if (pid && actorUri && localPostExists(pid)) { 364 405 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); 366 407 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid); 367 408 } … … 475 516 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); 476 517 } 518 if (parent.threadInbox) inboxes.add(parent.threadInbox); // post author's server (nesting) 477 519 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); 478 520 let delivered = 0; … … 495 537 const actor = await fetchActor(actorUri).catch(() => null); 496 538 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 } 497 551 const rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, ''); 498 552 const images = (Array.isArray(note.attachment) ? note.attachment : []) … … 509 563 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized 510 564 images, 565 threadInbox, // post author's inbox (if a comment) 511 566 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240), 512 567 }; 568 } 569 570 // List a site's own outbound fediverse replies (for the manage/delete view). 571 export 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. 576 export 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; 513 592 } 514 593 … … 518 597 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, 519 598 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote, 599 listOutbox, deliverOutboxDelete, 520 600 }; -
src/services/i18n.js
rcc24fa1 r7d932ce 109 109 'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse', 110 110 '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.', 112 112 'comments.to_start': 'om de conversatie te starten.', 113 113 'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren', … … 1104 1104 'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse', 1105 1105 '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.', 1107 1107 'comments.to_start': 'to start the conversation.', 1108 1108 'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel', … … 2097 2097 'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse', 2098 2098 '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.', 2100 2100 'comments.to_start': 'um das Gespräch zu starten.', 2101 2101 'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen', -
src/views/pages/authorize-interaction.ejs
rcc24fa1 r7d932ce 1 1 <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) { %> 3 21 <h1 class="auth-interact-title"><%= t('fedi.remote_sent_title') %></h1> 4 22 <p class="auth-interact-note"><%= t('fedi.remote_sent') %></p> -
src/views/pages/post.ejs
rcc24fa1 r7d932ce 72 72 <% } %> 73 73 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) --> 76 75 <% if (typeof fediverse !== 'undefined' && fediverse && fediverse.total > 0) { %> 77 76 <section class="post-fediverse post-comments" id="fediverse"> … … 80 79 <span title="<%= t('fedi.likes') %>">⭐ <%= fediverse.likeCount %></span> 81 80 <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> 83 82 </p> 84 <% if (fediverse. replies.length) { %>83 <% if (fediverse.thread && fediverse.thread.length) { %> 85 84 <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> 139 87 <% }); %> 140 88 </ol>
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)