Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/approvelist.js

22.9 KiB, 13 runs

created by r2519314175:1341, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — the approve-list (approvelist.js)
3 ------------------------------------------------------------
4 ONE PRESS SENDS THE ONES YOU TICKED. js/triage.js drafts a
5 plan from the whole list of notes and hands the drafts HERE --
6 it no longer has a send of its own. This is the ONE review
7 surface: the drafts land in a QUEUE, each in a row a person can
8 read, edit and tick, and one "Send selected" posts the ticked
9 ones as a batch through improve.js's forge door.
10
11 ── WHAT LEAVES IS STILL WHAT IS ON THE SCREEN ──────────────
12
13 The rule js/improve.js and js/triage.js keep is kept here too:
14 nothing leaves until a person presses a button beside the
15 characters, and what leaves is exactly the characters in the
16 box at the instant of the press. `boxed()` reads the textarea
17 and nothing else -- not the record a draft was enqueued from --
18 so an edit made in the queue is what is sent. A batch is still
19 one press per SEND, not one press per draft: the person read
20 every ticked row and pressed once, and the send reads each of
21 those rows at the moment it posts it.
22
23 ── THE QUEUE IS LOCAL, LIKE THE NOTES IT CAME FROM ─────────
24
25 Drafts are ephemeral. They live under `daimond-approvelist`,
26 which sync.js's `collectParcel` does not gather -- the parcel
27 is an allowlist of named sources, so a key it was never told
28 about is out of it by construction, the same way notes are
29 (improve.js keeps notes out at its own store; this keeps drafts
30 out for the same reason). A draft is a proposal not yet made;
31 it belongs to the device the notes were written on until a
32 person sends it.
33
34 ── THE FORGE DOOR IS improve.js's, NOT A SECOND ONE ────────
35
36 Every send goes through `DaimondImprove.forge` -- `open` for a
37 new proposal, `say` for a comment, `amend` for a revision of
38 the user's OWN proposal. This file holds no route, no voice
39 header and no copy of the nine refusal wordings; `forge.saying`
40 answers those. A revision amends the author's own proposal
41 only: `forge.mayAmend(n)` gates it, and a row the forge has not
42 granted offers no tick, exactly as the panel's own Revise
43 control is dark until the forge answers.
44
45 Attaches `window.DaimondApproveList`.
46 ============================================================ */
47(function () {
48 'use strict';
49
50 // ── Saying things ──────────────────────────────────────────
51
52 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
53
54 /// A string with the English written at the call site as its fallback, on
55 /// the same terms improve.js and triage.js state: `t` answers with the KEY
56 /// when the table has no entry, and every string this file adds is new.
57 function tOr(k, fallback, v) {
58 var s = t(k, v);
59 if (s !== k) return s;
60 if (!v) return fallback;
61 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
62 return v[name] != null ? String(v[name]) : whole;
63 });
64 }
65
66 function log(/* ...args */) {
67 try { if (window.console && console.debug) console.debug.apply(console, ['[approve]'].concat([].slice.call(arguments))); }
68 catch (e) { /* no console */ }
69 }
70
71 function el(id) { return document.getElementById(id); }
72
73 function panel() { return window.DaimondImprove || null; }
74
75 function hasVoice() {
76 try { return !!(window.DaimondVoice && DaimondVoice.has()); } catch (e) { return false; }
77 }
78
79 // ── The store ──────────────────────────────────────────────
80 //
81 // One key, namespaced per account by accounts.js like every other
82 // `daimond-*`. NOT in the sync parcel and not coming: a draft is a proposal
83 // nobody has pressed Send on yet, and it belongs to the device the notes
84 // were written on. See the header.
85
86 var KEY = 'daimond-approvelist';
87 var MAX = 200; // a queue nobody empties cannot fill the quota
88
89 var KINDS = { 'new': 1, comment: 1, revision: 1 };
90
91 var _q = null; // the queue, loaded lazily
92 var _busy = false; // a batch send is in flight
93 var _say = ''; // one line under the control
94
95 /// A whole number, or 0.
96 function whole(v) {
97 return (typeof v === 'number' && isFinite(v)) ? Math.floor(v) : 0;
98 }
99
100 /// One queued draft, defended against whatever was in storage or handed in.
101 /// The same field shapes js/triage.js's `cleanDraft` produces, plus a queue
102 /// id and the selected flag. Nothing here is drawn as markup.
103 function cleanDraft(d) {
104 if (!d || typeof d !== 'object') return null;
105 var kind = (typeof d.kind === 'string') ? d.kind : '';
106 if (!KINDS[kind]) return null;
107 var n = whole(d.n);
108 if ((kind === 'comment' || kind === 'revision') && n < 1) return null;
109 var title = (typeof d.title === 'string') ? d.title.replace(/[\r\n]+/g, ' ').trim() : '';
110 var body = (typeof d.body === 'string') ? d.body : '';
111 if (kind !== 'comment' && !title) return null;
112 if (kind === 'comment' && !body.trim()) return null;
113 var from = [];
114 (Array.isArray(d.from) ? d.from : []).forEach(function (id) {
115 if (typeof id === 'string' && id && from.indexOf(id) === -1 && from.length < 64) from.push(id);
116 });
117 return {
118 id: String(d.id || '') || ('d' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)),
119 kind: kind,
120 n: Math.max(0, n),
121 title: title.slice(0, 300),
122 body: body.slice(0, 16000),
123 from: from,
124 why: (typeof d.why === 'string') ? d.why.slice(0, 400) : '',
125 // Ticked to send. Off by default: a person chooses what leaves, and a
126 // queue that arrived pre-ticked would send on the first press of a
127 // button they had not read the rows above.
128 sel: d.sel === true,
129 // The panel's own sentence about why a send did not go, drawn beside
130 // the draft. A failed send stays in the queue with this set.
131 err: (typeof d.err === 'string') ? d.err.slice(0, 400) : '',
132 };
133 }
134
135 function load() {
136 if (_q) return _q;
137 _q = [];
138 try {
139 var raw = JSON.parse(localStorage.getItem(KEY) || '{}') || {};
140 (Array.isArray(raw.drafts) ? raw.drafts : []).forEach(function (d) {
141 var c = cleanDraft(d);
142 if (c) _q.push(c);
143 });
144 } catch (e) { _q = []; }
145 return _q;
146 }
147
148 function save() {
149 var q = load();
150 if (q.length > MAX) q.length = MAX;
151 try { localStorage.setItem(KEY, JSON.stringify({ v: 1, drafts: q })); }
152 catch (e) { log('could not write the queue', e); }
153 }
154
155 // ── Filling the queue ──────────────────────────────────────
156
157 /// Add drafts to the queue. Takes js/triage.js's own draft shape (or the
158 /// verifier's), cleans each, and drops a draft already queued whose content
159 /// matches one on the list -- so pulling the same plan twice does not double
160 /// it. Returns how many were added.
161 function enqueue(drafts) {
162 if (!Array.isArray(drafts)) return 0;
163 var q = load(), added = 0;
164 drafts.forEach(function (d) {
165 var c = cleanDraft(d);
166 if (!c) return;
167 var dup = q.some(function (x) {
168 return x.kind === c.kind && x.n === c.n && x.title === c.title && x.body === c.body;
169 });
170 if (dup) return;
171 if (q.length >= MAX) return;
172 q.push(c);
173 added++;
174 });
175 if (added) { save(); draw(); }
176 return added;
177 }
178
179 // ── Editing one row, in place ──────────────────────────────
180
181 /// The characters in one draft's box, RIGHT NOW. The twin of triage's
182 /// `boxed()` and improve.js's `outgoing()`: it reads the textarea and
183 /// nothing else, so an edit made in the queue is what is sent.
184 function boxed(id) {
185 var box = document.querySelector('.apl-row[data-draft="' + cssId(id) + '"] .apl-box');
186 return box ? String(box.value || '') : '';
187 }
188
189 /// A draft id, safe to put in a CSS attribute selector. Ids are minted from
190 /// base36 above, so this is belt-and-braces against a hand-fed one.
191 function cssId(id) { return String(id).replace(/["\\\]]/g, '\\$&'); }
192
193 /// A draft cut into the two fields a proposal is made of. A cut and never an
194 /// addition: `title + '\n' + body` is what was on screen.
195 function cut(text) {
196 var i = text.indexOf('\n');
197 var title = (i < 0) ? text : text.slice(0, i);
198 var body = (i < 0) ? '' : text.slice(i + 1);
199 if (!title.trim()) return null;
200 return { title: title, body: body };
201 }
202
203 // ── Ticking, and taking one off ────────────────────────────
204
205 function find(id) {
206 var q = load();
207 for (var i = 0; i < q.length; i++) if (q[i].id === String(id)) return q[i];
208 return null;
209 }
210
211 /// Set whether a draft is ticked to send. A revision the forge has not
212 /// granted can never be ticked: it would reach a route this asker may not
213 /// use, which is the defect improve.js was rewritten to remove.
214 function select(id, on) {
215 var d = find(id);
216 if (!d) return false;
217 if (on && !sendable(d)) return false;
218 d.sel = !!on;
219 save();
220 return true;
221 }
222
223 /// Tick, or untick, every row that CAN be sent.
224 function selectAll(on) {
225 load().forEach(function (d) { d.sel = on ? sendable(d) : false; });
226 save();
227 draw();
228 }
229
230 /// Take one draft off the queue. Nothing is sent and no note is touched.
231 function remove(id) {
232 var q = load();
233 var i = q.findIndex(function (d) { return d.id === String(id); });
234 if (i === -1) return false;
235 q.splice(i, 1);
236 save();
237 draw();
238 return true;
239 }
240
241 /// Empty the queue. The notes it was drafted from are where they were.
242 function clear() {
243 _q = [];
244 save();
245 _say = '';
246 draw();
247 }
248
249 /// Whether this draft can be sent at all. A voice is needed for every write;
250 /// a revision needs the forge to have said this asker may amend proposal `n`.
251 function sendable(d) {
252 if (!hasVoice()) return false;
253 if (d.kind !== 'revision') return true;
254 var p = panel();
255 try { return !!(p && p.forge.mayAmend(d.n)); } catch (e) { return false; }
256 }
257
258 // ── The one press: send the ticked ones ────────────────────
259
260 /// Send every ticked draft, in order, through improve.js's forge door.
261 ///
262 /// THE BOXES ARE READ FIRST, all of them, before the first send -- because a
263 /// send redraws the queue and a redraw replaces the textareas, so reading
264 /// them as the batch went would read the characters off a row that had
265 /// already been rebuilt. What is read is what is on the screen at the press.
266 ///
267 /// A sent draft LEAVES the queue; a refused one STAYS, with the forge's own
268 /// sentence beside it, and is never retried on its own. The notes a sent
269 /// draft was written from are folded, which improve.js decides the meaning of.
270 async function sendSelected() {
271 var p = panel();
272 if (!p || _busy) return null;
273 var q = load();
274 var picked = q.filter(function (d) { return d.sel && sendable(d); });
275 if (!picked.length) {
276 _say = tOr('approve.none_ticked', 'Tick a draft first.');
277 draw();
278 return null;
279 }
280 if (!hasVoice()) {
281 _say = tOr('approve.novoice', 'No voice yet — a draft can only wait here.');
282 draw();
283 return null;
284 }
285
286 // Read every ticked row NOW, off the screen, before anything is sent.
287 var batch = picked.map(function (d) { return { id: d.id, text: boxed(d.id) }; });
288
289 _busy = true; _say = ''; draw();
290 var sent = 0, failed = 0;
291 try {
292 for (var i = 0; i < batch.length; i++) {
293 var d = find(batch[i].id);
294 if (!d) continue; // taken off the queue meanwhile
295 var ok = await one(d, batch[i].text);
296 if (ok) sent++; else failed++;
297 }
298 } finally {
299 _busy = false;
300 _say = tOr('approve.sent_batch', 'Sent {sent}. {failed} still waiting.',
301 { sent: sent, failed: failed });
302 save();
303 draw();
304 // The notes list may now show a folded row; let the panel redraw it.
305 try { p.render(); } catch (e) { draw(); }
306 }
307 return { sent: sent, failed: failed };
308 }
309
310 /// Send one draft. On success it leaves the queue and its notes are folded;
311 /// on a refusal it stays, with the sentence the refusal earned. Returns
312 /// whether it went.
313 async function one(d, text) {
314 text = String(text || '');
315 var p = panel();
316 if (!text.trim()) { d.err = tOr('approve.empty', 'Nothing to send.'); return false; }
317
318 var a;
319 if (d.kind === 'comment') {
320 a = await p.forge.say(d.n, text);
321 } else {
322 var parts = cut(text);
323 if (!parts) {
324 d.err = tOr('approve.no_title', 'First line is the title — write one, then what happened.');
325 return false;
326 }
327 a = (d.kind === 'revision') ? await p.forge.amend(d.n, parts) : await p.forge.open(parts);
328 }
329
330 if (!a || !a.ok) {
331 d.err = p.forge.saying(a) + ' ' + tOr('approve.kept', 'Kept here; nothing tried again.');
332 return false;
333 }
334
335 // Fold the forge's answer into the panel's proposal store, so a proposal
336 // opened through this batch shows in the Proposals view at once. The panel's
337 // own send, comment and vote do this with the record the forge hands back; a
338 // batch that skipped it left every proposal it sent absent from the list
339 // until the next full walk -- the fault that hid seven of eight sent drafts.
340 try { if (p.forge && p.forge.absorb) p.forge.absorb(a.data); }
341 catch (e) { log('the proposal would not land in the list', e); }
342
343 // The number the forge gave it: from the ANSWER, never assumed. A comment
344 // and a revision land on the proposal they named; a new one on whatever
345 // number the forge answered with.
346 var num = Math.max(0, (a.data && typeof a.data.number === 'number') ? Math.floor(a.data.number) : 0) || d.n;
347 try { if (num && d.from.length) p.fold(d.from, num); }
348 catch (e) { log('the notes would not be marked', e); }
349 remove(d.id); // a sent draft leaves the queue
350 return true;
351 }
352
353 // ── Drawing ────────────────────────────────────────────────
354 //
355 // Into `#improve-approve`, a sibling of `#improve-triage` under the note
356 // box. Built here rather than in the markup on the same argument triage.js
357 // and improve.js make: the markup is another lane's file and every part of
358 // this list is drawn from this one anyway. The host is made if it is not
359 // there, so the module needs no line in index.html beyond its <script>.
360
361 function host() {
362 var h = el('improve-approve');
363 if (h) return h;
364 var trg = el('improve-triage');
365 var list = el('improve-list');
366 // The Notes view was merged into Proposals: `#improve-triage` and
367 // `#improve-list` are gone, so fall back to the Proposals view, drawing
368 // above its waiting-to-send queue. This list only appears when a batch
369 // draft has filled it, which the merged compose never does -- so in ordinary
370 // use it stays empty and unseen; it is here so a batch surface still works
371 // where one is driven (dev/verify_proposalsappear.mjs).
372 var queue = el('improve-queue');
373 var into = (trg && trg.parentNode) || (list && list.parentNode) || (queue && queue.parentNode);
374 if (!into) return null;
375 h = document.createElement('div');
376 h.className = 'imp-approve';
377 h.id = 'improve-approve';
378 // After the triage row / before the notes list where those existed; else
379 // above the Proposals queue.
380 if (list && list.parentNode === into) into.insertBefore(h, list);
381 else if (trg && trg.nextSibling) into.insertBefore(h, trg.nextSibling);
382 else if (queue && queue.parentNode === into) into.insertBefore(h, queue);
383 else into.appendChild(h);
384 return h;
385 }
386
387 function button(cls, act, text, title) {
388 var b = document.createElement('button');
389 b.type = 'button';
390 b.className = cls;
391 if (act) b.dataset.act = act;
392 b.textContent = text;
393 if (title) b.title = title;
394 return b;
395 }
396
397 function line(cls, text) {
398 var s = document.createElement('div');
399 s.className = cls;
400 s.textContent = text;
401 return s;
402 }
403
404 /// The word a draft's kind is read as, and what it promises.
405 function kindWord(d) {
406 if (d.kind === 'comment') return tOr('approve.kind_comment', 'Comment on #{n}', { n: d.n });
407 if (d.kind === 'revision') return tOr('approve.kind_revision', 'Revision of #{n}', { n: d.n });
408 return tOr('approve.kind_new', 'New proposal');
409 }
410
411 /// The characters a draft's box starts with: the same cut, put back
412 /// together, so what is read is what would leave.
413 function bodyOf(d) {
414 if (d.kind === 'comment') return d.body;
415 return d.body ? (d.title + '\n' + d.body) : d.title;
416 }
417
418 function draw() {
419 var h = host();
420 if (!h) return;
421 h.innerHTML = '';
422 var q = load();
423 if (!q.length) { _say = _say && _busy ? _say : ''; return; }
424
425 h.appendChild(drawHead(q));
426 if (_say) h.appendChild(line('rail-note apl-say', _say));
427 q.forEach(function (d) { h.appendChild(drawRow(d)); });
428 }
429
430 /// The head: what the queue is, and the one press that sends the ticked ones.
431 function drawHead(q) {
432 var box = document.createElement('div');
433 box.className = 'apl-head';
434
435 var ticked = q.filter(function (d) { return d.sel && sendable(d); }).length;
436 box.appendChild(line('imp-asat apl-count', tOr('approve.count',
437 '{n} drafts waiting, {sel} ticked. Edit any, tick the ones to send.',
438 { n: q.length, sel: ticked })));
439
440 var acts = document.createElement('div');
441 acts.className = 'imp-acts apl-acts';
442
443 var send = button('imp-send apl-send', 'approve-send',
444 _busy ? tOr('approve.sending', 'Sending…')
445 : tOr('approve.send', 'Send selected'),
446 tOr('approve.send_help', 'Sends exactly the ticked drafts, each as it is in its box.'));
447 if (_busy || !ticked) send.disabled = true;
448 acts.appendChild(send);
449
450 acts.appendChild(button('imp-note-copy apl-all', 'approve-all',
451 tOr('approve.all', 'Tick all'),
452 tOr('approve.all_help', 'Tick every draft that can be sent.')));
453 acts.appendChild(button('imp-note-copy apl-none', 'approve-none',
454 tOr('approve.clear_sel', 'Untick all')));
455 acts.appendChild(button('imp-note-copy apl-clear', 'approve-clear',
456 tOr('approve.clear', 'Empty the queue'),
457 tOr('approve.clear_help', 'Remove every draft. Nothing is sent.')));
458
459 box.appendChild(acts);
460 return box;
461 }
462
463 /// One draft: a checkbox, what it is and where it lands, a box to edit it,
464 /// and a way to take it off.
465 function drawRow(d) {
466 var row = document.createElement('div');
467 row.className = 'apl-row';
468 row.dataset.draft = String(d.id);
469 row.dataset.kind = d.kind;
470
471 var head = document.createElement('div');
472 head.className = 'apl-rowhead';
473
474 var can = sendable(d);
475 if (can) {
476 var box = document.createElement('input');
477 box.type = 'checkbox';
478 box.className = 'apl-tick';
479 box.checked = d.sel;
480 box.dataset.act = 'approve-tick';
481 box.setAttribute('aria-label', tOr('approve.tick', 'Send this one'));
482 head.appendChild(box);
483 }
484
485 var kind = document.createElement('span');
486 kind.className = 'apl-kind';
487 kind.textContent = kindWord(d);
488 head.appendChild(kind);
489
490 // The target, said plainly: which proposal a comment or revision lands on.
491 var target = document.createElement('span');
492 target.className = 'imp-note-state apl-target';
493 target.textContent = (d.kind === 'new')
494 ? tOr('approve.target_new', 'opens a new proposal')
495 : tOr('approve.target_on', 'on proposal #{n}', { n: d.n });
496 head.appendChild(target);
497
498 row.appendChild(head);
499
500 if (d.why) row.appendChild(line('imp-as apl-why', d.why));
501
502 // A revision the forge has not granted: no tick, and a plain sentence
503 // saying why, rather than a control that reaches a route this asker may
504 // not use. Never a path that edits someone else's proposal.
505 if (d.kind === 'revision' && !can && hasVoice()) {
506 row.appendChild(line('imp-as apl-dark', tOr('approve.not_yours',
507 'Only the proposal’s author can revise it.')));
508 } else if (!hasVoice()) {
509 row.appendChild(line('imp-as apl-novoice', tOr('approve.novoice',
510 'No voice yet — a draft can only wait here.')));
511 }
512
513 var ta = document.createElement('textarea');
514 ta.className = 'imp-box apl-box';
515 ta.rows = d.kind === 'comment' ? 3 : 6;
516 ta.value = bodyOf(d);
517 ta.setAttribute('aria-label', kindWord(d));
518 row.appendChild(ta);
519
520 if (d.err) row.appendChild(line('rail-note imp-err apl-err', d.err));
521
522 var acts = document.createElement('div');
523 acts.className = 'imp-acts apl-rowacts';
524 acts.appendChild(button('imp-note-copy apl-drop', 'approve-drop',
525 tOr('approve.drop', 'Not this one'),
526 tOr('approve.drop_help', 'Take this draft off the queue. Nothing is sent.')));
527 row.appendChild(acts);
528
529 return row;
530 }
531
532 // ── Wiring ─────────────────────────────────────────────────
533
534 document.addEventListener('click', function (e) {
535 var h = e.target && e.target.closest ? e.target.closest('#improve-approve') : null;
536 if (!h) return;
537 var b = e.target.closest('[data-act]');
538 if (!b) return;
539 var act = b.dataset.act;
540 if (act === 'approve-send') { e.preventDefault(); sendSelected(); return; }
541 if (act === 'approve-all') { e.preventDefault(); selectAll(true); return; }
542 if (act === 'approve-none') { e.preventDefault(); selectAll(false); return; }
543 if (act === 'approve-clear') { e.preventDefault(); clear(); return; }
544 var row = b.closest('.apl-row');
545 if (!row) return;
546 var id = row.dataset.draft;
547 if (act === 'approve-drop') { e.preventDefault(); remove(id); return; }
548 });
549
550 // A tick is a change, not a click, and it must not redraw the row from under
551 // the pointer: `select` records it and leaves the box alone. The head's count
552 // is refreshed so "N ticked" and the Send button keep pace.
553 document.addEventListener('change', function (e) {
554 var box = e.target;
555 if (!box || box.dataset.act !== 'approve-tick') return;
556 var row = box.closest('.apl-row');
557 if (!row) return;
558 select(row.dataset.draft, box.checked);
559 var h = el('improve-approve');
560 if (h) {
561 var head = h.querySelector('.apl-head');
562 if (head) head.replaceWith(drawHead(load()));
563 }
564 });
565
566 // Say this list's words again in a new language, on the same surface improve.js
567 // and triage.js register -- a locale change redraws it with them.
568 try {
569 DaimondI18n.surface(function () { return el('improve-approve'); },
570 function () { draw(); });
571 } catch (e) { /* no i18n in this build */ }
572
573 window.DaimondApproveList = {
574 /// Drawn by improve.js's `render()`, beside the triage row, so the queue
575 /// and the notes it counts can never disagree.
576 draw: draw,
577 /// Add drafts (triage's own shape) to the queue. Returns how many landed.
578 /// js/triage.js calls this after a run; it is the one way drafts arrive.
579 enqueue: enqueue,
580 /// The one press: send every ticked draft as a batch through the forge
581 /// door. Sent drafts leave; refused ones stay with an error.
582 send: sendSelected,
583 /// Tick or untick one draft, or all.
584 select: select,
585 selectAll: selectAll,
586 /// Take one draft off, or empty the queue.
587 remove: remove,
588 clear: clear,
589 /// The queue on screen, a copy, for a verifier or an account switch.
590 queue: function () { return JSON.parse(JSON.stringify(load())); },
591 /// Whether a batch send is in flight.
592 busy: function () { return _busy; },
593 /// For a verifier that wants a cold list.
594 reset: function () { _q = null; _busy = false; _say = ''; },
595 };
596})();