Changeset 7875a80 in Klonkt


Ignore:
Timestamp:
08/06/2026 01:56:08 AM (5 weeks ago)
Author:
Claude (agent) <aiclaude@…>
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)
Message:

Berichten toont gesprekken: Berichten, Gesprekken en Verzonden vouwen samen

Berichten had zes chips, waarvan drie over hetzelfde gesprek gingen. Een
uitwisseling viel daardoor uit elkaar: wat jij stuurde stond onder Verzonden,
wat je terugkreeg onder Berichten of Gesprekken, en om een draad te volgen moest
je tussen chips heen en weer. Dat waren geen aparte pagina's maar client-side
filters over een enkele lijst, dus het probleem zat in het model, niet in de
weergave.

Antwoorden, mentions en je eigen verzonden berichten vouwen nu samen tot draden.
De sleutel: hangt het bericht aan een post van jou, dan is dat de draad --
ontvangen antwoorden kennen hun post via de join op posts, verzonden antwoorden
via ap_outbox.post_slug, en dat is dezelfde sleutel. Hangt het aan niets van jou
(een mention), dan loopt de draad per tegenpartij. De post wint van de persoon:
twee mensen onder dezelfde post voeren een gesprek, geen twee.

De context hoort bij de draad. Gaat het gesprek over een post, dan staat de link
ernaartoe in de kop, want zonder die context is een los antwoord in een lijst
niet te plaatsen. Binnen de draad leest het van oud naar nieuw, en jouw eigen
bijdragen staan aan de andere kant van de lijn zodat je de beurten ziet zonder
namen te lezen.

Likes, boosts, follows, reports en poll-uitslagen blijven losse regels: dat is
geen gesprek. De chips gaan van zes naar vier: Alles, Gesprekken, Activiteit,
Moderatie.

Twee dingen die daarbij moesten:

  • listOutbox selecteert post_slug (stond in de tabel, werd niet gelezen).
  • De filter kijkt naar ':scope > .msg-item', want de bubbels in een draad zijn geen zelfstandige regels meer; zoeken indexeert een draad op zijn hele tekst in plaats van op de eerste bubbel.

De titel van de post blijft leeg zolang hij onbekend is in plaats van terug te
vallen op de slug: het nieuwste bericht in een draad is vaak je eigen antwoord,
en dat kent alleen de slug -- zou die de titel worden, dan kon het ontvangen
antwoord eronder de echte titel niet meer invullen.

Getest tegen dev.klonkt.com: de groepering en de template gedraaid op de echte
database daar, en op een kopie ervan met een nagebouwd gesprek (twee ontvangen
antwoorden, jouw antwoord ertussen, plus een like). Dat levert een draad van
drie berichten op volgorde met de post als context, terwijl de like een losse
regel blijft. Suite 408/408.

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

Files:
1 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r04aca12 r7875a80  
    639639      items.push({
    640640        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,
    642643      });
    643644    }
    644645  } 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 */ }
    645660  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
    646661  const out = [];
     
    656671    out.push(it);
    657672  }
    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.
     684const 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 */
     700export 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 */
     714export 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;
    659758}
    660759
     
    29073006}
    29083007export 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')
    29103013    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
    29113014}
  • src/views/pages/messages.ejs

    r04aca12 r7875a80  
    2525
    2626  <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. %>
    2730    <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>
    2931    <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
    3032    <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
    3133    <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>
    3334  </div>
    3435  <div class="msg-search">
     
    7172  var items = [];
    7273  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    }
    7380    // Index once: sender + message body + linked post title + poll text.
    7481    var body = li.querySelector('.msg-content');
     
    8289  // items join the filter/search (and inherit the active chip via apply()).
    8390  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')) : [];
    8592    items.forEach(function (li) { if (!li._search) indexItem(li); });
    8693  }
     
    138145
    139146  .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; }
    140167
    141168  .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 { %>
    133<%
    234           var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
     
    1345             : (_t === 'mention' || _priv0) ? 'msgs'
    1446             : '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);
    1550           var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
    1651           var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
    1752      %>
    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() %>">
    1954          <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
    2055            <% 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>
     
    65100              <% } else { %><%= t('notif.replied') %><% } %>
    66101              <% 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><% } %>
    68103              <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    69104              <% 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><% } %>
     
    116151          </div>
    117152        </li>
     153<% } %>
  • test/messages.test.js

    r04aca12 r7875a80  
    22// in één stroom, groepeert opeenvolgende likes/boosts op dezelfde post, en geeft
    33// 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.
    48//
    59// Run: npm test   (= node --test)
     
    4246const msgs = AP.getMessages('me', 50);
    4347
    44 test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => {
    45   const sent = msgs.find((m) => m.type === 'sent');
     48test('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');
    4653  assert.ok(sent, 'sent-item ontbreekt');
    4754  assert.equal(sent.outboxId, 'out1');
    4855  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');
    5060});
    5161
     
    5868
    5969test('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');
    6172  assert.equal(reply.visibility, 'direct');
    6273  assert.equal(reply.post_slug, 'mijn-post');
    6374  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' });
    6477});
    6578
Note: See TracChangeset for help on using the changeset viewer.