Changeset feced2c in Klonkt for src/assets/js


Ignore:
Timestamp:
07/19/2026 05:25:26 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
5190152
Parents:
33e1dbd
git-author:
Robin <roboburr@…> (07/19/2026 05:24:58 PM)
git-committer:
Robin <roboburr@…> (07/19/2026 05:25:26 PM)
Message:

Feature: media in replies — rich replies phase 2 (klonkt-demo-c7f)

Drop, paste or pick images/audio/video in the reply editor; they upload, show
as removable chips, travel as AS2 attachments on the federated Note, and render
in the thread.

  • POST /posts/upload-reply-media (requireSiteManager): image/audio/video by extension AND mimetype, stored as-is under /media/reply-media/ (no transcode; a reply attachment is not a track), 32MB cap, returns {url, mediaType, name}.
  • Editor: paperclip button + hidden file input (the mobile path), paste-files and drag/drop handlers, busy/error chips, image thumbnails, max 4, hidden attachments JSON field. Media-only submit allowed (text no longer required when something is attached).
  • deliverReply({attachments}): re-validates server-side — own /media/ paths only (the upload route is the sole producer, remote URLs rejected), image|audio|video mimetypes, capped at 4; stored as JSON on ap_outbox (additive column). Dedup guard now includes attachments so two media-only replies to the same parent are distinct from each other but double-submits still dedup.
  • buildNote reply branch: attachment array with Image/Audio/Video types and absolute URLs. getInteractions passes media through; fedi-node renders it (img/audio/video) for visitors too, loading the stylesheet when the owner-only editor is not on the page.

3 new tests (foreign-URL and type rejection, typed absolute Note attachments,
media-only allowed, only-invalid rejected); 91 green. Browser-verified end to
end: real upload via the endpoint, paste-event -> chip with thumbnail ->
submit -> ap_outbox row with content+language+attachments -> media rendered in
the thread -> /ap/notes/<id> serves the typed absolute attachment.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/reply-editor.js

    r33e1dbd rfeced2c  
    3232    if (lang) lang.hidden = false;
    3333
     34    // ── Media attachments: picker (📎), drag/drop and paste ──────────────
     35    var attWrap = form.querySelector('.re-attachments');
     36    var attField = form.querySelector('input[name="attachments"]');
     37    var fileInput = form.querySelector('.re-file');
     38    var attachments = [];
     39
     40    function syncAtt() {
     41      attField.value = attachments.length ? JSON.stringify(attachments) : '';
     42      attWrap.hidden = attachments.length === 0;
     43    }
     44    function addChip(a) {
     45      var chip = document.createElement('span');
     46      chip.className = 're-att';
     47      if (a.mediaType.indexOf('image/') === 0) {
     48        var img = document.createElement('img');
     49        img.src = a.url; img.alt = a.name || '';
     50        chip.appendChild(img);
     51      } else {
     52        chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
     53      }
     54      var del = document.createElement('button');
     55      del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
     56      del.addEventListener('click', function () {
     57        attachments = attachments.filter(function (x) { return x !== a; });
     58        chip.remove(); syncAtt();
     59      });
     60      chip.appendChild(del);
     61      attWrap.appendChild(chip);
     62    }
     63    function uploadFiles(files) {
     64      Array.prototype.forEach.call(files, function (file) {
     65        if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
     66        var chip = document.createElement('span');
     67        chip.className = 're-att re-att-busy';
     68        chip.textContent = '⏳ ' + file.name;
     69        attWrap.hidden = false;
     70        attWrap.appendChild(chip);
     71        var fd = new FormData();
     72        fd.append('media', file);
     73        fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
     74          .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
     75          .then(function (j) {
     76            chip.remove();
     77            var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
     78            attachments.push(a); addChip(a); syncAtt();
     79          })
     80          .catch(function (err) {
     81            chip.className = 're-att re-att-err';
     82            chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
     83            setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
     84          });
     85      });
     86    }
     87
    3488    // Toolbar commands (execCommand is deprecated-but-universal; same approach
    3589    // as the post editor).
     
    3892      if (!btn) return;
    3993      e.preventDefault();
     94      var cmd = btn.getAttribute('data-cmd');
     95      if (cmd === 'attach') { fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
    4096      ed.focus();
    41       var cmd = btn.getAttribute('data-cmd');
    4297      if (cmd === 'bold') document.execCommand('bold');
    4398      else if (cmd === 'italic') document.execCommand('italic');
     
    49104      }
    50105    });
     106    fileInput.addEventListener('change', function () {
     107      uploadFiles(fileInput.files);
     108      fileInput.value = '';
     109    });
    51110
    52     // Paste as plain text (rich paste becomes messy HTML; formatting is what
    53     // the toolbar is for). Media paste/drop lands in the media phase.
     111    // Paste: files become attachments; text pastes as plain text (rich paste
     112    // becomes messy HTML; formatting is what the toolbar is for).
    54113    ed.addEventListener('paste', function (e) {
    55       var txt = (e.clipboardData || window.clipboardData).getData('text/plain');
     114      var cd = e.clipboardData || window.clipboardData;
     115      if (cd.files && cd.files.length) {
     116        e.preventDefault();
     117        uploadFiles(cd.files);
     118        return;
     119      }
     120      var txt = cd.getData('text/plain');
    56121      if (!txt) return;
    57122      e.preventDefault();
    58123      document.execCommand('insertText', false, txt);
     124    });
     125
     126    // Drag/drop media onto the editor.
     127    ed.addEventListener('dragover', function (e) {
     128      if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
     129        e.preventDefault();
     130        ed.classList.add('re-drop');
     131      }
     132    });
     133    ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
     134    ed.addEventListener('drop', function (e) {
     135      ed.classList.remove('re-drop');
     136      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
     137        e.preventDefault();
     138        uploadFiles(e.dataTransfer.files);
     139      }
    59140    });
    60141
     
    72153    if (cancel) cancel.addEventListener('click', function () { setFull(false); });
    73154
    74     // Serialize on submit; block truly empty replies.
     155    // Serialize on submit; block truly empty replies (media-only is fine).
    75156    form.addEventListener('submit', function (e) {
    76157      var html = ed.innerHTML.trim();
    77158      var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
    78       if (!plain) { e.preventDefault(); ed.focus(); return; }
    79       hidden.value = html;
     159      if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
     160      hidden.value = plain ? html : '';
    80161      ta.value = plain;
    81162      document.documentElement.classList.remove('re-lock');
Note: See TracChangeset for help on using the changeset viewer.