Changeset 1485933 in Klonkt
- Timestamp:
- 07/20/2026 07:35:55 AM (7 weeks ago)
- Branches:
- main
- Children:
- 520e477
- Parents:
- 7b04d3b
- git-author:
- Robin <roboburr@…> (07/20/2026 07:18:02 AM)
- git-committer:
- Robin <roboburr@…> (07/20/2026 07:35:55 AM)
- Files:
-
- 2 added
- 4 edited
-
src/routes/posts.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (5 diffs)
-
src/views/pages/messages.ejs (modified) (4 diffs)
-
src/views/partials/messages-append.ejs (added)
-
src/views/partials/msg-item.ejs (added)
-
test/feed-pagination.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/posts.js
r7b04d3b r1485933 777 777 router.get('/messages', requireSiteManager, (req, res) => { 778 778 const site = res.locals.site; 779 const items = site ? ActivityPubService.getMessages(site.slug, 80) : []; 779 const append = req.query.append === '1'; 780 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0); 781 const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []; 782 const hasMore = page.length > FEED_PAGE; 783 const items = page.slice(0, FEED_PAGE); 780 784 // Read the watermark BEFORE marking seen → unread dots on items newer than last visit. 781 785 const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0; 782 if (site && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug); 786 // Only stamp "seen" on the first page load (not on Load-more appends). 787 if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug); 788 const moreBase = res.locals.siteUrlBase || ''; 789 if (append) { 790 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase }); 791 } 783 792 renderPage(req, res, 'pages/messages', { 784 793 pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt, 794 hasMore, nextOffset: offset + FEED_PAGE, moreBase, 785 795 success: req.query.success || null, error: req.query.error || null, 786 796 }); -
src/services/ActivityPubService.js
r7b04d3b r1485933 507 507 // outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into 508 508 // one grouped item (actors list + count) so activity doesn't drown out conversations. 509 export function getMessages(slug, limit) { 510 const items = getNotifications(slug, Math.max(120, (limit || 60))); 509 export function getMessages(slug, limit, offset) { 510 const off = Math.max(0, offset || 0); 511 const lim = limit || 60; 512 // The stream is grouped (consecutive likes/boosts collapse), so paging is done by 513 // recomputing the whole stream top-down and slicing [off, off+lim] — stable across 514 // pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page. 515 const need = off + lim + 100; 516 const items = getNotifications(slug, need); 511 517 try { 512 for (const m of listOutbox(slug).slice(0, 80)) {518 for (const m of listOutbox(slug).slice(0, need)) { 513 519 items.push({ 514 520 type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to, … … 530 536 out.push(it); 531 537 } 532 return out.slice( 0, limit || 60);538 return out.slice(off, off + lim); 533 539 } 534 540 … … 2660 2666 // Notifications inbox: new followers + replies/likes/boosts on this site's posts. 2661 2667 export function getNotifications(slug, limit) { 2668 // Per-source cap scales with the requested limit so Messages can page deep 2669 // (Load more). Bounded so a huge offset can't ask for unbounded rows. 2670 const L = Math.min(1000, Math.max(80, limit || 60)); 2662 2671 const out = []; 2663 2672 try { 2664 for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT 50').all(slug)) {2673 for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) { 2665 2674 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at }); 2666 2675 } … … 2672 2681 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id 2673 2682 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?) 2674 ORDER BY i.created_at DESC LIMIT 802675 `).all(slug );2683 ORDER BY i.created_at DESC LIMIT ? 2684 `).all(slug, L); 2676 2685 for (const r of rows) out.push({ 2677 2686 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon, … … 2682 2691 } catch { /* ignore */ } 2683 2692 try { 2684 for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT 50').all(slug)) {2693 for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) { 2685 2694 out.push({ type: 'report', name: r.actor_name, handle: r.actor_handle, url: r.actor_uri, icon: r.actor_icon, content: r.content, created_at: r.created_at }); 2686 2695 } 2687 2696 } catch { /* ignore */ } 2688 2697 try { 2689 for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT 50').all(slug)) {2698 for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) { 2690 2699 out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, created_at: r.created_at }); 2691 2700 } -
src/views/pages/messages.ejs
r7b04d3b r1485933 22 22 <p class="msg-empty"><%= t('msg.empty') %></p> 23 23 <% } else { %> 24 <ul class="msg-list" data-show="all"> 25 <% items.forEach(function(n){ 26 var _t = n.type === 'announce' ? 'boost' : n.type; // reply|mention|report|like|boost|follow|sent 27 var _new = _seen && n.created_at ? (Date.parse(n.created_at) > _seen) : false; 28 var _who = n.name || n.handle || ''; 29 var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct'); 30 // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies; 31 // Activity = likes/boosts/follows; Moderation = reports; Sent = your 32 // replies; Conversations = every other (public) reply. 33 var _kind = _t === 'sent' ? 'sent' 34 : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 35 : _t === 'report' ? 'mod' 36 : (_t === 'mention' || _priv0) ? 'msgs' 37 : 'conv'; 38 var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase(); 39 var _priv = (n.visibility === 'followers' || n.visibility === 'direct'); 40 %> 41 <li class="msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>" data-who="<%= String(_who).toLowerCase() %>"> 42 <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true"> 43 <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> 44 <% } else if (_t === 'poll_done') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg> 45 <% } else if (n.icon) { %><img src="<%= avatar(n.icon, 96) %>" alt="" loading="lazy"> 46 <% } else { %><%= _init %><% } %> 47 <span class="msg-dot msg-dot-<%= _t %>"> 48 <% if (_t === 'like') { %><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg> 49 <% } else if (_t === 'boost') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> 50 <% } else if (_t === 'follow') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg> 51 <% } else if (_t === 'report') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg> 52 <% } else if (_t === 'mention') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg> 53 <% } else if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg> 54 <% } else if (_t === 'poll_done') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg> 55 <% } else { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><% } %> 56 </span> 57 </span> 58 59 <div class="msg-body"> 60 <div class="msg-line"> 61 <% if (_t === 'sent') { %> 62 <span class="msg-who"><%= t('msg.you') %></span> 63 <% if (n.to_handle) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %> 64 <% } else { %> 65 <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%= _who %></a> 66 <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %> 67 <% } %> 68 <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> 69 <% if (n.created_at) { %><span class="msg-time"><%= formatDateTime(n.created_at) %></span><% } %> 70 </div> 71 72 <div class="msg-what"> 73 <% if (n.count && n.count > 1) { %> 74 <strong><%= t('msg.and_more', { n: n.count - 1 }) %></strong> 75 <%= _t === 'like' ? t('msg.liked_many') : t('msg.boosted_many') %> 76 <% } else if (_t === 'follow') { %><%= t('notif.followed') %> 77 <% } else if (_t === 'like') { %><%= t('notif.liked') %> 78 <% } else if (_t === 'boost') { %><%= t('notif.boosted') %> 79 <% } else if (_t === 'report') { %><%= t('notif.reported') %> 80 <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %> 81 <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %> 82 <% } else if (_t === 'poll_done') { %><strong><%= t('msg.poll_done') %></strong> 83 <% } else { %><%= t('notif.replied') %><% } %> 84 <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %> 85 <% if (n.post_slug) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %> 86 <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 87 <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 88 </div> 89 90 <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div> 91 <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content"><%- n.content %></div> 92 <% } else if (_t === 'poll_done' && n.poll) { %> 93 <div class="msg-poll" role="group"> 94 <% n.poll.options.forEach(function (o) { %> 95 <div class="msg-poll-opt"> 96 <div class="msg-poll-top"><span class="msg-poll-name"><%= o.name %></span><span class="msg-poll-pct"><%= o.pct %>%</span></div> 97 <div class="msg-poll-bar"><span style="width:<%= o.pct %>%"></span></div> 98 </div> 99 <% }); %> 100 <div class="msg-poll-total"><%= t('msg.poll_total', { n: n.poll.voters }) %></div> 101 </div> 102 <% } %> 103 104 <% if (_t === 'sent' && n.outboxId) { %> 105 <div class="msg-actions"> 106 <details class="msg-edit"> 107 <summary><%= t('fedi.edit') %></summary> 108 <div class="msg-edit-form"> 109 <%- include('../partials/reply-editor', { 110 action: '/fediverse/' + n.outboxId + '/edit', 111 placeholder: t('fedi.reply_ph'), 112 submitLabel: t('fedi.save_edit'), 113 rows: 2, 114 initialHtml: n.content, 115 initialText: n.editable, 116 defaultLang: n.language, 117 noAttach: true, 118 }) %> 119 </div> 120 </details> 121 <form method="post" action="/fediverse/<%= n.outboxId %>/delete" data-confirm="<%= t('fedi.delete_confirm') %>"> 122 <button type="submit" class="msg-del"><%= t('comments.delete') %></button> 123 </form> 124 </div> 125 <% } %> 126 </div> 127 </li> 128 <% }); %> 24 <ul class="msg-list" data-show="all" id="msg-list"> 25 <% items.forEach(function(n){ %><%- include('../partials/msg-item', { n: n, seen: _seen }) %><% }); %> 129 26 </ul> 130 27 <p class="msg-nomatch" hidden><%= t('msg.no_match') %></p> 28 <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#msg-list', morePath: '/messages' }) %> 131 29 <% } %> 132 30 … … 152 50 var q = document.getElementById('msg-q'); 153 51 var kind = 'all'; 154 var items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) :[];155 items.forEach(function(li) {156 // Index once: sender + message body + linked post title .52 var items = []; 53 function indexItem(li) { 54 // Index once: sender + message body + linked post title + poll text. 157 55 var body = li.querySelector('.msg-content'); 158 56 var post = li.querySelector('.msg-post'); … … 161 59 (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' + 162 60 (poll ? poll.textContent : '')).toLowerCase(); 163 }); 61 } 62 // Re-collect + index; called on load and after each "Load more" append so new 63 // items join the filter/search (and inherit the active chip via apply()). 64 function reindex() { 65 items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) : []; 66 items.forEach(function (li) { if (!li._search) indexItem(li); }); 67 } 68 reindex(); 164 69 function apply() { 165 70 if (!list) return; … … 181 86 }); 182 87 if (q) q.addEventListener('input', apply); 88 // After a "Load more" append (htmx), index the new rows and re-apply the filter. 89 document.body.addEventListener('htmx:afterSettle', function (e) { 90 if (e.target && e.target.id === 'msg-list') { reindex(); apply(); } 91 }); 183 92 184 93 var a = document.getElementById('fedi-bm-btn'); -
test/feed-pagination.test.js
r7b04d3b r1485933 41 41 assert.equal(AP.getTimeline('me', 72, 300).length, 0); 42 42 }); 43 44 // getMessages pages the merged stream by offset (recompute-top-down + slice). 45 const fins = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, created_at) VALUES (?,?,?)'); 46 for (let i = 0; i < 150; i++) { 47 const n = String(i).padStart(3, '0'); 48 const hh = String(23 - Math.floor(i / 60)).padStart(2, '0'); 49 const mm = String(59 - (i % 60)).padStart(2, '0'); 50 fins.run('me', 'https://r.test/u/f' + n, `2026-02-01 ${hh}:${mm}:00`); 51 } 52 53 test('getMessages pages the stream by offset without overlap', () => { 54 const p1 = AP.getMessages('me', 72, 0); 55 const p2 = AP.getMessages('me', 72, 72); 56 assert.equal(p1.length, 72); 57 assert.equal(p2.length, 72); 58 const k = (m) => m.type + '|' + (m.url || m.handle || m.outboxId || ''); 59 const set1 = new Set(p1.map(k)); 60 assert.equal(p2.filter((m) => set1.has(k(m))).length, 0, 'no overlap between pages'); 61 }); 62 63 test('getMessages probe of PAGE+1 signals the last page', () => { 64 assert.equal(AP.getMessages('me', 73, 0).length, 73); // more remain 65 assert.equal(AP.getMessages('me', 73, 144).length, 6); // 150 follows → 6 left 66 });
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)