Changeset c16e0a5 in Klonkt
- Timestamp:
- 06/24/2026 11:44:23 AM (3 months ago)
- Branches:
- main
- Children:
- 47a0d29
- Parents:
- eb852c5
- Location:
- src
- Files:
-
- 7 edited
-
assets/css/style.css (modified) (1 diff)
-
config/database.js (modified) (1 diff)
-
routes/posts.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (4 diffs)
-
services/i18n.js (modified) (3 diffs)
-
views/pages/post.ejs (modified) (1 diff)
-
views/shell.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/css/style.css
reb852c5 rc16e0a5 4400 4400 } 4401 4401 } 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 316 316 ); 317 317 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); 318 334 `); 319 335 } -
src/routes/posts.js
reb852c5 rc16e0a5 748 748 db.prepare('SELECT 1 FROM post_likes WHERE post_id = ? AND user_id = ?').get(post.id, req.session.user.id)); 749 749 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 750 754 renderPage(req, res, 'pages/post', { 751 755 post, … … 755 759 comments: topLevel, 756 760 totalComments, 761 fediverse, 757 762 likeCount, 758 763 likedByMe, -
src/services/ActivityPubService.js
reb852c5 rc16e0a5 18 18 import crypto from 'crypto'; 19 19 import db from '../config/database.js'; 20 import HtmlSanitizerService from './HtmlSanitizerService.js'; 20 21 21 22 const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'; … … 190 191 export function followerCount(slug) { return fStmts().cnt.get(slug).n; } 191 192 193 // ── inbound interactions store (replies / likes / boosts), lazy stmts ── 194 let _insI, _delLA, _delReply, _listI; 195 function 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 205 const 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). 207 function 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 } 213 function 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 } 216 function 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. 229 export 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 192 239 // ── HTTP Signatures + delivery ──────────────────────────────────── 193 240 const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; }; … … 263 310 return 202; 264 311 } 265 if (type === 'Undo' && act.object && act.object.type === 'Follow') {312 if (type === 'Undo' && act.object) { 266 313 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 } 270 327 return 202; 271 328 } 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 272 362 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)'); 273 363 return 202; … … 313 403 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, 314 404 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, 405 getInteractions, 315 406 }; -
src/services/i18n.js
reb852c5 rc16e0a5 107 107 'comments.heading_one': '{n} reactie', 'comments.heading_other': '{n} reacties', 108 108 'comments.empty': 'Nog geen reacties.', 109 'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse', 109 110 'comments.to_start': 'om de conversatie te starten.', 110 111 'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren', … … 1099 1100 'comments.heading_one': '{n} comment', 'comments.heading_other': '{n} comments', 1100 1101 'comments.empty': 'No comments yet.', 1102 'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse', 1101 1103 'comments.to_start': 'to start the conversation.', 1102 1104 'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel', … … 2089 2091 'comments.heading_one': '{n} Kommentar', 'comments.heading_other': '{n} Kommentare', 2090 2092 'comments.empty': 'Noch keine Kommentare.', 2093 'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse', 2091 2094 'comments.to_start': 'um das Gespräch zu starten.', 2092 2095 'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen', -
src/views/pages/post.ejs
reb852c5 rc16e0a5 70 70 <% } %> 71 71 </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> 72 104 <% } %> 73 105 -
src/views/shell.ejs
reb852c5 rc16e0a5 175 175 176 176 <!-- v9 stylesheet (full palette system) --> 177 <link rel="stylesheet" href="/assets/css/style.css?v=3 4">177 <link rel="stylesheet" href="/assets/css/style.css?v=35"> 178 178 179 179 <!-- Audio player styles: loaded on every page so the mini-player works
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)