source: Klonkt/src/assets/js/reply-editor.js@ feced2c

main
Last change on this file since feced2c was feced2c, checked in by Robin <roboburr@…>, 7 weeks ago

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@…>

  • Property mode set to 100644
File size: 7.1 KB
RevLine 
[33e1dbd]1// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs)
2// from a plain textarea to a contenteditable with a small toolbar and, on
3// narrow screens (≤700px), a full-screen compose overlay (the right pattern on
4// mobile). Progressive enhancement: without this file the textarea submits as
5// before. On submit: `content` = editor HTML (server sanitizes), `text` =
6// plain-text fallback.
7(function () {
8 'use strict';
9 if (window.__replyEditorInit) return;
10 window.__replyEditorInit = true;
11
12 var MOBILE = '(max-width: 700px)';
13
14 function init(form) {
15 if (form.__re) return;
16 form.__re = true;
17
18 var ta = form.querySelector('textarea[name="text"]');
19 var ed = form.querySelector('.re-editor');
20 var bar = form.querySelector('.re-toolbar');
21 var head = form.querySelector('.re-head');
22 var foot = form.querySelector('.re-foot');
23 var lang = form.querySelector('.re-lang');
24 var hidden = form.querySelector('input[name="content"]');
25 if (!ta || !ed || !hidden) return;
26
27 // Upgrade: swap the textarea for the editor.
28 ta.hidden = true;
29 ta.required = false;
30 ed.hidden = false;
31 bar.hidden = false;
32 if (lang) lang.hidden = false;
33
[feced2c]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
[33e1dbd]88 // Toolbar commands (execCommand is deprecated-but-universal; same approach
89 // as the post editor).
90 bar.addEventListener('click', function (e) {
91 var btn = e.target.closest('button[data-cmd]');
92 if (!btn) return;
93 e.preventDefault();
94 var cmd = btn.getAttribute('data-cmd');
[feced2c]95 if (cmd === 'attach') { fileInput.click(); return; } // no editor focus: keeps the picker usable on mobile
96 ed.focus();
[33e1dbd]97 if (cmd === 'bold') document.execCommand('bold');
98 else if (cmd === 'italic') document.execCommand('italic');
99 else if (cmd === 'list') document.execCommand('insertUnorderedList');
100 else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
101 else if (cmd === 'link') {
102 var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
103 if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
104 }
105 });
[feced2c]106 fileInput.addEventListener('change', function () {
107 uploadFiles(fileInput.files);
108 fileInput.value = '';
109 });
[33e1dbd]110
[feced2c]111 // Paste: files become attachments; text pastes as plain text (rich paste
112 // becomes messy HTML; formatting is what the toolbar is for).
[33e1dbd]113 ed.addEventListener('paste', function (e) {
[feced2c]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');
[33e1dbd]121 if (!txt) return;
122 e.preventDefault();
123 document.execCommand('insertText', false, txt);
124 });
125
[feced2c]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 }
140 });
141
[33e1dbd]142 // Full-screen compose on mobile: enter on focus, leave via ×.
143 function setFull(on) {
144 form.classList.toggle('re-full', on);
145 head.hidden = !on;
146 document.documentElement.classList.toggle('re-lock', on);
147 if (on) ed.focus();
148 }
149 ed.addEventListener('focus', function () {
150 if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
151 });
152 var cancel = form.querySelector('.re-cancel');
153 if (cancel) cancel.addEventListener('click', function () { setFull(false); });
154
[feced2c]155 // Serialize on submit; block truly empty replies (media-only is fine).
[33e1dbd]156 form.addEventListener('submit', function (e) {
157 var html = ed.innerHTML.trim();
158 var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
[feced2c]159 if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
160 hidden.value = plain ? html : '';
[33e1dbd]161 ta.value = plain;
162 document.documentElement.classList.remove('re-lock');
163 });
164
165 // Open <details> parents (fedi-node) keep working: nothing special needed.
166 void foot;
167 }
168
169 function initAll() {
170 document.querySelectorAll('form[data-re]').forEach(init);
171 }
172 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll);
173 else initAll();
174})();
Note: See TracBrowser for help on using the repository browser.