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 | })(); |