Index: src/assets/js/mod/messages.js
===================================================================
--- src/assets/js/mod/messages.js	(revision e64a49b380f38e666e08803dbc52f598a5d97bf2)
+++ src/assets/js/mod/messages.js	(revision e64a49b380f38e666e08803dbc52f598a5d97bf2)
@@ -0,0 +1,132 @@
+// Berichten: filteren, zoeken, en gesprekken in- en uitklappen (shaer-bqr).
+//
+// Dit stond inline in pages/messages.ejs. Dat werkte alleen na een volledige
+// laadbeurt: kwam je hier via een link BINNEN de site, dan arriveerde het script
+// via htmx met een nonce die het document niet kent, en weigerde de CSP het
+// (shaer-0i6). Chips, zoeken en het inklappen deden dan niets, en de reply-editor
+// laadde niet -- dat was Barts melding.
+//
+// TWEE DINGEN VERANDERD bij de verhuizing, en ze zijn allebei nodig omdat een
+// module ANDERS leeft dan een inline script:
+//
+//   NIETS VASTHOUDEN   het inline script pakte .msg-list, #msg-q en .msg-nomatch
+//                      een keer bij het inladen. Een module wordt per document
+//                      maar EEN keer geimporteerd, dus als je Berichten verlaat
+//                      en terugkomt wijzen die naar elementen die er niet meer
+//                      zijn. Nu wordt er per keer opgezocht.
+//   OPNIEUW INDEXEREN  bij binnenkomst op deze pagina, niet alleen bij "meer
+//                      laden". Anders is de lijst na een navigatie niet
+//                      geindexeerd en filtert het zoeken op niets.
+//
+// Alles gedelegeerd op document, met een slot: de module kan een tweede keer
+// geladen worden en mag dan geen tweede stel luisteraars neerzetten.
+
+(function () {
+  'use strict';
+  if (window.__msgWired) return;
+  window.__msgWired = true;
+
+  var kind = 'all';
+  var items = [];
+
+  // Per keer opzoeken. Zie de kop: vasthouden overleeft een navigatie niet.
+  function list() { return document.querySelector('.msg-list'); }
+  function noMatch() { return document.querySelector('.msg-nomatch'); }
+  function query() { return document.getElementById('msg-q'); }
+
+  function indexItem(li) {
+    // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken,
+    // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd.
+    if (li.classList.contains('msg-thread')) {
+      li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase();
+      return;
+    }
+    var body = li.querySelector('.msg-content');
+    var post = li.querySelector('.msg-post');
+    var poll = li.querySelector('.msg-poll');
+    li._search = ((li.getAttribute('data-who') || '') + ' ' +
+      (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
+      (poll ? poll.textContent : '')).toLowerCase();
+  }
+
+  function reindex() {
+    var l = list();
+    items = l ? Array.prototype.slice.call(l.querySelectorAll(':scope > .msg-item')) : [];
+    items.forEach(function (li) { if (!li._search) indexItem(li); });
+  }
+
+  function apply() {
+    if (!list()) return;
+    var qEl = query();
+    var term = (qEl && qEl.value || '').trim().toLowerCase();
+    var shown = 0;
+    items.forEach(function (li) {
+      var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
+        (!term || li._search.indexOf(term) !== -1);
+      li.style.display = ok ? '' : 'none';
+      // Een treffer die in een dichtgeklapt gesprek zit, laat anders alleen de
+      // naam zien: je zoekt iets, het staat er, en je ziet het niet. Zolang er
+      // gezocht wordt gaat zo'n gesprek open; daarna keert hij terug naar de
+      // stand die de lezer zelf koos.
+      if (li.classList.contains('msg-thread')) {
+        li.classList.toggle('is-search-open', !!term && ok);
+      }
+      if (ok) shown++;
+    });
+    var nm = noMatch();
+    if (nm) nm.hidden = shown !== 0;
+  }
+
+  // In- en uitklappen door op de tegenpartij te tikken. De stand leeft alleen in
+  // deze pagina: standaard uitgeklapt, en na een herlading weer. Dat is bewust --
+  // een gesprek dat je gisteren dichtklapte stilhouden is niet hetzelfde als het
+  // opruimen van je scherm van nu.
+  document.addEventListener('click', function (e) {
+    var who = e.target.closest && e.target.closest('.msg-thread-who'); if (!who) return;
+    var li = who.closest('.msg-thread'); if (!li) return;
+    var collapsed = li.classList.toggle('is-collapsed');
+    who.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
+  });
+
+  document.addEventListener('click', function (e) {
+    var chip = e.target.closest && e.target.closest('.msg-chip'); if (!chip) return;
+    kind = chip.getAttribute('data-show');
+    document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
+    apply();
+  });
+
+  // Gedelegeerd in plaats van op het invoerveld zelf: dat veld wordt bij een
+  // navigatie vervangen.
+  document.addEventListener('input', function (e) {
+    if (e.target && e.target.id === 'msg-q') apply();
+  });
+
+  document.body.addEventListener('htmx:afterSettle', function (e) {
+    // "Meer laden" voegt rijen toe aan de bestaande lijst; een navigatie brengt
+    // een hele nieuwe lijst. In beide gevallen opnieuw indexeren en de actieve
+    // chip toepassen.
+    if (e.target && (e.target.id === 'msg-list' || e.target.querySelector && e.target.querySelector('.msg-list'))) {
+      reindex();
+      apply();
+    }
+  });
+
+  // De bookmarklet-knop: bouwt zijn href pas in de browser, want hij heeft de
+  // origin nodig.
+  function wireBookmarklet() {
+    var a = document.getElementById('fedi-bm-btn');
+    if (!a || a.__wired) return;
+    a.__wired = true;
+    a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
+    a.addEventListener('click', function (e) {
+      e.preventDefault();
+      a.classList.add('nudge');
+      setTimeout(function () { a.classList.remove('nudge'); }, 600);
+    });
+  }
+
+  function start() { reindex(); apply(); wireBookmarklet(); }
+  start();
+  // Kom je hier via een link binnen de site, dan is de lijst er pas na de swap.
+  document.body.addEventListener('pcmsNav', start);
+})();
