Changeset 5190152 in Klonkt


Ignore:
Timestamp:
07/19/2026 05:38:25 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
e9c9ae1
Parents:
feced2c
git-author:
Robin <roboburr@…> (07/19/2026 05:38:00 PM)
git-committer:
Robin <roboburr@…> (07/19/2026 05:38:25 PM)
Message:

Feature: rich editor on the edit forms too (klonkt-demo-c7f, last slice)

Editing a sent reply (Messages + the interact page's manage list) now opens the
same shared editor as new replies, instead of a bare textarea. Prutter stays
plain on purpose (Robin: not part of this).

  • reply-editor partial: initialHtml/initialText prefill for edit mode, and a noAttach flag that omits the media UI (an edit never touches attachments). JS grew canAttach guards so the component runs without the attach elements; pasted files are still swallowed there rather than becoming base64 blobs.
  • deliverOutboxUpdate(site, id, text, {html, language}): rich path with the same sanitize + enrichment + mention-first-paragraph logic as deliverReply; language updated via COALESCE (bogus codes keep the old one); attachments survive untouched. Plain path unchanged.
  • /fediverse/:id/edit passes content + language; listOutbox and the Messages sent-projection carry language so the select preselects correctly.
  • The interact page's edit-toggle focuses the rich editor when present.

2 new tests (93 green). Browser-verified on /messages: prefilled editor
(mention-stripped HTML + plain fallback + language preselected, no paperclip),
edit saved -> content replaced with markup, mention re-attached inline,
language nl->en, no console errors.

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

Files:
10 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    rfeced2c r5190152  
    77
    88### Hinzugefügt
     9- **Das Bearbeiten einer gesendeten Antwort nutzt jetzt ebenfalls den
     10  Rich-Editor.** Die Bearbeiten-Formulare auf Nachrichten und der
     11  Interaktionsseite öffnen denselben Editor wie neue Antworten (Formatierung
     12  bleibt, Sprache anpassbar, Vollbild auf Telefonen). Anhänge der Antwort
     13  überstehen eine Bearbeitung unangetastet.
    914- **Medien in Antworten (reichhaltige Antworten, Phase 2).** Ziehe, füge ein
    1015  oder wähle Bilder, Audio und Video direkt im Antwort-Editor (die Büroklammer
  • CHANGELOG.md

    rfeced2c r5190152  
    77
    88### Added
     9- **Editing a sent reply uses the rich editor too.** The edit forms on Messages
     10  and the interact page open the same editor as new replies (formatting kept,
     11  language adjustable, full-screen on phones). Attachments on the reply survive
     12  an edit untouched.
    913- **Media in replies (rich replies, phase 2).** Drop, paste or pick images,
    1014  audio and video straight into the reply editor (the paperclip works on
  • CHANGELOG.nl.md

    rfeced2c r5190152  
    77
    88### Toegevoegd
     9- **Een verzonden reactie bewerken gebruikt nu ook de rijke editor.** De
     10  bewerk-formulieren op Berichten en de interactiepagina openen dezelfde editor
     11  als nieuwe reacties (opmaak blijft, taal aanpasbaar, volledig scherm op
     12  telefoons). Bijlagen op de reactie overleven een bewerking onaangeroerd.
    913- **Media in reacties (rijke reacties, fase 2).** Sleep, plak of kies
    1014  afbeeldingen, audio en video direct in de reactie-editor (de paperclip werkt
  • src/assets/js/reply-editor.js

    rfeced2c r5190152  
    3636    var attField = form.querySelector('input[name="attachments"]');
    3737    var fileInput = form.querySelector('.re-file');
     38    var canAttach = !!(attWrap && attField && fileInput);   // edit mode renders without media
    3839    var attachments = [];
    3940
    4041    function syncAtt() {
     42      if (!canAttach) return;
    4143      attField.value = attachments.length ? JSON.stringify(attachments) : '';
    4244      attWrap.hidden = attachments.length === 0;
     
    6264    }
    6365    function uploadFiles(files) {
     66      if (!canAttach) return;
    6467      Array.prototype.forEach.call(files, function (file) {
    6568        if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
     
    9396      e.preventDefault();
    9497      var cmd = btn.getAttribute('data-cmd');
    95       if (cmd === 'attach') { fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
     98      if (cmd === 'attach') { if (fileInput) fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
    9699      ed.focus();
    97100      if (cmd === 'bold') document.execCommand('bold');
     
    104107      }
    105108    });
    106     fileInput.addEventListener('change', function () {
     109    if (fileInput) fileInput.addEventListener('change', function () {
    107110      uploadFiles(fileInput.files);
    108111      fileInput.value = '';
     
    114117      var cd = e.clipboardData || window.clipboardData;
    115118      if (cd.files && cd.files.length) {
    116         e.preventDefault();
    117         uploadFiles(cd.files);
     119        e.preventDefault();            // never let the browser inline-paste a file as base64
     120        uploadFiles(cd.files);         // no-op without the attach UI (edit mode)
    118121        return;
    119122      }
     
    125128
    126129    // Drag/drop media onto the editor.
     130    if (canAttach) {
    127131    ed.addEventListener('dragover', function (e) {
    128132      if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
     
    139143      }
    140144    });
     145    }
    141146
    142147    // Full-screen compose on mobile: enter on focus, leave via ×.
  • src/routes/posts.js

    rfeced2c r5190152  
    827827router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
    828828  const site = res.locals.site;
    829   if (site && String(req.body.text || '').trim()) {
    830     try { await ActivityPubService.deliverOutboxUpdate(site, req.params.id, req.body.text); }
    831     catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
     829  const text = String(req.body.text || '');
     830  const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
     831  if (site && (text.trim() || html.trim())) {
     832    try {
     833      await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
     834        html, language: String(req.body.language || ''),
     835      });
     836    } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
    832837  }
    833838  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
  • src/services/ActivityPubService.js

    rfeced2c r5190152  
    513513      items.push({
    514514        type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
    515         content: m.content, editable: m.editable, created_at: m.created_at,
     515        content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
    516516      });
    517517    }
     
    20492049}
    20502050export function listOutbox(siteSlug) {
    2051   return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
     2051  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')
    20522052    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
    20532053}
     
    20772077// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
    20782078// re-linked) and send an Update(Note) so recipients refresh their cached copy.
    2079 export async function deliverOutboxUpdate(site, outboxId, newText) {
     2079export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
    20802080  const row = iStmts().getO.get(outboxId);
    20812081  if (!row || row.site_slug !== site.slug) return false;
    20822082  const text = String(newText || '').trim();
    2083   if (!text) return false;
     2083  // Rich edit: same sanitize + enrichment pipeline as deliverReply.
     2084  const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
     2085  const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
     2086  if (!text && !rich) return false;
    20842087  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    20852088  if (!base) return false;
     
    20912094  const mention = row.to_actor
    20922095    ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '';
    2093   const mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
    2094   const content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
    2095   db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
     2096  let content;
     2097  let mres;
     2098  if (rich) {
     2099    mres = await resolveMentionsInText(base, rich);
     2100    const processed = linkUrls(linkHashtags(base, mres.html));
     2101    if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
     2102    else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
     2103    else content = `<p>${mention}${processed}</p>`;
     2104  } else {
     2105    mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
     2106    content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
     2107  }
     2108  // Language may be updated with the edit; attachments always survive untouched.
     2109  const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
     2110  db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
    20962111  const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
    20972112  note.updated = new Date().toISOString();
  • src/views/pages/authorize-interaction.ejs

    rfeced2c r5190152  
    4141              </form>
    4242            </div>
    43             <form method="post" action="/fediverse/<%= m.id %>/edit" class="fedi-edit-form">
    44               <textarea name="text" rows="3" maxlength="2000" class="fedi-edit-ta"><%= m.editable %></textarea>
    45               <div class="fedi-edit-actions"><button type="submit" class="fedi-edit-save"><%= t('fedi.save_edit') %></button></div>
    46             </form>
     43            <div class="fedi-edit-form">
     44              <%- include('../partials/reply-editor', {
     45                action: '/fediverse/' + m.id + '/edit',
     46                placeholder: t('fedi.reply_ph'),
     47                submitLabel: t('fedi.save_edit'),
     48                rows: 3,
     49                initialHtml: m.content,
     50                initialText: m.editable,
     51                defaultLang: m.language,
     52                noAttach: true,
     53              }) %>
     54            </div>
    4755          </li>
    4856        <% }); %>
     
    5967          b.classList.toggle('is-open', open);
    6068          b.setAttribute('aria-expanded', open ? 'true' : 'false');
    61           if (open) { var ta = form.querySelector('textarea'); if (ta) ta.focus(); }
     69          if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); }
    6270        });
    6371      })();
  • src/views/pages/messages.ejs

    rfeced2c r5190152  
    7878                <details class="msg-edit">
    7979                  <summary><%= t('fedi.edit') %></summary>
    80                   <form method="post" action="/fediverse/<%= n.outboxId %>/edit" class="msg-edit-form">
    81                     <textarea name="text" rows="2" required><%= n.editable %></textarea>
    82                     <button type="submit" class="btn"><%= t('fedi.save_edit') %></button>
    83                   </form>
     80                  <div class="msg-edit-form">
     81                    <%- include('../partials/reply-editor', {
     82                      action: '/fediverse/' + n.outboxId + '/edit',
     83                      placeholder: t('fedi.reply_ph'),
     84                      submitLabel: t('fedi.save_edit'),
     85                      rows: 2,
     86                      initialHtml: n.content,
     87                      initialText: n.editable,
     88                      defaultLang: n.language,
     89                      noAttach: true,
     90                    }) %>
     91                  </div>
    8492                </details>
    8593                <form method="post" action="/fediverse/<%= n.outboxId %>/delete" data-confirm="<%= t('fedi.delete_confirm') %>">
  • src/views/partials/reply-editor.ejs

    rfeced2c r5190152  
    1616    rows          textarea rows for the no-JS fallback (default 3)
    1717    defaultLang   preselected reply language (default: UI lang)
     18    initialHtml   prefill for EDIT mode (our own sanitized HTML) (optional)
     19    initialText   plain-text prefill for the no-JS textarea (optional)
     20    noAttach      true hides the media button (edits do not touch attachments)
    1821%>
     22<% var _reInitHtml = (typeof initialHtml !== 'undefined' && initialHtml) || ''; %>
     23<% var _reInitText = (typeof initialText !== 'undefined' && initialText) || ''; %>
     24<% var _reNoAttach = (typeof noAttach !== 'undefined' && noAttach); %>
    1925<% var _reLang = (typeof defaultLang !== 'undefined' && defaultLang) || (typeof lang !== 'undefined' ? lang : 'en'); %>
    2026<form method="post" action="<%= action %>" class="re-form" data-re
     
    3642    <button type="button" data-cmd="list" title="<%= t('re.list') %>" aria-label="<%= t('re.list') %>">•≡</button>
    3743    <button type="button" data-cmd="quote" title="<%= t('re.quote') %>" aria-label="<%= t('re.quote') %>">❝</button>
    38     <button type="button" data-cmd="attach" title="<%= t('re.attach') %>" aria-label="<%= t('re.attach') %>">📎</button>
    39     <input type="file" class="re-file" accept="image/*,audio/*,video/*" multiple hidden>
     44    <% if (!_reNoAttach) { %>
     45      <button type="button" data-cmd="attach" title="<%= t('re.attach') %>" aria-label="<%= t('re.attach') %>">📎</button>
     46      <input type="file" class="re-file" accept="image/*,audio/*,video/*" multiple hidden>
     47    <% } %>
    4048  </div>
    41   <input type="hidden" name="attachments" value="">
    42   <div class="re-attachments" hidden></div>
    43   <textarea name="text" rows="<%= typeof rows !== 'undefined' ? rows : 3 %>" required placeholder="<%= placeholder %>"></textarea>
     49  <% if (!_reNoAttach) { %>
     50    <input type="hidden" name="attachments" value="">
     51    <div class="re-attachments" hidden></div>
     52  <% } %>
     53  <textarea name="text" rows="<%= typeof rows !== 'undefined' ? rows : 3 %>" required placeholder="<%= placeholder %>"><%= _reInitText %></textarea>
    4454  <div class="re-editor" contenteditable="true" role="textbox" aria-multiline="true"
    45        data-ph="<%= placeholder %>" aria-label="<%= placeholder %>" hidden></div>
     55       data-ph="<%= placeholder %>" aria-label="<%= placeholder %>" hidden><%- _reInitHtml %></div>
    4656  <div class="re-foot">
    4757    <select name="language" class="re-lang" aria-label="<%= t('re.lang') %>" hidden>
  • test/rich-reply.test.js

    rfeced2c r5190152  
    120120  assert.equal(r, null);
    121121});
     122
     123test('rich edit: content replaced, language updated, attachments survive', async () => {
     124  const r = await AP.deliverReply(site, {
     125    postId: 'p1', postSlug: 'hallo', parent, text: 'origineel', html: '',
     126    attachments: [{ url: '/media/reply-media/keep.webp', mediaType: 'image/webp', name: 'blijft' }],
     127    language: 'nl',
     128  });
     129  const upd = await AP.deliverOutboxUpdate(site, r.id, '', { html: '<p>bewerkt met <em>nadruk</em></p>', language: 'en' });
     130  assert.ok(upd && upd.ok);
     131  const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
     132  assert.match(row.content, /bewerkt met <em>nadruk<\/em>/);
     133  assert.match(row.content, /^<p><a [^>]*class="u-url mention"/); // mention re-attached inline
     134  assert.equal(row.language, 'en');
     135  assert.equal(JSON.parse(row.attachments)[0].url, '/media/reply-media/keep.webp');
     136});
     137
     138test('plain edit path unchanged; bogus language keeps the old one', async () => {
     139  const r = await AP.deliverReply(site, { postId: 'p1', postSlug: 'hallo', parent, text: 'plain start', language: 'nl' });
     140  const upd = await AP.deliverOutboxUpdate(site, r.id, 'plain bewerkt', { language: '???' });
     141  assert.ok(upd && upd.ok);
     142  const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
     143  assert.match(row.content, /plain bewerkt/);
     144  assert.equal(row.language, 'nl');
     145});
Note: See TracChangeset for help on using the changeset viewer.