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

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

Feature: mentions bar with conversation partners (klonkt-demo-u02)

Implicit mentions leave the text and become an editable "To:" bar above the
reply editor: the parent author plus the thread's ancestor authors as chips.
Removing a chip stops addressing that partner (no mention anchor, no Mention
tag, no inbox ping); explicit @mentions typed in the text keep working via the
existing resolveMentionsInText path.

  • getInteractions: each thread node gets "participants" (author + ancestor chain via the byId map, own nodes skipped, deduped, capped at 8) and carries actor_uri now.
  • reply-editor partial: the bar renders server-side with a hidden "mentions" JSON field carrying the full list, so the no-JS path addresses everyone; the JS removes chips and mirrors the remaining list into the field.
  • deliverReply({mentions}): undefined = legacy parent-only behavior; an array (possibly empty) = the kept list drives the mention prefix, the Mention tags (mentionTags reads the content anchors) and the per-actor inbox pings. to_actor/to_handle follow the kept list (parent when kept, else the first chip, else null -> the note addresses Public only).
  • deliverOutboxUpdate: an edit reuses the OLD content's leading mention anchors instead of rebuilding just to_actor, so co-mentions survive edits (legacy rows fall back as before).
  • Interact page passes the target author as the single chip. Full-screen mobile keeps the top bar above the mentions bar (flex order).

4 new tests (participant chains, multi-chip tags + to_actor, empty bar goes
Public-only, co-mentions survive edits); 97 green. Browser-verified: bar shows
@bob + @alice on a nested reply, removing @alice updates the hidden field, the
sent reply mentions and addresses only @bob; no console errors.

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

  • Property mode set to 100644
File size: 8.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');
[5190152]38 var canAttach = !!(attWrap && attField && fileInput); // edit mode renders without media
[feced2c]39 var attachments = [];
40
41 function syncAtt() {
[5190152]42 if (!canAttach) return;
[feced2c]43 attField.value = attachments.length ? JSON.stringify(attachments) : '';
44 attWrap.hidden = attachments.length === 0;
45 }
46 function addChip(a) {
47 var chip = document.createElement('span');
48 chip.className = 're-att';
49 if (a.mediaType.indexOf('image/') === 0) {
50 var img = document.createElement('img');
51 img.src = a.url; img.alt = a.name || '';
52 chip.appendChild(img);
53 } else {
54 chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
55 }
56 var del = document.createElement('button');
57 del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
58 del.addEventListener('click', function () {
59 attachments = attachments.filter(function (x) { return x !== a; });
60 chip.remove(); syncAtt();
61 });
62 chip.appendChild(del);
63 attWrap.appendChild(chip);
64 }
65 function uploadFiles(files) {
[5190152]66 if (!canAttach) return;
[feced2c]67 Array.prototype.forEach.call(files, function (file) {
68 if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
69 var chip = document.createElement('span');
70 chip.className = 're-att re-att-busy';
71 chip.textContent = '⏳ ' + file.name;
72 attWrap.hidden = false;
73 attWrap.appendChild(chip);
74 var fd = new FormData();
75 fd.append('media', file);
76 fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
77 .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
78 .then(function (j) {
79 chip.remove();
80 var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
81 attachments.push(a); addChip(a); syncAtt();
82 })
83 .catch(function (err) {
84 chip.className = 're-att re-att-err';
85 chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
86 setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
87 });
88 });
89 }
90
[33e1dbd]91 // Toolbar commands (execCommand is deprecated-but-universal; same approach
92 // as the post editor).
93 bar.addEventListener('click', function (e) {
94 var btn = e.target.closest('button[data-cmd]');
95 if (!btn) return;
96 e.preventDefault();
97 var cmd = btn.getAttribute('data-cmd');
[5190152]98 if (cmd === 'attach') { if (fileInput) fileInput.click(); return; } // no editor focus: keeps the picker usable on mobile
[feced2c]99 ed.focus();
[33e1dbd]100 if (cmd === 'bold') document.execCommand('bold');
101 else if (cmd === 'italic') document.execCommand('italic');
102 else if (cmd === 'list') document.execCommand('insertUnorderedList');
103 else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
104 else if (cmd === 'link') {
105 var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
106 if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
107 }
108 });
[5190152]109 if (fileInput) fileInput.addEventListener('change', function () {
[feced2c]110 uploadFiles(fileInput.files);
111 fileInput.value = '';
112 });
[33e1dbd]113
[feced2c]114 // Paste: files become attachments; text pastes as plain text (rich paste
115 // becomes messy HTML; formatting is what the toolbar is for).
[33e1dbd]116 ed.addEventListener('paste', function (e) {
[feced2c]117 var cd = e.clipboardData || window.clipboardData;
118 if (cd.files && cd.files.length) {
[5190152]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)
[feced2c]121 return;
122 }
123 var txt = cd.getData('text/plain');
[33e1dbd]124 if (!txt) return;
125 e.preventDefault();
126 document.execCommand('insertText', false, txt);
127 });
128
[feced2c]129 // Drag/drop media onto the editor.
[5190152]130 if (canAttach) {
[feced2c]131 ed.addEventListener('dragover', function (e) {
132 if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
133 e.preventDefault();
134 ed.classList.add('re-drop');
135 }
136 });
137 ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
138 ed.addEventListener('drop', function (e) {
139 ed.classList.remove('re-drop');
140 if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
141 e.preventDefault();
142 uploadFiles(e.dataTransfer.files);
143 }
144 });
[5190152]145 }
[feced2c]146
[e9c9ae1]147 // Mentions bar (u02): removing a chip stops addressing that partner. The
148 // hidden field always mirrors the remaining chips.
149 var mField = form.querySelector('input[name="mentions"]');
150 if (mField) {
151 var parts = [];
152 try { parts = JSON.parse(mField.value || '[]'); } catch (e) { parts = []; }
153 form.querySelectorAll('.re-mention-del').forEach(function (btn) {
154 btn.addEventListener('click', function () {
155 var chip = btn.closest('.re-mention');
156 var uri = chip.getAttribute('data-uri');
157 parts = parts.filter(function (p) { return p.uri !== uri; });
158 mField.value = JSON.stringify(parts);
159 chip.remove();
160 });
161 });
162 }
163
[33e1dbd]164 // Full-screen compose on mobile: enter on focus, leave via ×.
165 function setFull(on) {
166 form.classList.toggle('re-full', on);
167 head.hidden = !on;
168 document.documentElement.classList.toggle('re-lock', on);
169 if (on) ed.focus();
170 }
171 ed.addEventListener('focus', function () {
172 if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
173 });
174 var cancel = form.querySelector('.re-cancel');
175 if (cancel) cancel.addEventListener('click', function () { setFull(false); });
176
[feced2c]177 // Serialize on submit; block truly empty replies (media-only is fine).
[33e1dbd]178 form.addEventListener('submit', function (e) {
179 var html = ed.innerHTML.trim();
180 var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
[feced2c]181 if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
182 hidden.value = plain ? html : '';
[33e1dbd]183 ta.value = plain;
184 document.documentElement.classList.remove('re-lock');
185 });
186
187 // Open <details> parents (fedi-node) keep working: nothing special needed.
188 void foot;
189 }
190
191 function initAll() {
192 document.querySelectorAll('form[data-re]').forEach(init);
193 }
194 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll);
195 else initAll();
196})();
Note: See TracBrowser for help on using the repository browser.