Changeset 7875a80 in Klonkt
- Timestamp:
- 08/06/2026 01:56:08 AM (5 weeks ago)
- Branches:
- main
- Children:
- 0448e34
- Parents:
- 04aca12
- git-author:
- Robin <roboburr@…> (08/06/2026 01:55:53 AM)
- git-committer:
- Claude (agent) <aiclaude@…> (08/06/2026 01:56:08 AM)
- Files:
-
- 1 added
- 4 edited
-
src/services/ActivityPubService.js (modified) (3 diffs)
-
src/views/pages/messages.ejs (modified) (4 diffs)
-
src/views/partials/msg-item.ejs (modified) (4 diffs)
-
test/messages-conversations.test.js (added)
-
test/messages.test.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r04aca12 r7875a80 639 639 items.push({ 640 640 type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to, 641 content: m.content, editable: m.editable, language: m.language, created_at: m.created_at, 641 post_slug: m.post_slug, content: m.content, editable: m.editable, 642 language: m.language, created_at: m.created_at, 642 643 }); 643 644 } 644 645 } catch { /* ignore */ } 646 // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox 647 // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets 648 // zei alleen een slug, dus vullen we ze in één query aan. 649 try { 650 const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))]; 651 if (missing.length) { 652 const rows = db.prepare( 653 `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')}) 654 AND site_id = (SELECT id FROM sites WHERE slug = ?)`, 655 ).all(...missing, slug); 656 const byslug = new Map(rows.map((r) => [r.slug, r.title])); 657 for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null; 658 } 659 } catch { /* zonder titel valt de draad terug op de slug */ } 645 660 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications) 646 661 const out = []; … … 656 671 out.push(it); 657 672 } 658 return out.slice(off, off + lim); 673 // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot 674 // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap 675 // telt een draad als één item voor de paginering, wat klopt: je scrolt door 676 // gesprekken, niet door losse zinnen. 677 return groupConversations(out).slice(off, off + lim); 678 } 679 680 // De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie 681 // aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van 682 // de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen 683 // moest dus tussen chips heen en weer, terwijl het één draad is. 684 const CONV_TYPES = new Set(['reply', 'mention', 'sent']); 685 686 /** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt: 687 * 688 * 1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join 689 * op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is 690 * dezelfde sleutel, en daarom staan ze nu in dezelfde draad. 691 * 2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands 692 * eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad 693 * loopt per tegenpartij. 694 * 695 * De post wint van de persoon: twee mensen die onder dezelfde post reageren 696 * voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek 697 * is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid 698 * door. 699 */ 700 export function threadKey(it) { 701 if (!it || !CONV_TYPES.has(it.type)) return null; 702 if (it.post_slug) return `post:${it.post_slug}`; 703 const who = it.handle || it.to_handle || ''; 704 const norm = String(who).trim().toLowerCase().replace(/^@/, ''); 705 return norm ? `actor:${norm}` : null; 706 } 707 708 /** Vouw losse berichten samen tot draden, met alles wat geen gesprek is 709 * ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend 710 * (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn 711 * nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen 712 * de draad draait het om: een gesprek leest naar beneden, oud naar nieuw. 713 */ 714 export function groupConversations(items) { 715 const threads = new Map(); 716 const out = []; 717 for (const it of items || []) { 718 const key = threadKey(it); 719 if (!key) { out.push(it); continue; } 720 let t = threads.get(key); 721 if (!t) { 722 // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de 723 // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later. 724 t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at }; 725 threads.set(key, t); 726 out.push(t); 727 } 728 t.messages.push(it); 729 // De context bij de draad: gaat het over een post, dan hoort de link 730 // erbij, anders is een los antwoord in een lijst niet te plaatsen. 731 // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te 732 // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen 733 // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden, 734 // dan kan het ontvangen antwoord eronder de echte titel niet meer 735 // invullen. De terugval op de slug hoort in de weergave, niet in de data. 736 if (it.post_slug) { 737 if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null }; 738 else if (!t.post.title && it.post_title) t.post.title = it.post_title; 739 } 740 } 741 for (const t of threads.values()) { 742 t.messages.sort((a, b) => _msgTs(a) - _msgTs(b)); 743 t.count = t.messages.length; 744 // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij. 745 const seen = new Set(); 746 for (const m of t.messages) { 747 if (m.type === 'sent') continue; 748 const h = m.handle || m.name; 749 if (!h || seen.has(h)) continue; 750 seen.add(h); 751 t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url }); 752 } 753 // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als 754 // onbeantwoord bericht leest. 755 t.mine = t.messages.some((m) => m.type === 'sent'); 756 } 757 return out; 659 758 } 660 759 … … 2907 3006 } 2908 3007 export function listOutbox(siteSlug) { 2909 return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC') 3008 // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel 3009 // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post 3010 // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit 3011 // elkaar in "Verzonden" en "Gesprekken". 3012 return db.prepare('SELECT id, content, to_handle, to_actor, post_slug, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC') 2910 3013 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; }); 2911 3014 } -
src/views/pages/messages.ejs
r04aca12 r7875a80 25 25 26 26 <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>"> 27 <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in 28 een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde 29 draad staan. Activiteit en Moderatie blijven wat ze waren. %> 27 30 <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button> 28 <button type="button" class="msg-chip" data-show="msgs"><%= t('msg.filter_msgs') %></button>29 31 <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button> 30 32 <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button> 31 33 <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button> 32 <button type="button" class="msg-chip" data-show="sent"><%= t('msg.filter_sent') %></button>33 34 </div> 34 35 <div class="msg-search"> … … 71 72 var items = []; 72 73 function indexItem(li) { 74 // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken, 75 // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd. 76 if (li.classList.contains('msg-thread')) { 77 li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase(); 78 return; 79 } 73 80 // Index once: sender + message body + linked post title + poll text. 74 81 var body = li.querySelector('.msg-content'); … … 82 89 // items join the filter/search (and inherit the active chip via apply()). 83 90 function reindex() { 84 items = list ? Array.prototype.slice.call(list.querySelectorAll(' .msg-item')) : [];91 items = list ? Array.prototype.slice.call(list.querySelectorAll(':scope > .msg-item')) : []; 85 92 items.forEach(function (li) { if (!li._search) indexItem(li); }); 86 93 } … … 138 145 139 146 .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; } 147 148 /* Een draad is geen regel maar een blok: kop met de context, daaronder de 149 bubbels van oud naar nieuw. De verticale lijn links maakt zichtbaar dat ze 150 bij elkaar horen -- dat was precies wat ontbrak toen verzonden en ontvangen 151 in aparte chips zaten. */ 152 .msg-thread { display: block; } 153 .msg-thread-head { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; } 154 .msg-thread-who { font-weight: 600; } 155 .msg-thread-count { font-size: .78em; padding: .05rem .4rem; border-radius: 999px; 156 background: color-mix(in srgb, var(--ink, #000) 10%, transparent); } 157 .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em; 158 margin-inline-start: auto; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 159 .msg-thread-post svg { width: 13px; height: 13px; flex: none; } 160 .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .85rem; display: flex; flex-direction: column; gap: .45rem; 161 border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); } 162 .msg-sub { display: flex; gap: .8rem; align-items: flex-start; } 163 /* Jouw eigen bijdrage staat aan de andere kant van de lijn, zodat je de 164 beurten ziet zonder namen te lezen. */ 165 .msg-sub.msg-sent { flex-direction: row-reverse; text-align: end; } 166 .msg-sub.msg-sent .msg-line { justify-content: flex-end; } 140 167 141 168 .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start; -
src/views/partials/msg-item.ejs
r04aca12 r7875a80 1 <% if (n.type === 'thread') { %> 2 <% 3 var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0; 4 // Een draad is nieuw zodra EEN bericht erin nieuw is. 5 var _tnew = !!_tseen && (n.messages || []).some(function (m) { 6 return m.created_at && Date.parse(m.created_at) > _tseen; 7 }); 8 var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; }); 9 // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan 10 // je schreef, niet een lege naam. 11 var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle; 12 var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you')); 13 %> 14 <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>"> 15 <div class="msg-thread-head"> 16 <span class="msg-thread-who"><%= _tlabel %></span> 17 <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %> 18 <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> 19 <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze 20 link is een antwoord in een lijst niet te plaatsen. %> 21 <% if (n.post) { %> 22 <a class="msg-thread-post" href="/<%= n.post.slug %>"> 23 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> 24 <%= n.post.title || n.post.slug %> 25 </a> 26 <% } %> 27 </div> 28 <ul class="msg-thread-msgs"> 29 <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %> 30 </ul> 31 </li> 32 <% } else { %> 1 33 <% 2 34 var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0; … … 13 45 : (_t === 'mention' || _priv0) ? 'msgs' 14 46 : 'conv'; 47 // Binnen een draad is dit geen zelfstandige regel: de chip filtert op 48 // de draad, niet op de losse zinnen erin. 49 var _sub = (typeof inThread !== 'undefined' && inThread); 15 50 var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase(); 16 51 var _priv = (n.visibility === 'followers' || n.visibility === 'direct'); 17 52 %> 18 <li class=" msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>"data-who="<%= String(_who).toLowerCase() %>">53 <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>"> 19 54 <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true"> 20 55 <% 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> … … 65 100 <% } else { %><%= t('notif.replied') %><% } %> 66 101 <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %> 67 <% if (n.post_slug ) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>102 <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %> 68 103 <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 69 104 <% 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><% } %> … … 116 151 </div> 117 152 </li> 153 <% } %> -
test/messages.test.js
r04aca12 r7875a80 2 2 // in één stroom, groepeert opeenvolgende likes/boosts op dezelfde post, en geeft 3 3 // visibility door (voor de privé-badge). Besluit Robin+Bart 2026-07-16. 4 // 5 // Sinds Berichten gesprekken toont vouwen antwoorden, mentions en je eigen 6 // verzonden berichten samen tot draden (zie messages-conversations.test.js voor 7 // de groepeerlogica zelf); likes, boosts en follows blijven losse regels. 4 8 // 5 9 // Run: npm test (= node --test) … … 42 46 const msgs = AP.getMessages('me', 50); 43 47 44 test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => { 45 const sent = msgs.find((m) => m.type === 'sent'); 48 test('eigen outbound reply en het ontvangen antwoord staan in EEN draad', () => { 49 const thread = msgs.find((m) => m.type === 'thread'); 50 assert.ok(thread, 'draad ontbreekt'); 51 assert.equal(msgs[0], thread, 'de draad met het nieuwste bericht hoort bovenaan'); 52 const sent = thread.messages.find((m) => m.type === 'sent'); 46 53 assert.ok(sent, 'sent-item ontbreekt'); 47 54 assert.equal(sent.outboxId, 'out1'); 48 55 assert.equal(sent.to_handle, '@dana@r.test'); 49 assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan'); 56 // Hier gaat het om: Dana's antwoord en het jouwe zaten in aparte chips 57 // (Gesprekken en Verzonden) en staan nu op volgorde in dezelfde draad. 58 assert.deepEqual(thread.messages.map((m) => m.type), ['reply', 'sent']); 59 assert.equal(thread.post.slug, 'mijn-post'); 50 60 }); 51 61 … … 58 68 59 69 test('privé-reply draagt visibility voor de badge en heeft post-context', () => { 60 const reply = msgs.find((m) => m.type === 'reply'); 70 const thread = msgs.find((m) => m.type === 'thread'); 71 const reply = thread.messages.find((m) => m.type === 'reply'); 61 72 assert.equal(reply.visibility, 'direct'); 62 73 assert.equal(reply.post_slug, 'mijn-post'); 63 74 assert.equal(reply.post_title, 'Mijn post'); 75 // De context hangt ook aan de draad zelf: die voedt de link in de kop. 76 assert.deepEqual(thread.post, { slug: 'mijn-post', title: 'Mijn post' }); 64 77 }); 65 78
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)