Changeset 5190152 in Klonkt for src


Ignore:
Timestamp:
07/19/2026 05:38:25 PM (8 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@…>

Location:
src
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • 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>
Note: See TracChangeset for help on using the changeset viewer.