oxedyne/daimond/www/js/improve.js
157 KiB, 190 runs
created by r2519314175:1385, 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 Social panel (improve.js) |
| 3 | ------------------------------------------------------------ |
| 4 | Where a note is written, and where the proposals made from |
| 5 | notes are read and voted on. `dev/IMPROVE_CONTRACT.md` is the |
| 6 | contract; `www/guide/social.html` is the public promise this |
| 7 | panel has to keep, and every part of this screen is called what |
| 8 | that page already calls it: a head, chips, a closer, rows, |
| 9 | and the note box. |
| 10 | |
| 11 | THE PANEL IS SOCIAL AND THIS FILE IS TWO OF ITS FOUR CHIPS. |
| 12 | Decision 13 renamed the `improve` dock panel to `social` and gave |
| 13 | it Messages, People, Notes and Proposals. Notes and Proposals are |
| 14 | this file's, whole. Messages and People are empty containers with |
| 15 | an honest line in each, rendered into by other modules — so this |
| 16 | file also owns the SHELL: the chips, the view switch, and the |
| 17 | `window.DaimondSocial` those modules talk to. It is one file |
| 18 | rather than two because the shell has no life of its own: the |
| 19 | chips, the head and the i18n surface are the same head Notes and |
| 20 | Proposals already hang on. |
| 21 | |
| 22 | The `/api/improve` ROUTE is unchanged. The panel was renamed, not |
| 23 | the door the forge is behind. |
| 24 | |
| 25 | ── A NOTE IS A PROPOSAL NOW ──────────────────────────────── |
| 26 | |
| 27 | This panel used to post a note to `/api/note` and read its |
| 28 | proposals out of a file that shipped with the build. There has |
| 29 | never been a `/api/note` handler in the gateway, so every note a |
| 30 | tester pressed Send on was answered 404 and kept; and |
| 31 | `assets/proposals.json` shipped permanently empty, so the |
| 32 | proposals half drew nothing. Two carefully reasoned halves of one |
| 33 | feature that had never met. |
| 34 | |
| 35 | Both now go through the one door the gateway actually has: |
| 36 | |
| 37 | GET /api/improve?account=&repo=[&state=][&from=][&limit=] |
| 38 | GET /api/improve?account=&repo=&n=<number> |
| 39 | POST /api/improve?account=&repo= open one |
| 40 | POST /api/improve?account=&repo=&n=<number> comment |
| 41 | POST /api/improve?account=&repo=&n=&vote=1 vote |
| 42 | |
| 43 | which the gateway forwards over loopback to the Oregami forge. |
| 44 | The compose box OPENS A PROPOSAL: the first line is its title and |
| 45 | the rest is its body. Keep still keeps, and a note that could not |
| 46 | be sent is still kept here and says so. |
| 47 | |
| 48 | ── THE ONE RULE THIS FILE EXISTS TO KEEP ─────────────────── |
| 49 | |
| 50 | A NOTE LEAVES THIS DEVICE ONLY WHEN A PERSON PRESSES SEND ON |
| 51 | THAT ONE NOTE, AND WHAT LEAVES IS EXACTLY THE CHARACTERS ON |
| 52 | THE SCREEN AT THAT MOMENT. |
| 53 | |
| 54 | Nothing about a note is queued, retried, batched or synced. |
| 55 | |
| 56 | `telemetry.js` keeps the same promise from the other side and |
| 57 | the contrast is worth stating, because the two files look like |
| 58 | opposites and are not. That one makes leaking impossible by |
| 59 | SHAPE: its payload can only ever be integers, so no edit to it |
| 60 | can carry a sentence. Free text has no shape to hide behind, so |
| 61 | this file makes leaking impossible by ACT: there is no sender |
| 62 | until the press, `outgoing()` reads the screen and `split()` CUTS |
| 63 | what it read into the two fields a proposal is -- a cut, never an |
| 64 | addition, so putting the two back together with one newline gives |
| 65 | the characters that were on the screen, and dev/verify_improve.mjs |
| 66 | settles that by comparing two strings. |
| 67 | |
| 68 | The old form had no envelope at all, and said so: an envelope is |
| 69 | somewhere a field can hide. A proposal has a title and a body, so |
| 70 | an envelope there must be, and what replaces the argument is a |
| 71 | check rather than a shape -- the request's FIELD SET is asserted |
| 72 | to be exactly `title`, `body` and at most `build`, so a fifth |
| 73 | field has to defeat a check rather than merely be forgotten. |
| 74 | |
| 75 | ── AND WHAT PRESSING SEND MEANS IS SAID BEFORE IT ────────── |
| 76 | |
| 77 | The forge refuses on a repository's `public` flag before it |
| 78 | examines any credential, and renders public repositories only, |
| 79 | so this panel can read nothing at all unless `oxedyne/daimond` |
| 80 | is public. It is. A proposal there is therefore readable by |
| 81 | ANYBODY, with NO credential, the voice name it was written |
| 82 | under included. The rule above is unchanged by that -- a note |
| 83 | still leaves only on a press -- but the press now means more |
| 84 | than it did, so `drawPublic()` puts that in the box, above the |
| 85 | button, where the person acts. It is on the screen exactly when |
| 86 | Send is, and a person with no voice is told nothing of the kind, |
| 87 | because they cannot send and it would not be true. |
| 88 | |
| 89 | And a note that cannot be sent STAYS HERE AND SAYS SO. There is |
| 90 | no retry: a queue of text outlives the consent that filled it, |
| 91 | which is the failure telemetry.js refuses by not remembering |
| 92 | consent at all. Copy is offered instead. |
| 93 | |
| 94 | ── WHAT GOES WITH A NOTE GOES IN THE NOTE ────────────────── |
| 95 | The guide asks a user to say which build, which panel was open, |
| 96 | whether they were on a phone, which palette they were wearing. |
| 97 | That is gathered for them and shown as one line, in the exact |
| 98 | characters that will be appended, in a row with a CLOSER on it. |
| 99 | Closing the row takes the line off the screen and off the wire -- |
| 100 | and off the `build` field with it, since that field carries the |
| 101 | same characters the row's first item shows. There is no third |
| 102 | state and nothing is gathered silently. |
| 103 | |
| 104 | ── A CONTROL DRAWS FROM THE ANSWER, NOT FROM A DATE ──────── |
| 105 | Contract §9 puts voting on the forge, and the forge answers it: |
| 106 | `views/proposals.rs` dispatches `proposals/<n>/vote`, and both |
| 107 | the listing and the whole record carry `votes`, checked against |
| 108 | the deployed host on 2026-08-27. So the control DRAWS, and it |
| 109 | draws for the reason it was built to -- BECAUSE THE ANSWER |
| 110 | CARRIES `votes`, never because a comment here said the day had |
| 111 | come. A visible control that reaches nothing is the defect this |
| 112 | file was rewritten to remove, and a control gated on the answer |
| 113 | cannot become one. |
| 114 | |
| 115 | That is the whole point of the shape, and it has now been paid |
| 116 | off once: nothing in this file changed when the forge started |
| 117 | answering. The three sentences that said otherwise -- here, at |
| 118 | `cleanProp`, and in the contract's §9 -- were prose that had gone |
| 119 | stale while the code stayed right, which is the failure mode a |
| 120 | dated claim has and a derived one does not. |
| 121 | |
| 122 | AMENDMENT IS THE SAME SHAPE AGAIN, and it has now paid off |
| 123 | twice. The forge answers `POST proposals/<n>/amend` and carries |
| 124 | `mine_to_amend` beside `mine`; the gateway's door was already |
| 125 | open (`&amend=1`), and `drawAmendControl` below draws only when |
| 126 | that flag is PRESENT. Absent is not false -- see `cleanProp`. |
| 127 | The whole proposal also carries `revisions`, a LIST oldest |
| 128 | first and EMPTY rather than absent, which is not the count |
| 129 | `comments` beside it is. |
| 130 | |
| 131 | Attaches two globals, `window.DaimondSocial` (the panel |
| 132 | shell) and `window.DaimondImprove` (notes and proposals). |
| 133 | ============================================================ */ |
| 134 | (function () { |
| 135 | 'use strict'; |
| 136 | |
| 137 | // ── Saying things ────────────────────────────────────────── |
| 138 | |
| 139 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 140 | |
| 141 | /// A string with the English written at the call site as its fallback. |
| 142 | /// |
| 143 | /// `t` answers with the KEY when the table has no entry, so a panel built |
| 144 | /// against keys the locale files have not been given yet would read |
| 145 | /// "social.send" on screen. Every string this panel adds is new, and they |
| 146 | /// are routed to the catalogue separately from this file, so all of them go |
| 147 | /// through here: the English shows until the tables catch up, and not one |
| 148 | /// moment longer. trash.js keeps the same discipline for the same reason. |
| 149 | function tOr(k, fallback, v) { |
| 150 | var s = t(k, v); |
| 151 | if (s !== k) return s; |
| 152 | if (!v) return fallback; |
| 153 | return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) { |
| 154 | return v[name] != null ? String(v[name]) : whole; |
| 155 | }); |
| 156 | } |
| 157 | |
| 158 | function tnOr(k, n, one, other, v) { |
| 159 | var s = window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k; |
| 160 | if (s.indexOf(k) !== 0) return s; |
| 161 | return String(n === 1 ? one : other).replace(/\{(\w+)\}/g, function (whole, name) { |
| 162 | return v && v[name] != null ? String(v[name]) : whole; |
| 163 | }); |
| 164 | } |
| 165 | |
| 166 | function log(/* ...args */) { |
| 167 | try { if (window.console && console.debug) console.debug.apply(console, ['[improve]'].concat([].slice.call(arguments))); } |
| 168 | catch (e) { /* no console */ } |
| 169 | } |
| 170 | |
| 171 | function el(id) { return document.getElementById(id); } |
| 172 | |
| 173 | // ── The repository this panel reads ──────────────────────── |
| 174 | // |
| 175 | // One account, one repository, one panel: the contract has no |
| 176 | // cross-repository listing and this is not a setting. It names Daimond's own |
| 177 | // forge repository, which is the same for every tester, so a knob for it |
| 178 | // would be a knob whose only correct value is this one. |
| 179 | |
| 180 | var ACCOUNT = 'oxedyne'; |
| 181 | var REPO = 'daimond'; |
| 182 | |
| 183 | /// How many proposals one page carries. Well inside the contract's 1..200, |
| 184 | /// and small enough that opening the panel on a large repository draws |
| 185 | /// something immediately rather than after two hundred records. |
| 186 | var PAGE = 25; |
| 187 | |
| 188 | // ── The store ────────────────────────────────────────────── |
| 189 | // |
| 190 | // One key, namespaced per account by accounts.js like every other |
| 191 | // `daimond-*`. Notes are NOT in the sync parcel and are not coming: a note is |
| 192 | // a report of a moment on the device it happened on, and making them travel |
| 193 | // needs a merge rule this contract does not have. |
| 194 | // |
| 195 | // VOTES ARE NOT HERE ANY MORE. They used to be, with a queue that carried one |
| 196 | // made offline. Contract §9 puts the tally on the forge, and a copy here |
| 197 | // would be a second store of truth about one proposal -- which is exactly |
| 198 | // what §9 rejected both alternatives for. What the forge says is what is |
| 199 | // drawn. |
| 200 | |
| 201 | var KEY = 'daimond-improve'; |
| 202 | var MAX_NOTES = 200; // past this the oldest KEPT note goes; sent ones stay |
| 203 | var MAX_RAISED = 500; // cap on remembered posted-proposal numbers, oldest dropped |
| 204 | // What one note may be. NOT the gateway's cap, which is what this said until |
| 205 | // 2026-08-28: the improve route forwards at most 64 KiB (`MAX_BODY`, |
| 206 | // gateway/src/handlers/improve.rs:227) and no 20000 exists anywhere under |
| 207 | // `gateway/src/`. This is a client-side choice and a defensible one -- 20,000 |
| 208 | // characters is a long report and well inside the door, so a note trimmed here |
| 209 | // is never a note the hop refuses -- but it was justified by a number nobody |
| 210 | // wrote, and the next reader would have moved it to match a cap it does not track. |
| 211 | var MAX_CHARS = 20000; |
| 212 | |
| 213 | var _st = null; |
| 214 | |
| 215 | /// A millisecond stamp, or 0. Not `n | 0`: epoch-ms is past 32 bits. |
| 216 | function ms(v) { |
| 217 | return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0; |
| 218 | } |
| 219 | |
| 220 | /// A whole number, or 0. |
| 221 | function whole(v) { |
| 222 | return (typeof v === 'number' && isFinite(v)) ? Math.floor(v) : 0; |
| 223 | } |
| 224 | |
| 225 | /// One stored note, defended against whatever was in storage. |
| 226 | function cleanNote(r) { |
| 227 | if (!r || typeof r !== 'object') return null; |
| 228 | var text = (typeof r.text === 'string') ? r.text : ''; |
| 229 | if (!text) return null; |
| 230 | return { |
| 231 | id: String(r.id || '') || ('n' + ms(r.at)), |
| 232 | at: ms(r.at) || Date.now(), |
| 233 | text: text.slice(0, MAX_CHARS), |
| 234 | mode: (r.mode === 'polish') ? 'polish' : 'verbatim', |
| 235 | build: (typeof r.build === 'string') ? r.build : '', |
| 236 | sent: ms(r.sent), |
| 237 | /// The proposal this note became, or 0. Kept so a row can name it: a |
| 238 | /// tester who sent something and was told only "Sent" has no way back |
| 239 | /// to what happened to it. |
| 240 | n: Math.max(0, whole(r.n)), |
| 241 | // Folded, which is not sent |
| 242 | // |
| 243 | // NOT `n`, and the difference is this panel's only copy of somebody's |
| 244 | // words. `n` is the proposal this note BECAME: the forge holds these |
| 245 | // exact characters, so what is here is a second copy. `into` is a |
| 246 | // proposal that js/triage.js DRAFTED from this note, usually beside |
| 247 | // others -- the forge holds the drafting, which is not what this |
| 248 | // person wrote, and where several notes were folded together it holds |
| 249 | // a fragment of each. A folded note is therefore still the only copy |
| 250 | // of what somebody wrote, and `save()` must never evict one. |
| 251 | // |
| 252 | // A LIST, because one note holding two faults is drafted into two |
| 253 | // proposals. This tree has lost user data once already to a field |
| 254 | // doing double duty; `n` doing this one as well would be the same |
| 255 | // mistake with the same consequence. |
| 256 | into: intoList(r.into), |
| 257 | }; |
| 258 | } |
| 259 | |
| 260 | /// The proposal numbers on a stored note's `into`, cleaned. Whole numbers |
| 261 | /// above zero, no repeats, and capped: a record that arrived from anywhere |
| 262 | /// but this file's own `fold()` is still only a list of numbers. |
| 263 | function intoList(v) { |
| 264 | if (!Array.isArray(v)) return []; |
| 265 | var out = []; |
| 266 | for (var i = 0; i < v.length && out.length < 32; i++) { |
| 267 | var n = Math.max(0, whole(v[i])); |
| 268 | if (n > 0 && out.indexOf(n) === -1) out.push(n); |
| 269 | } |
| 270 | return out; |
| 271 | } |
| 272 | |
| 273 | function load() { |
| 274 | if (_st) return _st; |
| 275 | _st = { notes: [], raised: [] }; |
| 276 | try { |
| 277 | var raw = JSON.parse(localStorage.getItem(KEY) || '{}') || {}; |
| 278 | (Array.isArray(raw.notes) ? raw.notes : []).forEach(function (n) { |
| 279 | var c = cleanNote(n); |
| 280 | if (c) _st.notes.push(c); |
| 281 | }); |
| 282 | // The proposal numbers this device has POSTED, kept apart from the notes. |
| 283 | // A posted note leaves the queue at once (`through`), so the number it |
| 284 | // became is the only trace that this device raised it -- see `recordRaised`. |
| 285 | (Array.isArray(raw.raised) ? raw.raised : []).forEach(function (n) { |
| 286 | var w = whole(n); |
| 287 | if (w > 0 && _st.raised.indexOf(w) === -1) _st.raised.push(w); |
| 288 | }); |
| 289 | } catch (e) { _st = { notes: [], raised: [] }; } |
| 290 | return _st; |
| 291 | } |
| 292 | |
| 293 | /// Does the forge hold this note's own characters? |
| 294 | /// |
| 295 | /// The one question `save()`'s cap turns on, and the reason it is a function |
| 296 | /// with a name. `sent` says these exact characters went to the forge as a |
| 297 | /// proposal, so a second copy here is spare. `into` says a DRAFT written from |
| 298 | /// this note went instead -- the forge holds the drafting, and where several |
| 299 | /// notes were folded into one proposal it holds a fragment of each. So a |
| 300 | /// folded note is still the only copy of what somebody wrote and is never |
| 301 | /// spare, whatever else is true of it. |
| 302 | /// |
| 303 | /// Both are tested rather than only `sent`, although no path here sets both: |
| 304 | /// a rule this file cannot afford to get wrong should not also depend on a |
| 305 | /// rule kept somewhere else. |
| 306 | function delivered(rec) { |
| 307 | return !!(rec && rec.sent && !(rec.into && rec.into.length)); |
| 308 | } |
| 309 | |
| 310 | /// The proposal numbers this DEVICE has raised, de-duped, whole and above zero. |
| 311 | /// |
| 312 | /// The local voice has NO NAME -- the secret is the identity and this client |
| 313 | /// never learns its own author (voice.js) -- so "raised here" cannot be matched |
| 314 | /// against a proposal's `author`. It is matched by the numbers this file already |
| 315 | /// tracks: every stored note's `into` (the proposals a draft written from it was |
| 316 | /// folded into) and its `n` (the proposal the note itself became, where a path |
| 317 | /// keeps the note). The Improve hub's "Mine" filter reads this and shows only |
| 318 | /// those numbers. A read, so nothing here writes the store. |
| 319 | function raisedProposalNumbers() { |
| 320 | var out = []; |
| 321 | var s = load(); |
| 322 | s.notes.forEach(function (rec) { |
| 323 | (rec.into || []).forEach(function (n) { |
| 324 | if (n > 0 && out.indexOf(n) === -1) out.push(n); |
| 325 | }); |
| 326 | if (rec.n > 0 && out.indexOf(rec.n) === -1) out.push(rec.n); |
| 327 | }); |
| 328 | // Numbers from posts whose note has already left the queue. |
| 329 | (s.raised || []).forEach(function (n) { |
| 330 | if (n > 0 && out.indexOf(n) === -1) out.push(n); |
| 331 | }); |
| 332 | return out; |
| 333 | } |
| 334 | |
| 335 | /// Remember that this device posted proposal `n`, so the Improve hub's "Mine" |
| 336 | /// filter shows it. A verbatim post removes its note the moment the forge takes |
| 337 | /// it (`through`), so the number is the only surviving trace that THIS device |
| 338 | /// raised the proposal -- a resurrected note would be wrong (the note is spare |
| 339 | /// once the proposal exists), but the authorship fact is not spare. |
| 340 | function recordRaised(n) { |
| 341 | var num = Math.max(0, whole(n)); |
| 342 | if (!num) return; |
| 343 | var s = load(); |
| 344 | if (!Array.isArray(s.raised)) s.raised = []; |
| 345 | if (s.raised.indexOf(num) !== -1) return; |
| 346 | s.raised.push(num); |
| 347 | // A cap for symmetry with MAX_NOTES: drop the oldest numbers, which matter |
| 348 | // least once a proposal is long settled. |
| 349 | if (s.raised.length > MAX_RAISED) s.raised.splice(0, s.raised.length - MAX_RAISED); |
| 350 | save(); |
| 351 | } |
| 352 | |
| 353 | function save() { |
| 354 | var s = load(); |
| 355 | // Newest first on disk as well as on screen, so a person reading the raw |
| 356 | // key finds what they just wrote at the top of it. |
| 357 | s.notes.sort(function (a, b) { return b.at - a.at || (a.id < b.id ? 1 : -1); }); |
| 358 | // A cap, so a panel nobody empties cannot fill the quota. |
| 359 | // |
| 360 | // THE OLDEST SENT NOTES GO FIRST. A sent note has a copy at the other end; |
| 361 | // a kept one is the ONLY copy of what somebody wrote, and dropping it to |
| 362 | // make room for a note that has already been delivered is losing the one |
| 363 | // that mattered. Kept notes are dropped only if there is nothing else |
| 364 | // left to drop, and then oldest first like anything else. |
| 365 | // |
| 366 | // A FOLDED NOTE IS NOT A DELIVERED ONE, and `delivered()` is where that |
| 367 | // is decided rather than here, so the one question this rule turns on is |
| 368 | // asked in one place and can be proved on its own. |
| 369 | if (s.notes.length > MAX_NOTES) { |
| 370 | var over = s.notes.length - MAX_NOTES; |
| 371 | for (var i = s.notes.length - 1; i >= 0 && over > 0; i--) { |
| 372 | if (delivered(s.notes[i])) { s.notes.splice(i, 1); over--; } |
| 373 | } |
| 374 | if (over > 0) s.notes.length = MAX_NOTES; |
| 375 | } |
| 376 | try { localStorage.setItem(KEY, JSON.stringify({ v: 3, notes: s.notes, raised: s.raised || [] })); } |
| 377 | catch (e) { log('could not write the notes', e); } |
| 378 | } |
| 379 | |
| 380 | // ── What goes with a note ────────────────────────────────── |
| 381 | // |
| 382 | // The guide's fourth piece of advice, gathered rather than asked for. Every |
| 383 | // item here is one the guide names by name, and each is a fact about the |
| 384 | // APP, never about the person: no user agent string, no screen fingerprint, |
| 385 | // no id of any kind. |
| 386 | |
| 387 | var _build = ''; |
| 388 | |
| 389 | /// Read the build id once, from the same `build.json` the updater reads. |
| 390 | /// A failure leaves it empty, and the line simply does not name a build -- |
| 391 | /// which is honest, where a guessed one would not be. |
| 392 | function readBuild() { |
| 393 | try { |
| 394 | return fetch('build.json', { cache: 'no-store' }) |
| 395 | .then(function (r) { return r.ok ? r.json() : null; }) |
| 396 | .then(function (j) { _build = (j && typeof j.build === 'string') ? j.build : ''; }) |
| 397 | .catch(function () { /* no build id; the line says less */ }); |
| 398 | } catch (e) { return Promise.resolve(); } |
| 399 | } |
| 400 | |
| 401 | /// Which panels are open, by the label the user reads on their chips -- not |
| 402 | /// by their ids, which mean nothing to the person writing the note and |
| 403 | /// nothing to a reader who has only ever seen the screen. |
| 404 | function openPanels() { |
| 405 | try { |
| 406 | if (!window.DaimondPanels || !DaimondPanels.panels) return []; |
| 407 | return DaimondPanels.panels() |
| 408 | .filter(function (p) { return DaimondPanels.isOpen(p.id); }) |
| 409 | .map(function (p) { return p.label; }); |
| 410 | } catch (e) { return []; } |
| 411 | } |
| 412 | |
| 413 | /// The one line that is appended to a note, in the characters that will |
| 414 | /// travel. Built here and shown verbatim; nothing is added on the way out. |
| 415 | function context() { |
| 416 | var bits = []; |
| 417 | if (_build) bits.push(tOr('social.ctx_build', 'Build {id}', { id: _build })); |
| 418 | try { |
| 419 | var loc = window.DaimondI18n ? DaimondI18n.locale() : ''; |
| 420 | if (loc) bits.push(loc); |
| 421 | } catch (e) { /* no i18n */ } |
| 422 | try { bits.push(window.innerWidth + '×' + window.innerHeight); } catch (e) { /* no window */ } |
| 423 | try { |
| 424 | var coarse = window.matchMedia && window.matchMedia('(any-pointer: coarse)').matches; |
| 425 | bits.push(coarse |
| 426 | ? tOr('social.ctx_touch', 'touch') |
| 427 | : tOr('social.ctx_pointer', 'pointer')); |
| 428 | } catch (e) { /* no matchMedia */ } |
| 429 | try { |
| 430 | var theme = localStorage.getItem('daimond-theme'); |
| 431 | var skin = localStorage.getItem('daimond-skin'); |
| 432 | if (theme) bits.push(tOr('social.ctx_palette', 'palette {name}', { name: theme + (skin ? ' ' + skin : '') })); |
| 433 | } catch (e) { /* private mode */ } |
| 434 | var panels = openPanels(); |
| 435 | if (panels.length) bits.push(tOr('social.ctx_panels', 'panels open: {list}', { list: panels.join(', ') })); |
| 436 | return bits.join(' · '); |
| 437 | } |
| 438 | |
| 439 | // ── The note box ─────────────────────────────────────────── |
| 440 | |
| 441 | /// Whether the "What goes with it" row has been closed for this note. |
| 442 | function contextOff() { |
| 443 | var row = el('improve-with'); |
| 444 | return !!(row && row.dataset.off === '1'); |
| 445 | } |
| 446 | |
| 447 | /// The exact characters a Send would put on the wire, right now. |
| 448 | /// |
| 449 | /// THE ONE FUNCTION THAT DECIDES WHAT LEAVES. The box's value and, when the |
| 450 | /// row is still on screen, the text that row is showing -- read off the node, |
| 451 | /// not rebuilt, so a line the user cannot see cannot be in it. `send()` calls |
| 452 | /// this, `split()` cuts the result in two, and nothing else contributes. |
| 453 | /// |
| 454 | /// THE ROW IS REDRAWN FIRST, in the same breath as the read. The line names |
| 455 | /// the palette and the panels that are open, and both of those change under a |
| 456 | /// panel that is already on screen: on a desktop the Social panel stays put |
| 457 | /// while somebody switches palette or opens something else, and a row rendered |
| 458 | /// when the panel opened then describes a screen they have left behind. Notes |
| 459 | /// went out for weeks naming a palette nobody was looking at. Redrawing here, |
| 460 | /// rather than listening for every way the app can move, is what makes that a |
| 461 | /// closed class instead of two patched instances -- there is no signal left to |
| 462 | /// forget, because the line is computed at the moment of the press, shown, and |
| 463 | /// then read off the node exactly as before. Still a read of the screen: the |
| 464 | /// characters returned are the characters the row is showing when it returns. |
| 465 | /// |
| 466 | /// CLOSING THE ROW STILL TAKES THE LINE OFF THE WIRE. The `contextOff()` line |
| 467 | /// below returns before the redraw is reached, and `drawContext()` leaves a |
| 468 | /// closed row shut in any case. |
| 469 | function outgoing() { |
| 470 | var box = el('improve-box'); |
| 471 | var body = box ? String(box.value || '').trim() : ''; |
| 472 | if (!body) return ''; |
| 473 | if (contextOff()) return body; |
| 474 | drawContext(); |
| 475 | var line = el('improve-with-text'); |
| 476 | var ctx = line ? String(line.textContent || '').trim() : ''; |
| 477 | return ctx ? (body + '\n\n' + ctx) : body; |
| 478 | } |
| 479 | |
| 480 | /// The note, cut into the fields a proposal is made of, or null when there is |
| 481 | /// no title to make one with. |
| 482 | /// |
| 483 | /// A CUT AND NEVER AN ADDITION. `title` is the characters before the first |
| 484 | /// newline and `body` is the characters after it, both verbatim, so |
| 485 | /// `title + '\n' + body` is exactly what `outgoing()` read off the screen -- |
| 486 | /// which is the property the verifier settles by comparing two strings. A |
| 487 | /// note with no newline in it is all title and an empty body. |
| 488 | /// |
| 489 | /// `build` is the sealed build identifier contract §6 asks a panel to write, |
| 490 | /// and it travels ONLY while the "What goes with it" row is on screen: those |
| 491 | /// are the same characters that row's first item shows, so closing the row |
| 492 | /// takes them off the wire here as well as out of the body. |
| 493 | function split(text) { |
| 494 | var i = text.indexOf('\n'); |
| 495 | var title = (i < 0) ? text : text.slice(0, i); |
| 496 | var body = (i < 0) ? '' : text.slice(i + 1); |
| 497 | if (!title.trim()) return null; |
| 498 | return { title: title, body: body, build: contextOff() ? '' : _build }; |
| 499 | } |
| 500 | |
| 501 | /// Redraw the row that says what goes with the note. Left closed if the user |
| 502 | /// closed it: a row that reappeared on every keystroke would be a control |
| 503 | /// that does not stay pressed. |
| 504 | function drawContext() { |
| 505 | var row = el('improve-with'), line = el('improve-with-text'); |
| 506 | if (!row || !line) return; |
| 507 | if (row.dataset.off === '1') { row.hidden = true; return; } |
| 508 | var ctx = context(); |
| 509 | line.textContent = ctx; |
| 510 | row.hidden = !ctx; |
| 511 | } |
| 512 | |
| 513 | // ── The voice a proposal is written with ─────────────────── |
| 514 | // |
| 515 | // `voice.js` holds it, encrypted under the user's passphrase, and this panel |
| 516 | // is the only surface that has ever needed one. Without a place to GET it the |
| 517 | // whole write half would be unreachable, which is the defect this file was |
| 518 | // rewritten to remove -- so the place is here, beside the button that needs |
| 519 | // it, rather than in a settings screen a tester would have to be told about. |
| 520 | // |
| 521 | // ONE TAP, NOT A PASTE. A first voice is now GOT rather than pasted: `Get my |
| 522 | // voice` posts to `/api/voice/provision`, the gateway has the forge mint one, |
| 523 | // and the secret it answers with is handed straight to `DaimondVoice.set` and |
| 524 | // dropped in the same breath. Nothing here logs it, draws it, or keeps it past |
| 525 | // the wrap. Pasting survives only as an unobtrusive fallback for a voice the |
| 526 | // forge made elsewhere -- kept because a secret can arrive out of band, not |
| 527 | // because a first voice is got that way. |
| 528 | // |
| 529 | // WHY A TAP AND NOT SILENT. Provisioning is a forge WRITE, so it happens on a |
| 530 | // press and never on its own: the button says what it will do, and a tester |
| 531 | // who only wants to read is never surprised by a voice appearing in their name. |
| 532 | |
| 533 | function voice() { return window.DaimondVoice || null; } |
| 534 | |
| 535 | /// The gateway module, or null in a build without it. |
| 536 | function gw() { return window.DaimondGateway || null; } |
| 537 | |
| 538 | /// Is a voice held on this device? Presence only -- reading it needs the |
| 539 | /// passphrase, and that question is asked at the moment of a request. |
| 540 | function hasVoice() { |
| 541 | var v = voice(); |
| 542 | try { return !!(v && v.has()); } catch (e) { return false; } |
| 543 | } |
| 544 | |
| 545 | var _voiceOpen = false; // whether the manual-paste fallback form is showing |
| 546 | var _voiceBusy = false; // a provision request is in flight |
| 547 | var _voiceAlready = false; // the forge holds a voice this device has not got yet |
| 548 | |
| 549 | /// Whether a voice is held, and how to change it -- drawn in the Settings view |
| 550 | /// now, not beside the compose box. Built here rather than in the markup because |
| 551 | /// every word of it is drawn from this file anyway. `drawSettings` calls this |
| 552 | /// after it has put the section heading in place. |
| 553 | function drawVoice() { |
| 554 | var write = el('improve-settings'); |
| 555 | if (!write) return; |
| 556 | var host = el('improve-voice'); |
| 557 | if (!host) { |
| 558 | host = document.createElement('div'); |
| 559 | // `.imp-acts` lays a row of buttons out with a sentence above them, which |
| 560 | // is exactly this row's shape, so the panel needs no new rule to be |
| 561 | // legible. |
| 562 | host.className = 'imp-acts imp-voice'; |
| 563 | host.id = 'improve-voice'; |
| 564 | write.appendChild(host); |
| 565 | } |
| 566 | host.innerHTML = ''; |
| 567 | if (!voice()) return; // no voice.js in this build |
| 568 | |
| 569 | var line = document.createElement('span'); |
| 570 | line.className = 'imp-as'; |
| 571 | line.id = 'improve-voice-say'; |
| 572 | line.textContent = hasVoice() |
| 573 | ? tOr('social.voice_held', 'Voice held on this device, encrypted under your passphrase.') |
| 574 | : tOr('social.voice_none', 'No voice yet: you can read proposals, but only Keep a note to this device.'); |
| 575 | host.appendChild(line); |
| 576 | |
| 577 | // THE MANUAL-PASTE FALLBACK, shared by "Replace the voice" (a voice is |
| 578 | // held) and the unobtrusive "I already have a voice" affordance (none is). |
| 579 | // It is no longer how a FIRST voice is got -- that is one tap now -- but a |
| 580 | // voice the forge made elsewhere can still be pasted in, so the form stays. |
| 581 | if (_voiceOpen) { |
| 582 | var input = document.createElement('input'); |
| 583 | input.type = 'password'; |
| 584 | input.className = 'imp-box'; |
| 585 | input.id = 'improve-voice-in'; |
| 586 | input.autocomplete = 'off'; |
| 587 | input.spellcheck = false; |
| 588 | input.placeholder = tOr('social.voice_ph', 'Paste the line the forge showed you'); |
| 589 | input.setAttribute('aria-label', tOr('social.voice_ph', 'Paste the line the forge showed you')); |
| 590 | host.appendChild(input); |
| 591 | host.appendChild(button('imp-send', 'improve-voice-save', tOr('social.voice_save', 'Save the voice'))); |
| 592 | host.appendChild(button('imp-keep', 'improve-voice-cancel', t('common.cancel'))); |
| 593 | return; |
| 594 | } |
| 595 | |
| 596 | // A VOICE IS HELD: replace it, or forget the copy on this device. |
| 597 | if (hasVoice()) { |
| 598 | host.appendChild(button('imp-note-copy', 'improve-voice-open', |
| 599 | tOr('social.voice_replace', 'Replace the voice'), |
| 600 | tOr('social.voice_help', 'The line the forge showed you. Kept encrypted on this device.'))); |
| 601 | host.appendChild(button('imp-note-copy', 'improve-voice-forget', |
| 602 | tOr('social.voice_forget', 'Forget it'), |
| 603 | tOr('social.voice_forget_help', 'Remove the copy on this device.'))); |
| 604 | return; |
| 605 | } |
| 606 | |
| 607 | // NO VOICE HELD. One tap gets one, and there is nothing to paste. |
| 608 | // |
| 609 | // The old empty state told a reader to ask Oxedyne for an invitation link |
| 610 | // and paste 45 characters back in -- true of how the forge issues a voice, |
| 611 | // but a dead end for a person who just wants to write, which is what the |
| 612 | // owner met. Provisioning makes the voice for them: the honest instruction |
| 613 | // is now "tap the button", and the button does exactly what it says. |
| 614 | |
| 615 | // THE FORGE ALREADY HOLDS ONE, on another device -- the provision call said |
| 616 | // `already`. It is not minted again here; it arrives by sync. Say so, and |
| 617 | // offer the one way out if it never comes: a re-issue, which is destructive. |
| 618 | if (_voiceAlready) { |
| 619 | var arriving = document.createElement('span'); |
| 620 | arriving.className = 'imp-as'; |
| 621 | arriving.id = 'improve-voice-arriving'; |
| 622 | arriving.textContent = tOr('social.voice_already', |
| 623 | 'Your voice is set on another device and will sync here shortly.'); |
| 624 | host.appendChild(arriving); |
| 625 | host.appendChild(button('imp-note-copy', 'improve-voice-reissue', |
| 626 | tOr('social.voice_reissue', 'I lost my voice \u2014 re-issue'), |
| 627 | tOr('social.voice_reissue_help', |
| 628 | 'Makes a new voice; the old one stops working everywhere. Cannot be undone.'))); |
| 629 | return; |
| 630 | } |
| 631 | |
| 632 | var how = document.createElement('span'); |
| 633 | how.className = 'imp-as'; |
| 634 | how.id = 'improve-voice-how'; |
| 635 | how.textContent = tOr('social.voice_intro', |
| 636 | 'A voice lets you post, reply and vote on the forge; reading needs none.', |
| 637 | { host: FORGE_HOST }); |
| 638 | host.appendChild(how); |
| 639 | |
| 640 | // THE PRIMARY PATH: one tap. Disabled and relabelled while the request is |
| 641 | // in flight, so a second press cannot mint a second voice. |
| 642 | var get = button('imp-send', 'improve-voice-get', |
| 643 | _voiceBusy |
| 644 | ? tOr('social.voice_getting', 'Making your voice on the forge\u2026') |
| 645 | : tOr('social.voice_get', 'Get my voice'), |
| 646 | tOr('social.voice_get_help', |
| 647 | 'Makes your voice on the forge. One tap.')); |
| 648 | if (_voiceBusy) get.disabled = true; |
| 649 | host.appendChild(get); |
| 650 | |
| 651 | // THE UNOBTRUSIVE FALLBACK, kept per the owner's instruction behind an "I |
| 652 | // already have a voice" affordance rather than as the primary path: for a |
| 653 | // voice the forge made elsewhere and handed over out of band. |
| 654 | host.appendChild(button('imp-keep', 'improve-voice-open', |
| 655 | tOr('social.voice_have', 'I already have a voice'), |
| 656 | tOr('social.voice_have_help', |
| 657 | 'Paste a voice the forge already gave you.'))); |
| 658 | } |
| 659 | |
| 660 | /// Take the secret off the input and hand it to voice.js, which wraps it. |
| 661 | /// The input is emptied whatever happened: a secret left in a field is a |
| 662 | /// secret in a screenshot. |
| 663 | async function saveVoice() { |
| 664 | var input = el('improve-voice-in'); |
| 665 | if (!input) return false; |
| 666 | var raw = String(input.value || ''); |
| 667 | input.value = ''; |
| 668 | var v = voice(); |
| 669 | if (!v) return false; |
| 670 | var why = ''; |
| 671 | try { why = v.check(raw); } catch (e) { why = ''; } |
| 672 | if (why) { flash(why); return false; } |
| 673 | try { await v.set(raw); } |
| 674 | catch (e) { flash(e && e.message ? String(e.message) : tOr('social.voice_failed', 'That voice could not be stored.')); return false; } |
| 675 | _voiceOpen = false; |
| 676 | _voiceAlready = false; |
| 677 | flash(tOr('social.voice_saved', 'Your voice is held here, encrypted.')); |
| 678 | render(); |
| 679 | return true; |
| 680 | } |
| 681 | |
| 682 | /// A Daimond-session POST that keeps the STATUS and the BODY. |
| 683 | /// |
| 684 | /// The gateway's own `post()` reduces every failure to a message string and |
| 685 | /// throws it, but this panel has to tell a 402 `pro_required` from a 200 |
| 686 | /// `already` and from an ordinary failure, and those live in the status and the |
| 687 | /// body. So it goes through `DaimondGateway.gwFetch` -- the one copy of the |
| 688 | /// session rule, renew once and retry once -- and reads the answer itself. |
| 689 | /// |
| 690 | /// NOT `DaimondVoice.send`: provisioning is how a voice is GOT, so there is no |
| 691 | /// voice to carry and this is a Daimond-account call rather than a forge one. |
| 692 | async function gwPost(path, body) { |
| 693 | var g = gw(); |
| 694 | if (!g || !g.gwFetch) return { ok: false, status: 0, data: null }; |
| 695 | var r; |
| 696 | try { |
| 697 | r = await g.gwFetch(path, { |
| 698 | method: 'POST', |
| 699 | credentials: 'same-origin', |
| 700 | headers: { |
| 701 | 'content-type': 'application/json', |
| 702 | 'x-daimond-api': String(g.clientApi ? g.clientApi() : ''), |
| 703 | }, |
| 704 | body: JSON.stringify(body || {}), |
| 705 | }); |
| 706 | } catch (e) { |
| 707 | // A network failure throws a `TypeError` whose message is the browser's |
| 708 | // own English; it must not reach a screen this app has translated. The |
| 709 | // caller says its own sentence off `ok:false`. |
| 710 | return { ok: false, status: 0, data: null }; |
| 711 | } |
| 712 | var data = null; |
| 713 | try { data = await r.json(); } catch (e) { data = null; } |
| 714 | return { ok: r.ok, status: r.status, data: data }; |
| 715 | } |
| 716 | |
| 717 | /// Make this device a voice, on a press. |
| 718 | /// |
| 719 | /// POST `/api/voice/provision` -- body `{}` to mint or adopt, `{reissue:true}` |
| 720 | /// to replace a voice the forge holds that this device cannot read. The gateway |
| 721 | /// forwards to the forge and answers one of: |
| 722 | /// |
| 723 | /// 200 { provisioned:true, secret:"<45>" } a voice was minted -- hold it |
| 724 | /// 200 { provisioned:true, already:true } one exists already -- it syncs |
| 725 | /// 402 { error:"pro_required" } a voice is part of Pro |
| 726 | /// |
| 727 | /// A minted secret is handed straight to `DaimondVoice.set`, which wraps it |
| 728 | /// under the passphrase, and the local reference is dropped in the same breath. |
| 729 | /// NOTHING HERE LOGS, DRAWS OR KEEPS THE SECRET -- the same rule voice.js opens |
| 730 | /// with, kept on this side of the call as well. |
| 731 | async function provision(reissue) { |
| 732 | var v = voice(); |
| 733 | if (!v || _voiceBusy) return false; |
| 734 | // A voice is wrapped at rest under the passphrase, so `set()` needs an |
| 735 | // unlocked identity. Say so before a round trip that would only fail at the |
| 736 | // end of it -- and before a forge write is made that could not be held. |
| 737 | try { |
| 738 | if (window.DaimondIdentity && !DaimondIdentity.isUnlocked()) { |
| 739 | flash(tOr('voice.err.locked', |
| 740 | 'Unlock Daimond first: your voice is kept encrypted under your passphrase.')); |
| 741 | return false; |
| 742 | } |
| 743 | } catch (e) { /* no identity module: let set() below speak */ } |
| 744 | |
| 745 | _voiceBusy = true; |
| 746 | drawVoice(); |
| 747 | var a = await gwPost('/api/voice/provision', reissue ? { reissue: true } : {}); |
| 748 | _voiceBusy = false; |
| 749 | |
| 750 | // A VOICE IS PART OF PRO. Say it once and hand the person to the offer that |
| 751 | // already exists -- the Pro block at the top of the Credits drawer, the same |
| 752 | // door mail.js and sync.js send a buyer to -- rather than drawing a second |
| 753 | // one here. Read off the status OR the token, so a body-less 402 still lands. |
| 754 | if (a.status === 402 || (a.data && a.data.error === 'pro_required')) { |
| 755 | flash(tOr('social.voice_pro', 'A voice is part of Daimond Pro.')); |
| 756 | try { |
| 757 | if (window.DaimondAdmin && DaimondAdmin.credits) { |
| 758 | DaimondAdmin.credits(tOr('social.voice_pitch', |
| 759 | 'A voice on the forge is part of Daimond Pro.')); |
| 760 | } |
| 761 | } catch (e) { /* the drawer is absent; the sentence still stood */ } |
| 762 | drawVoice(); |
| 763 | return false; |
| 764 | } |
| 765 | |
| 766 | if (!a.ok || !a.data || a.data.provisioned !== true) { |
| 767 | flash(tOr('social.voice_get_failed', |
| 768 | 'Could not make your voice. Try again shortly.')); |
| 769 | drawVoice(); |
| 770 | return false; |
| 771 | } |
| 772 | |
| 773 | // THE FORGE ALREADY HOLDS ONE and did not hand a secret back: it exists on |
| 774 | // another device and arrives by sync, not by minting a second here. Say so, |
| 775 | // and `drawVoice` offers the destructive re-issue as the one way out. |
| 776 | if (a.data.already === true || typeof a.data.secret !== 'string') { |
| 777 | _voiceAlready = true; |
| 778 | _voiceOpen = false; |
| 779 | flash(tOr('social.voice_already', |
| 780 | 'Your voice is set on another device and will sync here shortly.')); |
| 781 | drawVoice(); |
| 782 | return true; |
| 783 | } |
| 784 | |
| 785 | // A VOICE WAS MINTED. Hold it, then drop the plaintext at once. |
| 786 | try { |
| 787 | await v.set(a.data.secret); |
| 788 | } catch (e) { |
| 789 | // `set()` throws a sentence a person can act on -- a locked identity, a |
| 790 | // wrap that refused. Never quote the value. |
| 791 | flash(e && e.message ? String(e.message) |
| 792 | : tOr('social.voice_get_failed', |
| 793 | 'Could not make your voice. Try again shortly.')); |
| 794 | drawVoice(); |
| 795 | return false; |
| 796 | } |
| 797 | a.data.secret = ''; // in the clear only for the wrap above |
| 798 | _voiceOpen = false; |
| 799 | _voiceAlready = false; |
| 800 | flash(tOr('social.voice_saved', 'Your voice is held here, encrypted.')); |
| 801 | render(); |
| 802 | return true; |
| 803 | } |
| 804 | |
| 805 | /// Re-issue a voice the forge holds but this device cannot read. |
| 806 | /// |
| 807 | /// DESTRUCTIVE, so it asks first: a re-issue mints a new voice and the old one |
| 808 | /// stops working on every device, and there is no undo. Offered only after a |
| 809 | /// provision answered `already` -- the one case where a person genuinely lost |
| 810 | /// the copy the forge made for them. |
| 811 | async function reissueVoice() { |
| 812 | var ok = true; |
| 813 | try { |
| 814 | if (window.DaimondCore && DaimondCore.confirm) { |
| 815 | ok = await DaimondCore.confirm( |
| 816 | tOr('social.voice_reissue_ask', |
| 817 | 'Re-issue your voice? The old one stops working on every device, and this cannot be undone.'), |
| 818 | tOr('social.voice_reissue_do', 'Re-issue'), |
| 819 | { title: tOr('social.voice_reissue_title', 'Re-issue your voice'), danger: true }); |
| 820 | } |
| 821 | } catch (e) { ok = true; } |
| 822 | if (!ok) return false; |
| 823 | return await provision(true); |
| 824 | } |
| 825 | |
| 826 | /// Forget the voice on this device. It asks, because the forge cannot give it |
| 827 | /// back: a voice is minted once and shown once. |
| 828 | async function forgetVoice() { |
| 829 | var v = voice(); |
| 830 | if (!v) return false; |
| 831 | var ok = true; |
| 832 | try { |
| 833 | if (window.DaimondCore && DaimondCore.confirm) { |
| 834 | ok = await DaimondCore.confirm( |
| 835 | tOr('social.voice_ask_forget', |
| 836 | 'Forget your voice here? It was shown once and will not be shown again.'), |
| 837 | tOr('social.voice_forget', 'Forget it'), |
| 838 | { title: tOr('social.voice_forget', 'Forget it') }); |
| 839 | } |
| 840 | } catch (e) { ok = true; } |
| 841 | if (!ok) return false; |
| 842 | try { v.clear(); } catch (e) { /* nothing was stored */ } |
| 843 | flash(tOr('social.voice_forgotten', 'The copy on this device is gone.')); |
| 844 | render(); |
| 845 | return true; |
| 846 | } |
| 847 | |
| 848 | /// The Send button, asked for the way the markup really carries it. |
| 849 | /// |
| 850 | /// `#improve-acts .imp-send` AND NOT `#improve-send`, which does not exist: the |
| 851 | /// markup gives that button a class and no id. This file asked for it by id, |
| 852 | /// got null, and the guard that hides it silently did nothing -- so Send was |
| 853 | /// offered on every build to every user with nothing to send as, from the day |
| 854 | /// the panel was written. A verifier that asked for the same missing id would |
| 855 | /// have agreed with it: an absent locator reports itself hidden. |
| 856 | /// |
| 857 | /// One function, because two things now hang off this element -- whether Send |
| 858 | /// is offered, and whether the sentence saying what Send does is on the screen. |
| 859 | /// Two copies of a locator are two things to get separately wrong. |
| 860 | function sendBtn() { |
| 861 | return document.querySelector('#panel-social #improve-acts .imp-send'); |
| 862 | } |
| 863 | |
| 864 | /// Where the forge this panel writes to is read, in public. |
| 865 | /// |
| 866 | /// The panel talks to `/api/improve` and the gateway forwards from there, so |
| 867 | /// the browser is never told this address and cannot ask for it: it is a |
| 868 | /// deployment fact, written here once. If the gateway is ever pointed at a |
| 869 | /// different forge, THIS LINE IS A LIE UNTIL IT IS CHANGED, which is the price |
| 870 | /// of naming a host at all -- and naming it is the point, because "published |
| 871 | /// online" is a claim nobody can go and check. |
| 872 | var FORGE_HOST = 'oregami.oxegen.io'; |
| 873 | |
| 874 | /// The line beside Send, saying what a note goes as. |
| 875 | /// |
| 876 | /// A user must never find out AFTERWARDS that something about them went too, |
| 877 | /// so this is beside the button and not in a help text. It no longer names a |
| 878 | /// handle: what the forge attributes a proposal to is the VOICE, and the |
| 879 | /// browser never learns that voice's name -- there is no name on the wire. |
| 880 | /// |
| 881 | /// WITH a voice, this line says nothing: `drawPublic()` says all of it and |
| 882 | /// more, above the button rather than under it. The two sentences said the |
| 883 | /// same thing in different words, and four grey sentences stacked under one |
| 884 | /// box is how a panel starts reading as a warning against using it. |
| 885 | function drawAs() { |
| 886 | var as = el('improve-as'); |
| 887 | var send = sendBtn(); |
| 888 | if (as) { |
| 889 | as.hidden = hasVoice(); |
| 890 | as.textContent = hasVoice() ? '' |
| 891 | : tOr('social.novoice_set', 'No voice yet — set one in Settings to post.'); |
| 892 | } |
| 893 | // Without a voice there is nothing to post AS, and the forge would refuse |
| 894 | // it. The button is hidden rather than shown-and-inert: a control that |
| 895 | // does nothing when pressed teaches people to distrust every control. The |
| 896 | // polish button rides with it -- both post, and neither can without a voice. |
| 897 | if (send) send.hidden = !hasVoice(); |
| 898 | var polish = el('panel-social') ? document.querySelector('[data-act="improve-polish"]') : null; |
| 899 | if (polish) polish.hidden = !hasVoice(); |
| 900 | drawPublic(send); |
| 901 | drawHint(); |
| 902 | } |
| 903 | |
| 904 | /// What pressing Send does to a note, said ABOVE Send. |
| 905 | /// |
| 906 | /// The forge refuses on a repository's `public` flag before it looks at any |
| 907 | /// credential and draws only public repositories, so this panel can only read |
| 908 | /// anything at all while `oxedyne/daimond` is public -- and a proposal on a |
| 909 | /// public repository is readable by ANYBODY, with NO credential of any kind, |
| 910 | /// the voice name it was written under included. That is now part of what |
| 911 | /// pressing Send means, so it belongs where the press happens: in the box, |
| 912 | /// above the button, in front of the hand on its way there. Not a help page, |
| 913 | /// not an info mark, and not a dialogue -- a dialogue is dismissed once and |
| 914 | /// then never seen by the person who most needed it. |
| 915 | /// |
| 916 | /// It is drawn here rather than written into the markup for the reason |
| 917 | /// `drawHint()` and `drawVoice()` are: `www/index.html` is another lane's file |
| 918 | /// and every other word in this box is already drawn from this one. |
| 919 | /// |
| 920 | /// IT IS ON THE SCREEN EXACTLY WHEN SEND IS. A tester with no voice cannot |
| 921 | /// send, so telling them their notes will be published would be simply false; |
| 922 | /// and it is keyed off the BUTTON rather than off `hasVoice()` read a second |
| 923 | /// time, so the sentence cannot drift away from the control it describes. |
| 924 | function drawPublic(send) { |
| 925 | var write = document.querySelector('#panel-social .imp-write'); |
| 926 | var acts = el('improve-acts'); |
| 927 | if (!write || !acts) return; |
| 928 | var line = el('improve-public'); |
| 929 | if (!line) { |
| 930 | line = document.createElement('p'); |
| 931 | line.className = 'imp-public'; |
| 932 | line.id = 'improve-public'; |
| 933 | write.insertBefore(line, acts); // above the buttons, under the box |
| 934 | } |
| 935 | // Terse (#6): the whole address and the "no account needed" detail sit in the |
| 936 | // Post button's tooltip; the line under the box is the one fact that must be |
| 937 | // read before pressing -- it goes out in public, under your name. |
| 938 | line.textContent = tOr('social.compose_public', |
| 939 | 'Posted publicly, under your voice name — anyone can read it.'); |
| 940 | line.hidden = !(send && !send.hidden); |
| 941 | } |
| 942 | |
| 943 | /// The one sentence a person needs before they press Send: the first line is |
| 944 | /// the title. Drawn beside the buttons rather than in the placeholder, which |
| 945 | /// vanishes the moment anybody types. |
| 946 | function drawHint() { |
| 947 | var acts = el('improve-acts'); |
| 948 | if (!acts) return; |
| 949 | var hint = el('improve-hint'); |
| 950 | if (!hint) { |
| 951 | hint = document.createElement('span'); |
| 952 | hint.className = 'imp-as'; |
| 953 | hint.id = 'improve-hint'; |
| 954 | acts.appendChild(hint); |
| 955 | } |
| 956 | hint.textContent = tOr('social.title_hint', |
| 957 | 'First line is the title; what happened goes below.'); |
| 958 | } |
| 959 | |
| 960 | // ── The queue, and posting from it ───────────────────────── |
| 961 | |
| 962 | /// Put a note in the queue, with the mode a flush will send it in. There is no |
| 963 | /// "kept" state any more -- a note is here only because it has not been sent |
| 964 | /// yet, and it leaves the moment it is. |
| 965 | function store(text, mode) { |
| 966 | var s = load(); |
| 967 | var rec = { |
| 968 | id: 'n' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), |
| 969 | at: Date.now(), |
| 970 | text: String(text).slice(0, MAX_CHARS), |
| 971 | // How a flush will send it: 'verbatim' posts the words as they are; |
| 972 | // 'polish' has the model rewrite it into a proposal first. Remembered so |
| 973 | // a note queued offline is sent the way its author chose when the browser |
| 974 | // comes back. |
| 975 | mode: (mode === 'polish') ? 'polish' : 'verbatim', |
| 976 | // The sealed build identifier, captured NOW -- while the "what goes with |
| 977 | // it" row is in the state the author left it. A note may sit in the queue |
| 978 | // past a reconnect, by when the live row has been reset, so the build |
| 979 | // cannot be read again at send time or a closed row would put it back. |
| 980 | build: contextOff() ? '' : _build, |
| 981 | sent: 0, |
| 982 | n: 0, |
| 983 | into: [], |
| 984 | }; |
| 985 | s.notes.unshift(rec); |
| 986 | save(); |
| 987 | return rec; |
| 988 | } |
| 989 | |
| 990 | /// Take one note off the queue. Called when a send takes it (the note becomes a |
| 991 | /// proposal, so the copy here is spare) and when a person drops it by hand. |
| 992 | function removeNote(id) { |
| 993 | var s = load(); |
| 994 | var i = s.notes.findIndex(function (n) { return n.id === String(id); }); |
| 995 | if (i === -1) return false; |
| 996 | s.notes.splice(i, 1); |
| 997 | save(); |
| 998 | return true; |
| 999 | } |
| 1000 | |
| 1001 | /// One queued note by id, or null. |
| 1002 | function find(id) { |
| 1003 | var s = load(); |
| 1004 | for (var i = 0; i < s.notes.length; i++) if (s.notes[i].id === String(id)) return s.notes[i]; |
| 1005 | return null; |
| 1006 | } |
| 1007 | |
| 1008 | /// Is the browser online? `navigator.onLine` is only reliably FALSE (a true can |
| 1009 | /// still fail to reach the forge), so a submit tries to send and leaves the note |
| 1010 | /// queued if it does not go -- this only stops a pointless attempt while plainly |
| 1011 | /// offline, and gates the reconnect flush. |
| 1012 | function onLine() { |
| 1013 | try { return navigator.onLine !== false; } catch (e) { return true; } |
| 1014 | } |
| 1015 | |
| 1016 | /// Mark the notes a draft was written from as folded into proposal `n`. |
| 1017 | /// |
| 1018 | /// WHAT THIS DOES NOT DO IS SET `sent`, and that is the whole of it. These |
| 1019 | /// characters did not leave: a drafting of them did, and this panel is still |
| 1020 | /// holding the only copy of what the person actually wrote. `delivered()` |
| 1021 | /// reads the difference, so a folded note survives the cap that a sent one |
| 1022 | /// does not. |
| 1023 | /// |
| 1024 | /// Called by js/triage.js after the forge took a draft, and by nothing else. |
| 1025 | function fold(ids, n) { |
| 1026 | var num = Math.max(0, whole(n)); |
| 1027 | if (!num || !Array.isArray(ids) || !ids.length) return 0; |
| 1028 | var s = load(), hit = 0; |
| 1029 | ids.forEach(function (id) { |
| 1030 | var rec = s.notes.find(function (x) { return x.id === String(id); }); |
| 1031 | if (!rec) return; |
| 1032 | if (rec.into.indexOf(num) === -1) rec.into.push(num); |
| 1033 | hit++; |
| 1034 | }); |
| 1035 | if (hit) save(); |
| 1036 | return hit; |
| 1037 | } |
| 1038 | |
| 1039 | function clearBox() { |
| 1040 | var box = el('improve-box'); |
| 1041 | if (box) box.value = ''; |
| 1042 | // AT ONCE, not on the settle timer. This runs when a note has been kept or |
| 1043 | // sent, and a draft of a note that has already gone is words the user would |
| 1044 | // find sitting in the box afterwards looking unsent. |
| 1045 | try { if (drafts()) drafts().drop(draftKey('note')); } catch (e) { /* storage blocked */ } |
| 1046 | var row = el('improve-with'); |
| 1047 | if (row) delete row.dataset.off; // the next note starts with its details on |
| 1048 | drawContext(); |
| 1049 | } |
| 1050 | |
| 1051 | /// Open a proposal from one note's characters. |
| 1052 | /// |
| 1053 | /// THE WHOLE OF WHAT LEAVES is these three fields, and all three came out of |
| 1054 | /// `split()`, which cut what `outgoing()` read off the screen. The field set |
| 1055 | /// is asserted by dev/verify_improve.mjs, so a fourth field has to defeat a |
| 1056 | /// check rather than merely be forgotten. |
| 1057 | /// |
| 1058 | /// A failure is not retried and nothing is queued. The note is kept, the row |
| 1059 | /// says so, and Copy is there for a person who wants to carry it themselves. |
| 1060 | async function post(parts) { |
| 1061 | var f = new URLSearchParams(); |
| 1062 | f.set('title', parts.title); |
| 1063 | f.set('body', parts.body); |
| 1064 | if (parts.build) f.set('build', parts.build); |
| 1065 | return await ask(route(''), { |
| 1066 | method: 'POST', |
| 1067 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 1068 | body: f.toString(), |
| 1069 | }); |
| 1070 | } |
| 1071 | |
| 1072 | /// What a refusal leaves on the screen after a send: why it did not go, and that |
| 1073 | /// the note is still waiting in the queue. |
| 1074 | function keptAfter(a) { |
| 1075 | return saying(a) + ' ' + tOr('social.waiting_here', |
| 1076 | 'Waiting to send; it will go when the forge is reachable.'); |
| 1077 | } |
| 1078 | |
| 1079 | /// Put one already-stored note on the wire and take the answer back into the |
| 1080 | /// record. THE ONE DOOR a note leaves by, whether the press came from the box, |
| 1081 | /// from the queue, or from a daimon that was told yes -- so a fourth caller |
| 1082 | /// cannot quietly acquire a different idea of what "sent" means. On success the |
| 1083 | /// note LEAVES the queue (#3): the forge holds the proposal, so the copy here |
| 1084 | /// is spare and there is no list for it to sit in. |
| 1085 | async function through(rec, parts) { |
| 1086 | var a = await post(parts); |
| 1087 | if (a.ok) { |
| 1088 | var prop = cleanProp(a.data); |
| 1089 | removeNote(rec.id); |
| 1090 | absorb(prop); |
| 1091 | // The note is gone; the number it became is what the hub's "Mine" filter |
| 1092 | // reads, so keep it -- otherwise a post never appears in the very hub the |
| 1093 | // `showRaised` note points the person at. |
| 1094 | if (prop) recordRaised(prop.n); |
| 1095 | } |
| 1096 | return a; |
| 1097 | } |
| 1098 | |
| 1099 | // ── The compose box: two ways to post, both auto-send ────── |
| 1100 | // |
| 1101 | // One box, two verbs. "Post" sends the words as they are; "Polish & post" has |
| 1102 | // the model rewrite them into a proposal first. NEITHER keeps -- both queue the |
| 1103 | // note and immediately try to send it, so the only holding area is the queue, |
| 1104 | // and only for a note that could not go yet. A note remembers which verb made |
| 1105 | // it, so a reconnect flush sends it the way its author chose. |
| 1106 | |
| 1107 | /// Post the box, in one of the two modes. Queues the note, then -- if the |
| 1108 | /// browser is online -- tries to send it at once. If the send does not go (or |
| 1109 | /// the browser is offline) the note stays in the queue and the reconnect flush |
| 1110 | /// will take it later. |
| 1111 | async function submit(mode) { |
| 1112 | var text = outgoing(); |
| 1113 | if (!text) { flash(tOr('social.nothing', 'Write something first.')); return null; } |
| 1114 | // A verbatim post needs a first line to be its title; a polished one does |
| 1115 | // not, because the model writes the title. |
| 1116 | if (mode !== 'polish' && !split(text)) { |
| 1117 | flash(tOr('social.no_title', 'First line is the title — write one, then what happened.')); |
| 1118 | return null; |
| 1119 | } |
| 1120 | if (!hasVoice()) { flash(tOr('social.novoice_set', 'No voice yet — set one in Settings to post.')); return null; } |
| 1121 | var rec = store(text, mode); |
| 1122 | clearBox(); |
| 1123 | render(); |
| 1124 | if (onLine()) { await sendOne(rec); } |
| 1125 | render(); |
| 1126 | // Raised, whether it posted or is queued -- either way it has left the box. |
| 1127 | // Point the person at the Improve hub, where it is read and settled. |
| 1128 | showRaised(); |
| 1129 | return rec; |
| 1130 | } |
| 1131 | |
| 1132 | /// Send one queued note, in its own mode. Verbatim goes straight through the |
| 1133 | /// door; polish runs the model first and posts what it drafted. |
| 1134 | async function sendOne(rec) { |
| 1135 | var cur = find(rec.id); |
| 1136 | if (!cur) return false; // already gone |
| 1137 | return (cur.mode === 'polish') ? await sendPolished(cur) : await sendVerbatim(cur); |
| 1138 | } |
| 1139 | |
| 1140 | async function sendVerbatim(rec) { |
| 1141 | var parts = split(rec.text); |
| 1142 | if (!parts) { flash(tOr('social.no_title', 'First line is the title — write one, then what happened.')); return false; } |
| 1143 | // The build is the note's OWN, captured when it was written -- not whatever |
| 1144 | // the live "what goes with it" row happens to say now. `split` reads the live |
| 1145 | // row, so its build is overwritten here. |
| 1146 | parts.build = rec.build || ''; |
| 1147 | var a = await through(rec, parts); |
| 1148 | if (!a.ok) flash(keptAfter(a)); |
| 1149 | return a.ok; |
| 1150 | } |
| 1151 | |
| 1152 | /// Polish one note into a proposal with the model, then post it. The drafting |
| 1153 | /// is js/triage.js's, the one place the model machinery and its metering live; |
| 1154 | /// this posts what it drafted through the same door a verbatim note leaves by. |
| 1155 | /// A failure -- no model, offline, an unreadable answer -- leaves the note in |
| 1156 | /// the queue for the next flush. |
| 1157 | async function sendPolished(rec) { |
| 1158 | var got = null; |
| 1159 | try { if (window.DaimondTriage && DaimondTriage.polish) got = await DaimondTriage.polish(rec.text); } |
| 1160 | catch (e) { got = null; } |
| 1161 | if (!got || !got.title) { |
| 1162 | flash(tOr('social.polish_wait', 'The model could not draft it just now; it is still waiting to send.')); |
| 1163 | return false; |
| 1164 | } |
| 1165 | var a = await through(rec, { title: got.title, body: got.body || '', build: rec.build || '' }); |
| 1166 | if (!a.ok) flash(keptAfter(a)); |
| 1167 | return a.ok; |
| 1168 | } |
| 1169 | |
| 1170 | /// Send one queued note now, by id -- the queue row's own "Send now". The same |
| 1171 | /// one act the flush makes, from a press instead of from a reconnect. |
| 1172 | async function resend(id) { |
| 1173 | var rec = find(id); |
| 1174 | if (!rec) return false; |
| 1175 | if (!hasVoice()) { flash(tOr('social.novoice_set', 'No voice yet — set one in Settings to post.')); return false; } |
| 1176 | var ok = await sendOne(rec); |
| 1177 | render(); |
| 1178 | return ok; |
| 1179 | } |
| 1180 | |
| 1181 | // ── Draining the queue when the browser comes back ───────── |
| 1182 | // |
| 1183 | // THE NET-NEW PIECE. A note written offline (or one whose send failed) waits in |
| 1184 | // the queue; when the browser fires `online`, js/daimond.js calls this and every |
| 1185 | // waiting note is sent in the mode it was written in -- a polish note drafts on |
| 1186 | // reconnect, because the model needs the network too. One flush at a time, and |
| 1187 | // it stops the moment the browser drops again rather than throwing every note at |
| 1188 | // a dead forge. |
| 1189 | |
| 1190 | var _flushing = false; |
| 1191 | |
| 1192 | async function flushQueue() { |
| 1193 | if (_flushing || !onLine()) return { sent: 0, waiting: load().notes.length }; |
| 1194 | if (!hasVoice()) return { sent: 0, waiting: load().notes.length }; |
| 1195 | _flushing = true; |
| 1196 | var sent = 0; |
| 1197 | try { |
| 1198 | var q = load().notes.slice(); // a snapshot of ids; the list changes under us |
| 1199 | for (var i = 0; i < q.length; i++) { |
| 1200 | if (!onLine()) break; |
| 1201 | var rec = find(q[i].id); |
| 1202 | if (!rec) continue; // taken meanwhile |
| 1203 | var ok = await sendOne(rec); |
| 1204 | if (ok) sent++; |
| 1205 | } |
| 1206 | } finally { |
| 1207 | _flushing = false; |
| 1208 | render(); |
| 1209 | } |
| 1210 | return { sent: sent, waiting: load().notes.length }; |
| 1211 | } |
| 1212 | |
| 1213 | /// Delete one note. It is only on this device, so this is the whole of it -- |
| 1214 | /// which is why it asks, and why the question says so. |
| 1215 | async function drop(id) { |
| 1216 | var s = load(); |
| 1217 | var i = s.notes.findIndex(function (n) { return n.id === id; }); |
| 1218 | if (i === -1) return false; |
| 1219 | var ok = true; |
| 1220 | try { |
| 1221 | if (window.DaimondCore && DaimondCore.confirm) { |
| 1222 | ok = await DaimondCore.confirm( |
| 1223 | tOr('social.drop_ask', 'Delete this note? It is only here — no other copy.'), |
| 1224 | tOr('social.drop_ok', 'Delete'), |
| 1225 | { title: tOr('social.drop', 'Delete this note') }); |
| 1226 | } |
| 1227 | } catch (e) { ok = true; } |
| 1228 | if (!ok) return false; |
| 1229 | s.notes.splice(i, 1); |
| 1230 | save(); |
| 1231 | render(); |
| 1232 | return true; |
| 1233 | } |
| 1234 | |
| 1235 | /// Put a note on the clipboard, so somebody with no voice, or no gateway, |
| 1236 | /// can still carry their own report out by hand. |
| 1237 | async function copy(id) { |
| 1238 | var s = load(); |
| 1239 | var rec = s.notes.find(function (n) { return n.id === id; }); |
| 1240 | if (!rec) return false; |
| 1241 | try { await navigator.clipboard.writeText(rec.text); } |
| 1242 | catch (e) { flash(t('copy.failed')); return false; } |
| 1243 | flash(tOr('social.copied', 'Copied.')); |
| 1244 | return true; |
| 1245 | } |
| 1246 | |
| 1247 | /// One line under the box, for the answers that are not worth a dialog. |
| 1248 | function flash(text) { |
| 1249 | var n = el('improve-say'); |
| 1250 | if (!n) return; |
| 1251 | n.textContent = text; |
| 1252 | clearTimeout(flash._t); |
| 1253 | flash._t = setTimeout(function () { if (n.textContent === text) n.textContent = ''; }, 8000); |
| 1254 | } |
| 1255 | |
| 1256 | /// Open the Improve hub -- the read-and-settle board (js/tracker.js), a panel of |
| 1257 | /// its own. This surface CAPTURES a proposal; the hub is where it is read and |
| 1258 | /// decided, so a confirmation and a reference both hand the reader there. |
| 1259 | function openHub() { |
| 1260 | try { if (window.DaimondPanels) DaimondPanels.show('tracker'); } catch (e) { /* no engine */ } |
| 1261 | } |
| 1262 | |
| 1263 | /// Say a note was raised, with a press that opens the Improve hub. Drawn after a |
| 1264 | /// post OR a queue: either way the note has left the box and is on its way, so |
| 1265 | /// the person is pointed at where it is read. The hub word is a real affordance, |
| 1266 | /// not prose -- "see it in Improve" with Improve the button. |
| 1267 | function showRaised() { |
| 1268 | var host = el('improve-raised'); |
| 1269 | if (!host) return; |
| 1270 | host.innerHTML = ''; |
| 1271 | host.appendChild(document.createTextNode( |
| 1272 | tOr('social.raised_lead', 'Raised — see it in') + ' ')); |
| 1273 | host.appendChild(button('imp-raised-hub', 'improve-open-hub', |
| 1274 | tOr('social.raised_hub', 'Improve'))); |
| 1275 | host.hidden = false; |
| 1276 | clearTimeout(showRaised._t); |
| 1277 | showRaised._t = setTimeout(function () { if (host) host.hidden = true; }, 12000); |
| 1278 | } |
| 1279 | |
| 1280 | /// Take the confirmation down. Called when a new note is being written, so it |
| 1281 | /// does not sit stale over a fresh compose. |
| 1282 | function hideRaised() { |
| 1283 | var host = el('improve-raised'); |
| 1284 | if (host && !host.hidden) host.hidden = true; |
| 1285 | } |
| 1286 | |
| 1287 | // ── The wire ─────────────────────────────────────────────── |
| 1288 | // |
| 1289 | // One door: `/api/improve`, which the gateway forwards over loopback to the |
| 1290 | // forge. The account and the repository ride in the QUERY on both methods, so |
| 1291 | // the body stays whatever the forge reads and the proxy in between never has |
| 1292 | // to parse it. |
| 1293 | // |
| 1294 | // Every request goes through `DaimondVoice.send`, which spreads the voice |
| 1295 | // header and goes on through `DaimondGateway.gwFetch` -- the one copy of the |
| 1296 | // session rule. A read of a public repository carries no voice at all, which |
| 1297 | // `header()` answers `{}` for by design, so this same door serves both. |
| 1298 | |
| 1299 | /// The route, with the repository this panel reads. Built here and nowhere |
| 1300 | /// else, and the voice is never in it: a query string is written into every |
| 1301 | /// access log it passes. |
| 1302 | function route(extra) { |
| 1303 | var q = 'account=' + encodeURIComponent(ACCOUNT) + '&repo=' + encodeURIComponent(REPO); |
| 1304 | return '/api/improve?' + q + (extra ? '&' + extra : ''); |
| 1305 | } |
| 1306 | |
| 1307 | /// The nine refusals the forge speaks, per contract §3.1. A client branches |
| 1308 | /// on `error` and NEVER on `said`, which is a sentence a person reads and may |
| 1309 | /// be reworded at any time. |
| 1310 | var TOKENS = { |
| 1311 | absent: 1, unvoiced: 1, unknown: 1, unpermitted: 1, throttled: 1, |
| 1312 | malformed: 1, no_proposal: 1, unsupported: 1, internal: 1, |
| 1313 | }; |
| 1314 | |
| 1315 | /// The three reasons a `throttled` carries. Anything else is read as none. |
| 1316 | var BECAUSE = { address: 1, voice: 1, failing: 1 }; |
| 1317 | |
| 1318 | /// One exchange with the forge, as this panel reads it. |
| 1319 | /// |
| 1320 | /// `{ ok: true, data }`, or `{ ok: false, why, because, status }` where `why` |
| 1321 | /// is one of the nine tokens, `gateway` for a refusal this side generated, or |
| 1322 | /// `offline` for a request that never got an answer at all. Three sources of |
| 1323 | /// refusal and one shape, because every caller has to handle all three. |
| 1324 | async function ask(path, opts) { |
| 1325 | var v = voice(); |
| 1326 | var r; |
| 1327 | try { |
| 1328 | r = v ? await v.send(path, opts || {}) : await fetch(path, opts || {}); |
| 1329 | } catch (e) { |
| 1330 | // `header()` throws a SENTENCE a person can act on -- a locked |
| 1331 | // identity, a voice that cannot be read under this passphrase -- and |
| 1332 | // those are worth showing. A network failure throws a `TypeError` |
| 1333 | // whose message is the browser's own English ("Failed to fetch"), |
| 1334 | // which must never reach a screen this app has translated eight ways. |
| 1335 | var mine = (e && e.name !== 'TypeError' && e.message) ? String(e.message) : ''; |
| 1336 | return { ok: false, why: 'offline', said: mine }; |
| 1337 | } |
| 1338 | var text = ''; |
| 1339 | try { text = await r.text(); } catch (e) { text = ''; } |
| 1340 | var data = null; |
| 1341 | try { data = text ? JSON.parse(text) : null; } catch (e) { data = null; } |
| 1342 | // The forge's refusal. Told from a record by the token, and from the |
| 1343 | // gateway's own refusal by the token being one of the nine: the gateway |
| 1344 | // answers `{ok:false, error:"<a sentence>"}`, which is not a token. |
| 1345 | if (data && typeof data === 'object' && typeof data.error === 'string' && TOKENS[data.error]) { |
| 1346 | return { |
| 1347 | ok: false, |
| 1348 | why: data.error, |
| 1349 | because: (typeof data.because === 'string' && BECAUSE[data.because]) ? data.because : '', |
| 1350 | status: r.status, |
| 1351 | }; |
| 1352 | } |
| 1353 | if (r.ok && data && typeof data === 'object') return { ok: true, data: data }; |
| 1354 | return { ok: false, why: 'gateway', status: r.status }; |
| 1355 | } |
| 1356 | |
| 1357 | /// Tag a refusal with the act it refused, so `saying()` can say the sentence |
| 1358 | /// that act needs. A copy, because the caller's record is what is drawn and a |
| 1359 | /// tag written onto it in place would outlive the request that earned it. |
| 1360 | function onAmend(a) { |
| 1361 | if (!a || a.ok) return a; |
| 1362 | var out = {}; |
| 1363 | Object.keys(a).forEach(function (k) { out[k] = a[k]; }); |
| 1364 | out.on = 'amend'; |
| 1365 | return out; |
| 1366 | } |
| 1367 | |
| 1368 | /// What a refusal says on the screen. |
| 1369 | /// |
| 1370 | /// EVERY ONE OF THE NINE IS SAID. A refusal a panel swallows is a panel that |
| 1371 | /// looks broken for a reason nobody can find, and this file shipped for weeks |
| 1372 | /// telling every tester the same sentence about a 404 that was really a route |
| 1373 | /// that did not exist. |
| 1374 | /// |
| 1375 | /// `absent` covers BOTH "no such repository" and "this repository is |
| 1376 | /// private", deliberately and permanently: any wording, status or timing that |
| 1377 | /// separated the two would republish exactly what a private repository is |
| 1378 | /// withholding. So the sentence has to be TRUE IN BOTH CASES. "There is no |
| 1379 | /// such repository" is false when it is private; "This repository is private" |
| 1380 | /// leaks. |
| 1381 | /// |
| 1382 | /// None of these names which allowance ran out, either. A limit that reports |
| 1383 | /// its own state is one somebody can pace against, and a vote and a proposal |
| 1384 | /// draw on different budgets -- so a sentence about "submissions" shown to |
| 1385 | /// somebody who tapped a vote button twice is wrong as well as leaky. |
| 1386 | function saying(a) { |
| 1387 | if (!a) return tOr('social.err_offline', 'Nothing could be sent just now.'); |
| 1388 | switch (a.why) { |
| 1389 | case 'absent': |
| 1390 | return tOr('social.err_absent', 'This repository is not available to you.'); |
| 1391 | case 'unvoiced': |
| 1392 | return tOr('social.err_unvoiced', 'The forge was given no voice, so it refused.'); |
| 1393 | case 'unknown': |
| 1394 | return tOr('social.err_unknown', 'The forge does not recognise your voice. Set it again from the line the forge printed for you.'); |
| 1395 | case 'unpermitted': |
| 1396 | // The one route where this refusal has a single cause worth naming. The |
| 1397 | // forge refuses a stranger's revision with `unpermitted` and writes |
| 1398 | // nothing, so the honest sentence is who may, not that somebody may not. |
| 1399 | if (a.on === 'amend') { |
| 1400 | return tOr('social.err_amend_unpermitted', |
| 1401 | 'Only the person who opened this proposal may revise it.'); |
| 1402 | } |
| 1403 | return tOr('social.err_unpermitted', 'Your voice may not do that here.'); |
| 1404 | case 'throttled': |
| 1405 | if (a.because === 'address') { |
| 1406 | return tOr('social.err_throttled_address', 'Too many requests from this address just now. Wait a little, then try again.'); |
| 1407 | } |
| 1408 | if (a.because === 'failing') { |
| 1409 | return tOr('social.err_throttled_failing', 'Too many failing requests just now. Wait a little, then try again.'); |
| 1410 | } |
| 1411 | return tOr('social.err_throttled', 'Too many requests just now. Wait a little, then try again.'); |
| 1412 | case 'malformed': |
| 1413 | return tOr('social.err_malformed', 'The forge could not read what Daimond asked it. That is a fault in Daimond, not in what you wrote.'); |
| 1414 | case 'no_proposal': |
| 1415 | return tOr('social.err_no_proposal', 'There is no such proposal here.'); |
| 1416 | case 'unsupported': |
| 1417 | return tOr('social.err_unsupported', 'The forge does not answer that.'); |
| 1418 | case 'internal': |
| 1419 | return tOr('social.err_internal', 'Something went wrong at the forge. This is not your fault.'); |
| 1420 | case 'gateway': |
| 1421 | if (a.status === 401) { |
| 1422 | return tOr('social.err_session', 'Daimond is not signed in just now, so it could not reach the forge.'); |
| 1423 | } |
| 1424 | if (a.status === 413) { |
| 1425 | return tOr('social.err_toolong', 'That is longer than the forge accepts. Shorten it, or send it in two.'); |
| 1426 | } |
| 1427 | return tOr('social.err_gateway', 'Daimond could not reach the forge just now.'); |
| 1428 | default: |
| 1429 | return a.said || tOr('social.err_offline', 'Nothing could be sent just now.'); |
| 1430 | } |
| 1431 | } |
| 1432 | |
| 1433 | // ── Proposals ────────────────────────────────────────────── |
| 1434 | // |
| 1435 | // Read from the forge as the panel is opened, NEWEST FIRST. `from` is a |
| 1436 | // numeric CEILING that counts DOWN and not an offset -- an offset slides as |
| 1437 | // proposals arrive, so a client paging through a growing list silently skips |
| 1438 | // or repeats records. |
| 1439 | // |
| 1440 | // THE WALK'S TERMINATION IS THE SUBTLE PART, and it is why `from=0` is never |
| 1441 | // sent from here. There is NO value of `from` that says "nothing below this": |
| 1442 | // zero is not a proposal number, so a forge either refuses it or reads it as |
| 1443 | // "no ceiling", which is BACK TO THE NEWEST. A client that pages by asking |
| 1444 | // for `lowest - 1` therefore wraps to the start and loops for ever the moment |
| 1445 | // it reaches proposal 1, with every answer along the way looking perfectly |
| 1446 | // valid and nothing anywhere reporting a fault. So the walk ends on a page |
| 1447 | // SHORTER than the limit it asked for, on proposal 1, or on a page that did |
| 1448 | // not descend -- and the last of those three holds even if the other two are |
| 1449 | // wrong, which is why it is there. |
| 1450 | |
| 1451 | var _by = {}; // proposal number -> the record, listing or detail |
| 1452 | var _order = []; // the numbers, in the order they are drawn |
| 1453 | var _open = {}; // which rows are open, so a redraw does not shut them |
| 1454 | var _list = { |
| 1455 | total: 0, |
| 1456 | lowest: null, // the lowest number drawn so far, the ceiling counts down from |
| 1457 | done: false, // the walk has ended and there is nothing below |
| 1458 | loading: false, |
| 1459 | err: null, // the last refusal, drawn under the list until it is gone |
| 1460 | read: false, // whether the listing has ever been read |
| 1461 | }; |
| 1462 | |
| 1463 | /// The four states a proposal takes on the forge. The vocabulary is closed: |
| 1464 | /// anything else is drawn as open rather than as a state nobody has a word |
| 1465 | /// for. |
| 1466 | var STATES = { open: 1, accepted: 1, declined: 1, done: 1 }; |
| 1467 | |
| 1468 | /// What the forge calls its per-asker "you may amend this" flag. |
| 1469 | /// |
| 1470 | /// SETTLED, AND READ OFF THE DEPLOYED FORGE rather than guessed. Two lanes |
| 1471 | /// built this half against a forge that did not answer the flag yet, and each |
| 1472 | /// picked its own spelling -- `amend` and `may_amend` -- because a name nobody |
| 1473 | /// has published is a name everybody invents. Neither was right. The forge |
| 1474 | /// answers `mine_to_amend`, carried in `shared_dat` so it sits on the listing |
| 1475 | /// entry and on the whole proposal alike, and an unvoiced read of |
| 1476 | /// `/oxedyne/ore/proposals/20?format=json` carries no such key at all -- |
| 1477 | /// confirmed against the deployed host on 2026-08-28. |
| 1478 | // |
| 1479 | // It is spelled beside `mine`, which is what it is a second of: `mine` is this |
| 1480 | // asker's vote and `mine_to_amend` is whether this proposal is this asker's to |
| 1481 | // revise. The gateway's own words are DIFFERENT WORDS on purpose -- `&amend=1` |
| 1482 | // in the query, `/amend` as the forge's route segment -- because those name the |
| 1483 | // ACT and this names a fact about the record. |
| 1484 | // |
| 1485 | // Held once so that the next rename is one line here rather than a search. The |
| 1486 | // fixtures do not get their own copy: dev/verify_triage.mjs takes the spelling |
| 1487 | // from this file, because a fixture answering the old key would keep a broken |
| 1488 | // client green. |
| 1489 | var AMEND_FLAG = 'mine_to_amend'; |
| 1490 | |
| 1491 | /// One proposal as it arrived, defended against a shape this build does not |
| 1492 | /// know. |
| 1493 | /// |
| 1494 | /// `mine` is the delicate one. Contract §9: it is `1`, `-1` or `null` when a |
| 1495 | /// voice was sent and ABSENT ENTIRELY when none was, so that "I have not |
| 1496 | /// voted" and "I was not asked" cannot be confused. `asked` carries that |
| 1497 | /// distinction here, because `undefined` and `null` are the same thing to |
| 1498 | /// anything that round-trips this record through JSON. |
| 1499 | function cleanProp(p) { |
| 1500 | if (!p || typeof p !== 'object') return null; |
| 1501 | var n = whole(p.number); |
| 1502 | if (n < 1) return null; |
| 1503 | var rec = { |
| 1504 | n: n, |
| 1505 | title: (typeof p.title === 'string') ? p.title : '', |
| 1506 | state: STATES[p.state] ? p.state : 'open', |
| 1507 | author: (typeof p.author === 'string') ? p.author : '', |
| 1508 | comments: Math.max(0, whole(p.comments)), |
| 1509 | opened: Math.max(0, whole(p.opened)), |
| 1510 | // PRESENCE FIRST, NUMBER SECOND, and null where the answer was silent. |
| 1511 | // `whole()` would have turned an absent `changed` into 0, and a 0 here |
| 1512 | // is a real reading: "revised at the epoch", which is older than every |
| 1513 | // proposal there is. A panel that compared it would find either every |
| 1514 | // tile stale for ever or none of them ever, and both look exactly like a |
| 1515 | // cache that is not being invalidated -- which is the week somebody |
| 1516 | // spends before finding this line. Same rule as `mine`/`asked` and as |
| 1517 | // `mine_to_amend`/`askedAmend` below. |
| 1518 | changed: (typeof p.changed === 'number') ? Math.max(0, whole(p.changed)) : null, |
| 1519 | mark: (typeof p.mark === 'string') ? p.mark : '', |
| 1520 | build: (typeof p.build === 'string') ? p.build : '', |
| 1521 | body: (typeof p.body === 'string') ? p.body : '', |
| 1522 | discussion: null, |
| 1523 | // NULL WHERE THE ANSWER CARRIED NONE, and `[]` where it carried an empty |
| 1524 | // one, which are different facts: the listing does not answer this field |
| 1525 | // at all and the whole proposal always does, so `[]` means "read, and |
| 1526 | // never revised" and `null` means "not read yet". `absorb` keeps the |
| 1527 | // list across a listing record for the reason it keeps the body. |
| 1528 | revisions: null, |
| 1529 | detail: false, |
| 1530 | votes: null, // null: the answer carried no tally at all |
| 1531 | asked: false, // whether the answer carried `mine` at all |
| 1532 | mine: null, |
| 1533 | askedAmend: false, // whether the answer carried the amend flag at all |
| 1534 | amendable: false, // and, if it did, whether this asker may amend |
| 1535 | }; |
| 1536 | if (Array.isArray(p.discussion)) { |
| 1537 | rec.detail = true; |
| 1538 | rec.discussion = p.discussion.map(function (d) { |
| 1539 | return { |
| 1540 | author: (d && typeof d.author === 'string') ? d.author : '', |
| 1541 | // `said` on a discussion entry is what a person wrote, which is |
| 1542 | // not the `said` of a refusal. One word, two contracts. |
| 1543 | said: (d && typeof d.said === 'string') ? d.said : '', |
| 1544 | when: Math.max(0, whole(d && d.when)), |
| 1545 | }; |
| 1546 | }); |
| 1547 | } |
| 1548 | // A LIST, NOT A COUNT, and that is the whole of the care this needs. `comments` |
| 1549 | // beside it IS a count on both routes, so a reader working by analogy reaches |
| 1550 | // for `whole()` and gets `NaN` from an array -- or worse, a length that looks |
| 1551 | // like an answer. The forge answers `revisions` on the WHOLE PROPOSAL ONLY, |
| 1552 | // oldest first, EMPTY rather than absent where nothing has been amended. |
| 1553 | if (Array.isArray(p.revisions)) { |
| 1554 | rec.revisions = p.revisions.map(function (r) { |
| 1555 | return { |
| 1556 | title: (r && typeof r.title === 'string') ? r.title : '', |
| 1557 | body: (r && typeof r.body === 'string') ? r.body : '', |
| 1558 | when: Math.max(0, whole(r && r.when)), |
| 1559 | }; |
| 1560 | }); |
| 1561 | } |
| 1562 | if (typeof p.body === 'string') rec.detail = true; |
| 1563 | if (p.votes && typeof p.votes === 'object' && !Array.isArray(p.votes)) { |
| 1564 | rec.votes = { |
| 1565 | for: Math.max(0, whole(p.votes.for)), |
| 1566 | against: Math.max(0, whole(p.votes.against)), |
| 1567 | }; |
| 1568 | } |
| 1569 | if (Object.prototype.hasOwnProperty.call(p, 'mine')) { |
| 1570 | rec.asked = true; |
| 1571 | rec.mine = (p.mine === 1 || p.mine === -1) ? p.mine : null; |
| 1572 | } |
| 1573 | // PRESENCE FIRST, BOOLEAN SECOND, and the two are kept apart for the same |
| 1574 | // reason `mine` and `asked` are. The flag is ABSENT when no voice asked -- |
| 1575 | // not `false` -- so a reader that tested only its truth would draw "you |
| 1576 | // may not amend this" at a person who was never asked, and would go on |
| 1577 | // drawing it after they set a voice. `hasOwnProperty`, so a `false` that |
| 1578 | // really was answered is still an answer. |
| 1579 | // |
| 1580 | // NOTHING ABOVE COERCES IT. Every other field on this record has a |
| 1581 | // defaulting cast, which is right for a value whose absence means |
| 1582 | // nothing; it is wrong for one whose absence IS the fact. |
| 1583 | if (Object.prototype.hasOwnProperty.call(p, AMEND_FLAG)) { |
| 1584 | rec.askedAmend = true; |
| 1585 | rec.amendable = (p[AMEND_FLAG] === true); |
| 1586 | } |
| 1587 | return rec; |
| 1588 | } |
| 1589 | |
| 1590 | /// May this asker revise that proposal? |
| 1591 | /// |
| 1592 | /// ONE PREDICATE FOR THE THREE PLACES THAT ASK, because the two halves have to |
| 1593 | /// be read together and any reader that forgets one of them is wrong in a way |
| 1594 | /// nothing shows: `amendable` alone offers the control to a proposal nobody was |
| 1595 | /// asked about, and `askedAmend` alone offers it to a voice that was told no. |
| 1596 | /// The exported `forge.mayAmend` is this same question by proposal number. |
| 1597 | function canAmend(rec) { |
| 1598 | return !!(rec && rec.askedAmend && rec.amendable); |
| 1599 | } |
| 1600 | |
| 1601 | /// Take one record in, keeping what a listing does not carry. |
| 1602 | /// |
| 1603 | /// A listing record has no body and no discussion, so absorbing one over a |
| 1604 | /// detail that has already been read must not wipe them -- otherwise opening |
| 1605 | /// a proposal and then paging would empty it. |
| 1606 | function absorb(rec) { |
| 1607 | if (!rec) return null; |
| 1608 | var cur = _by[rec.n]; |
| 1609 | if (cur && !rec.detail) { |
| 1610 | rec.body = cur.body; |
| 1611 | rec.discussion = cur.discussion; |
| 1612 | rec.revisions = cur.revisions; |
| 1613 | rec.detail = cur.detail; |
| 1614 | } |
| 1615 | if (!cur) _order.push(rec.n); |
| 1616 | _by[rec.n] = rec; |
| 1617 | return rec; |
| 1618 | } |
| 1619 | |
| 1620 | /// Read a page of the listing. `more` walks downwards from what is drawn. |
| 1621 | async function loadList(more) { |
| 1622 | if (_list.loading) return false; |
| 1623 | if (more && _list.done) return false; |
| 1624 | var extra = 'limit=' + PAGE; |
| 1625 | if (more) { |
| 1626 | // Never `from=0`. See the note above: there is no value of `from` that |
| 1627 | // says "nothing below this", and zero means back to the newest. |
| 1628 | if (!(_list.lowest > 1)) { _list.done = true; drawProps(); return false; } |
| 1629 | extra += '&from=' + (_list.lowest - 1); |
| 1630 | } |
| 1631 | _list.loading = true; |
| 1632 | _list.err = null; |
| 1633 | drawProps(); |
| 1634 | var a = await ask(route(extra), { method: 'GET' }); |
| 1635 | _list.loading = false; |
| 1636 | if (!a.ok) { _list.err = a; drawProps(); return false; } |
| 1637 | |
| 1638 | if (!more) { _by = {}; _order = []; _list.lowest = null; _list.done = false; } |
| 1639 | var raw = Array.isArray(a.data.proposals) ? a.data.proposals : []; |
| 1640 | var got = 0, lowest = null; |
| 1641 | raw.forEach(function (p) { |
| 1642 | var rec = cleanProp(p); |
| 1643 | if (!rec) return; |
| 1644 | got++; |
| 1645 | if (lowest === null || rec.n < lowest) lowest = rec.n; |
| 1646 | absorb(rec); |
| 1647 | }); |
| 1648 | // `total` is the count AFTER `state` and BEFORE `from` and `limit`, so it |
| 1649 | // is how "no more" is told from "more to come". |
| 1650 | _list.total = Math.max(0, whole(a.data.total)); |
| 1651 | _list.read = true; |
| 1652 | |
| 1653 | // A short page is the end of the walk. |
| 1654 | if (got < PAGE) _list.done = true; |
| 1655 | // So is proposal 1: there is nothing below it to ask for. |
| 1656 | if (lowest === null || lowest <= 1) _list.done = true; |
| 1657 | // AND SO IS A PAGE THAT DID NOT DESCEND. This is the belt: a forge that |
| 1658 | // read `from` as a lower bound, or aliased a value of it back to the |
| 1659 | // newest, would hand the same page again for ever and every answer would |
| 1660 | // look valid. Stopping the moment the walk stops descending turns that |
| 1661 | // into a list that ends rather than a tab that never settles. |
| 1662 | if (more && lowest !== null && _list.lowest !== null && lowest >= _list.lowest) { |
| 1663 | _list.done = true; |
| 1664 | } |
| 1665 | if (lowest !== null && (_list.lowest === null || lowest < _list.lowest)) _list.lowest = lowest; |
| 1666 | drawProps(); |
| 1667 | return true; |
| 1668 | } |
| 1669 | |
| 1670 | /// Read one proposal in full, for its body and its discussion. |
| 1671 | async function loadOne(n) { |
| 1672 | var a = await ask(route('n=' + n), { method: 'GET' }); |
| 1673 | if (!a.ok) { _list.err = a; drawProps(); return false; } |
| 1674 | absorb(cleanProp(a.data)); |
| 1675 | drawProps(); |
| 1676 | return true; |
| 1677 | } |
| 1678 | |
| 1679 | // ── Voting ───────────────────────────────────────────────── |
| 1680 | // |
| 1681 | // Contract §9: one store of truth, and it is the forge. Nothing is queued |
| 1682 | // here and nothing is kept here, because a tally in two places is a tally |
| 1683 | // that disagrees with itself. Every write answers with the DETAIL SHAPE of |
| 1684 | // the record it changed, so a vote's answer carries the new tally and the |
| 1685 | // caller's own `mine` and the control is redrawn from it -- never from a |
| 1686 | // second request, and never from a guess about what the press must have done. |
| 1687 | |
| 1688 | /// A vote's whole body: one field, one of three values, and nothing else. |
| 1689 | /// |
| 1690 | /// Form-encoded, like every other write on this surface. Written as a |
| 1691 | /// function that returns the exact characters rather than assembled at the |
| 1692 | /// call site, so an edit that wants to send a fifth thing has to defeat this |
| 1693 | /// rather than merely forget it -- the same discipline the old integers-only |
| 1694 | /// gate kept, in the shape the contract now asks for. |
| 1695 | /// |
| 1696 | /// A vote with no `d` at all is `malformed` at the forge and NOT a |
| 1697 | /// withdrawal, which is right: reading a lost field as an instruction to |
| 1698 | /// delete turns a dropped parameter into silent data loss. So there is no |
| 1699 | /// path here that sends one. |
| 1700 | function voteBody(d) { |
| 1701 | if (d !== 1 && d !== -1 && d !== 0) return ''; |
| 1702 | return 'd=' + d; |
| 1703 | } |
| 1704 | |
| 1705 | /// Cast, move, or take back a vote. |
| 1706 | /// |
| 1707 | /// Pressing the side you already chose withdraws it, which is the only way |
| 1708 | /// back and is what a pressed control that stays pressed has to offer. |
| 1709 | async function vote(n, dir) { |
| 1710 | var rec = _by[n]; |
| 1711 | if (!rec || !rec.votes || !rec.asked) return false; |
| 1712 | var want = (dir === 'do') ? 1 : -1; |
| 1713 | var body = voteBody(rec.mine === want ? 0 : want); |
| 1714 | if (!body) return false; |
| 1715 | var a = await ask(route('n=' + n + '&vote=1'), { |
| 1716 | method: 'POST', |
| 1717 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 1718 | body: body, |
| 1719 | }); |
| 1720 | if (!a.ok) { _list.err = a; drawProps(); return false; } |
| 1721 | absorb(cleanProp(a.data)); |
| 1722 | _list.err = null; |
| 1723 | drawProps(); |
| 1724 | return true; |
| 1725 | } |
| 1726 | |
| 1727 | /// Put one vote on the wire, and hand the forge's answer straight back. THE |
| 1728 | /// DOOR the Improve hub's card (js/tracker.js) sends a vote through, so the |
| 1729 | /// board holds neither this POST nor the pull voice it rides -- it draws its own |
| 1730 | /// row from the answer, which is the detail shape of the record it changed. `d` |
| 1731 | /// is 1, -1 or 0; anything else is refused here rather than sent, so a caller |
| 1732 | /// cannot turn a dropped value into a stray withdrawal. |
| 1733 | /// |
| 1734 | /// Kept apart from `vote` above, which draws THIS panel's own list and is where |
| 1735 | /// dev/verify_improve.mjs anchors its vote breaks: this one only puts the request |
| 1736 | /// on the wire, the same shape `say` and `post` do for a comment and a proposal. |
| 1737 | async function voteWire(n, d) { |
| 1738 | var body = voteBody(d); |
| 1739 | if (!body) return { ok: false, why: 'malformed' }; |
| 1740 | return await ask(route('n=' + n + '&vote=1'), { |
| 1741 | method: 'POST', |
| 1742 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 1743 | body: body, |
| 1744 | }); |
| 1745 | } |
| 1746 | |
| 1747 | // ── Revising one's own proposal ──────────────────────────── |
| 1748 | // |
| 1749 | // THE SECOND DOOR ON THE SAME SEAM, and it was built dark for the reason the |
| 1750 | // vote control was: `drawAmendControl` draws only when the answer carried the |
| 1751 | // flag. The forge answers it now, and NOTHING IN THIS FILE CHANGED WHEN IT DID |
| 1752 | // -- which is the second time that shape has paid off here, and the argument for |
| 1753 | // gating a control on the answer rather than on a comment about a date. |
| 1754 | // |
| 1755 | // TWO QUESTIONS, KEPT APART. The forge's flag answers ONE thing -- is this |
| 1756 | // asker the person who opened the proposal -- and deliberately does not fold in |
| 1757 | // whether the proposal is still open. That second half is a UI opinion, it is |
| 1758 | // reversible, and this panel already holds `state`; asking the forge to answer |
| 1759 | // the combined question would entangle a decision anybody may change with a |
| 1760 | // fold rule that is replicated and cannot be. So the test is written as two: |
| 1761 | // `canAmend(rec) && rec.state === 'open'`. |
| 1762 | |
| 1763 | var _amending = {}; // proposal number -> the row is in amend mode |
| 1764 | |
| 1765 | /// Cut a proposal's own characters back into the two boxes an amendment is. |
| 1766 | /// |
| 1767 | /// The inverse of `split()` and the same rule: what goes back on the wire is |
| 1768 | /// what is on the screen, so the boxes must open holding exactly what the |
| 1769 | /// record holds and nothing composed on the way in. |
| 1770 | function amendBoxes(n) { |
| 1771 | var row = document.querySelector('.imp-prop[data-prop="' + n + '"]'); |
| 1772 | if (!row) return null; |
| 1773 | var title = row.querySelector('.imp-amend-title'); |
| 1774 | var body = row.querySelector('.imp-amend-body'); |
| 1775 | if (!title || !body) return null; |
| 1776 | return { title: title, body: body }; |
| 1777 | } |
| 1778 | |
| 1779 | /// Publish a revision of one's own proposal. |
| 1780 | /// |
| 1781 | /// The same one rule as a note and a comment: what leaves is exactly the |
| 1782 | /// characters in those two boxes at the moment the button is pressed, and a |
| 1783 | /// refusal is SAID rather than queued -- the boxes keep their words so a person |
| 1784 | /// can read and copy them. |
| 1785 | /// |
| 1786 | /// The field set is `title` and `body`, and that is the whole of it. Asserted |
| 1787 | /// rather than intended, for the reason `post` states: a fifth field must have |
| 1788 | /// to defeat a check. |
| 1789 | async function amend(n) { |
| 1790 | var rec = _by[n]; |
| 1791 | if (!canAmend(rec)) return false; |
| 1792 | var box = amendBoxes(n); |
| 1793 | if (!box) return false; |
| 1794 | var title = String(box.title.value || '').trim(); |
| 1795 | var body = String(box.body.value || ''); |
| 1796 | if (!title) { _list.err = null; flash(tOr('social.nothing', 'Write something first.')); return false; } |
| 1797 | if (!hasVoice()) { _list.err = { why: 'unvoiced' }; drawProps(); return false; } |
| 1798 | // THROUGH THE SAME DOOR THE TRIAGE PLAN USES. Two callers with their own idea |
| 1799 | // of the request shape is how two halves of a feature stop meeting, which is |
| 1800 | // what §0 of the contract records -- and this file had exactly that, twice |
| 1801 | // over, until the two `amend`s were found colliding. |
| 1802 | var a = await revise(n, { title: title, body: body }); |
| 1803 | if (!a.ok) { _list.err = a; drawProps(); return false; } |
| 1804 | delete _amending[String(n)]; |
| 1805 | try { if (drafts()) drafts().dropUnder(draftKey('amend', n)); } catch (e) { /* storage blocked */ } |
| 1806 | absorb(cleanProp(a.data)); |
| 1807 | _list.err = null; |
| 1808 | drawProps(); |
| 1809 | return true; |
| 1810 | } |
| 1811 | |
| 1812 | // ── Saying something on a proposal ───────────────────────── |
| 1813 | |
| 1814 | /// Add one comment to a proposal. |
| 1815 | /// |
| 1816 | /// The same one rule as a note: what leaves is exactly the characters in that |
| 1817 | /// one box, at the moment the button beside it is pressed, and a failure is |
| 1818 | /// said rather than queued. The box is emptied only when the forge took it, |
| 1819 | /// so a refusal leaves the words where the person can still read and copy |
| 1820 | /// them. |
| 1821 | async function comment(n) { |
| 1822 | var box = document.querySelector('.imp-prop[data-prop="' + n + '"] .imp-reply'); |
| 1823 | if (!box) return false; |
| 1824 | var text = String(box.value || '').trim(); |
| 1825 | if (!text) { _list.err = null; flash(tOr('social.nothing', 'Write something first.')); return false; } |
| 1826 | if (!hasVoice()) { _list.err = { why: 'unvoiced' }; drawProps(); return false; } |
| 1827 | var a = await say(n, text); |
| 1828 | if (!a.ok) { _list.err = a; drawProps(); return false; } |
| 1829 | box.value = ''; |
| 1830 | try { if (drafts()) drafts().drop(draftKey('reply', n)); } catch (e) { /* storage blocked */ } |
| 1831 | absorb(cleanProp(a.data)); |
| 1832 | _list.err = null; |
| 1833 | drawProps(); |
| 1834 | return true; |
| 1835 | } |
| 1836 | |
| 1837 | /// Put one comment on the wire. THE ONE DOOR a comment leaves by, whichever |
| 1838 | /// box it was read out of -- the reply box under an open proposal, or a |
| 1839 | /// drafted comment in the Notes view. A second caller with its own idea of |
| 1840 | /// the request shape is how two halves of a feature stop meeting, which is |
| 1841 | /// the fault §0 of the contract exists to record. |
| 1842 | /// |
| 1843 | /// It takes the CHARACTERS and never the element: what leaves is decided by |
| 1844 | /// whoever read the screen, and that reading happens once, at the press. |
| 1845 | async function say(n, text) { |
| 1846 | var f = new URLSearchParams(); |
| 1847 | f.set('said', text); |
| 1848 | return await ask(route('n=' + n), { |
| 1849 | method: 'POST', |
| 1850 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 1851 | body: f.toString(), |
| 1852 | }); |
| 1853 | } |
| 1854 | |
| 1855 | /// Put a revision of one proposal on the wire. THE ONE DOOR a revision leaves |
| 1856 | /// by, whichever screen read it -- the boxes under an open proposal, or a |
| 1857 | /// revision draft in the triage plan. `post` and `say` are its siblings and it |
| 1858 | /// is named apart from them for the same reason they are named apart from |
| 1859 | /// `send` and `comment`: a door takes CHARACTERS somebody else read, and the |
| 1860 | /// panel action that read them is `amend` below. |
| 1861 | /// |
| 1862 | /// IT WAS CALLED `amend` AND THAT WAS A DEFECT. Two lanes built the two halves |
| 1863 | /// of this seam a fortnight apart, each declared `async function amend` in this |
| 1864 | /// one closure, and a function declaration does not collide -- it wins. The |
| 1865 | /// later one silently replaced the earlier, so pressing "Publish the revision" |
| 1866 | /// called this with no `parts` at all and threw on the first line. Nothing in |
| 1867 | /// either lane's own tests could see it, because each half was right. |
| 1868 | /// |
| 1869 | /// The fields are a proposal's own: a revision restates the proposal, so it |
| 1870 | /// carries what opening one carries and the same cut applies -- `title` is the |
| 1871 | /// characters before the first newline, `body` the characters after it. |
| 1872 | async function revise(n, parts) { |
| 1873 | var f = new URLSearchParams(); |
| 1874 | f.set('title', parts.title); |
| 1875 | f.set('body', parts.body); |
| 1876 | var a = await ask(route('n=' + n + '&amend=1'), { |
| 1877 | method: 'POST', |
| 1878 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 1879 | body: f.toString(), |
| 1880 | }); |
| 1881 | // A REFUSAL IS NAMED WHERE IT HAPPENED, AT THE DOOR, so both callers get the |
| 1882 | // sentence without either of them remembering to ask for it. `unpermitted` on |
| 1883 | // this route has one cause and only one -- somebody else opened this proposal |
| 1884 | // -- and `saying()` cannot know that from the token alone. Untagged it reads |
| 1885 | // "Your voice may not do that here", which is true of nine routes and useless |
| 1886 | // on this one. |
| 1887 | return a.ok ? a : onAmend(a); |
| 1888 | } |
| 1889 | |
| 1890 | // ── Drawing ──────────────────────────────────────────────── |
| 1891 | |
| 1892 | /// The day something happened, in the language the APP is in — not the |
| 1893 | /// browser's. trash.js records why. |
| 1894 | function fmtDate(at) { |
| 1895 | var loc; |
| 1896 | try { loc = window.DaimondI18n ? DaimondI18n.locale() : undefined; } |
| 1897 | catch (e) { loc = undefined; } |
| 1898 | try { return new Date(at).toLocaleDateString(loc || undefined, { day: 'numeric', month: 'short' }); } |
| 1899 | catch (e) { return ''; } |
| 1900 | } |
| 1901 | |
| 1902 | /// A forge stamp is in seconds; everything else here is in milliseconds. |
| 1903 | function fmtWhen(secs) { return secs ? fmtDate(secs * 1000) : ''; } |
| 1904 | |
| 1905 | function button(cls, act, text, title) { |
| 1906 | var b = document.createElement('button'); |
| 1907 | b.type = 'button'; |
| 1908 | b.className = cls; |
| 1909 | if (act) b.dataset.act = act; |
| 1910 | b.textContent = text; |
| 1911 | if (title) b.title = title; |
| 1912 | return b; |
| 1913 | } |
| 1914 | |
| 1915 | /// One line of text in a `div`, its text set as textContent so nothing here is |
| 1916 | /// ever markup. The queue head and the Settings sections are drawn with it. |
| 1917 | function line(cls, text) { |
| 1918 | var d = document.createElement('div'); |
| 1919 | d.className = cls; |
| 1920 | d.textContent = text; |
| 1921 | return d; |
| 1922 | } |
| 1923 | |
| 1924 | /// The queue: notes that could not be sent yet, newest first. Empty when there |
| 1925 | /// is nothing waiting, so the compose box sits straight above the proposals in |
| 1926 | /// the ordinary case; it fills only when a send did not go -- offline, or a |
| 1927 | /// forge that refused. Each row shows the words, how it will be sent, and offers |
| 1928 | /// a Send-now and a Delete. The reconnect flush drains it on its own. |
| 1929 | function drawQueue() { |
| 1930 | var host = el('improve-queue'); |
| 1931 | if (!host) return; |
| 1932 | var q = load().notes.slice(); |
| 1933 | host.innerHTML = ''; |
| 1934 | if (!q.length) return; |
| 1935 | |
| 1936 | host.appendChild(line('imp-asat imp-queue-head', tnOr('social.queue', q.length, |
| 1937 | 'Waiting to send ({n})', 'Waiting to send ({n})', { n: q.length }))); |
| 1938 | |
| 1939 | q.forEach(function (n) { |
| 1940 | var row = document.createElement('div'); |
| 1941 | row.className = 'imp-note imp-queue-row'; |
| 1942 | row.dataset.note = n.id; |
| 1943 | |
| 1944 | var text = document.createElement('div'); |
| 1945 | text.className = 'imp-note-text'; |
| 1946 | text.textContent = n.text; |
| 1947 | row.appendChild(text); |
| 1948 | |
| 1949 | var foot = document.createElement('div'); |
| 1950 | foot.className = 'imp-note-foot'; |
| 1951 | |
| 1952 | var state = document.createElement('span'); |
| 1953 | state.className = 'imp-note-state'; |
| 1954 | state.dataset.state = 'waiting'; |
| 1955 | state.textContent = (n.mode === 'polish') |
| 1956 | ? tOr('social.q_polish', 'Waiting to polish and post') |
| 1957 | : tOr('social.q_verbatim', 'Waiting to post'); |
| 1958 | foot.appendChild(state); |
| 1959 | |
| 1960 | if (hasVoice()) { |
| 1961 | foot.appendChild(button('imp-note-send', 'improve-resend', |
| 1962 | tOr('social.send_now', 'Send now'), |
| 1963 | tOr('social.send_now_help', 'Try to send this one now, in the way it was written.'))); |
| 1964 | } |
| 1965 | foot.appendChild(button('imp-note-copy', 'improve-copy', t('common.copy'), t('common.copy'))); |
| 1966 | |
| 1967 | try { |
| 1968 | foot.appendChild(DaimondCloser.make({ |
| 1969 | name: tOr('social.drop', 'Delete this note'), |
| 1970 | cls: 'imp-note-drop', |
| 1971 | onClose: function () { drop(n.id); }, |
| 1972 | })); |
| 1973 | } catch (e) { |
| 1974 | foot.appendChild(button('imp-note-drop', 'improve-drop', '×', tOr('social.drop', 'Delete this note'))); |
| 1975 | } |
| 1976 | |
| 1977 | row.appendChild(foot); |
| 1978 | host.appendChild(row); |
| 1979 | }); |
| 1980 | } |
| 1981 | |
| 1982 | /// The word a state is read as. The forge's vocabulary is |
| 1983 | /// open/accepted/declined/done; the guide's words are Open, Being done, Done |
| 1984 | /// and Declined, and those are what a reader has been promised. |
| 1985 | /// |
| 1986 | /// `accepted` reads through `social.state_taken`, which is the key those |
| 1987 | /// eight locales already hold "Being done" in. The key's name is older than |
| 1988 | /// the forge's word and renaming it would throw eight translations away to |
| 1989 | /// tidy a string nobody sees. |
| 1990 | function stateWord(s) { |
| 1991 | if (s === 'accepted') return tOr('social.state_taken', 'Being done'); |
| 1992 | if (s === 'done') return tOr('social.state_done', 'Done'); |
| 1993 | if (s === 'declined') return tOr('social.state_declined', 'Declined'); |
| 1994 | return tOr('social.state_open', 'Open'); |
| 1995 | } |
| 1996 | |
| 1997 | /// The vote control, which draws ONLY when the answer carried a tally. |
| 1998 | /// |
| 1999 | /// DARK UNTIL THE ANSWER CARRIES A TALLY, which it now does. The forge dispatches |
| 2000 | /// `proposals/<n>/vote` and both the listing and the whole record carry `votes`, |
| 2001 | /// checked against the deployed host on 2026-08-27 -- so this control draws, and it |
| 2002 | /// draws BECAUSE THE ANSWER CARRIES `votes` rather than because a date arrived. |
| 2003 | /// Nothing here changed when the forge started answering, which is the whole point |
| 2004 | /// of gating a control on the answer. This comment said "the vote route is not built |
| 2005 | /// there yet" until 2026-08-28: it was the FOURTH site of the claim the file header |
| 2006 | /// records correcting three of, and it was missed because it reads as a note about |
| 2007 | /// the branch below rather than as a claim about the forge. |
| 2008 | /// |
| 2009 | /// The branch below is still right and still cheap. A record with no `votes` key at |
| 2010 | /// all draws nothing -- not a disabled button, not a zero. |
| 2011 | /// |
| 2012 | /// This is NOT the test §9 warns a client off. That warning is against |
| 2013 | /// treating an UNVOTED proposal as one with no tally: `votes` on a proposal |
| 2014 | /// nobody has voted on is `{"for":0,"against":0}`, the zero object, and it is |
| 2015 | /// drawn like any other. What is tested here is the key's ABSENCE, which |
| 2016 | /// under §9 cannot happen at all -- so the branch is unreachable against a |
| 2017 | /// conforming forge and costs nothing against one that stops being conforming. |
| 2018 | /// |
| 2019 | /// `mine` ABSENT and `mine` NULL are different and are drawn differently: the |
| 2020 | /// first says the request carried no voice, so the buttons are not offered at |
| 2021 | /// all and a line says why; the second says a voice asked and has not voted, |
| 2022 | /// which is two buttons with neither pressed. A control that drew those the |
| 2023 | /// same way would show an unvoted button to somebody who cannot vote. |
| 2024 | function drawVoteControl(p, into) { |
| 2025 | if (!p.votes) return; |
| 2026 | var box = document.createElement('div'); |
| 2027 | box.className = 'imp-votes'; |
| 2028 | |
| 2029 | var tally = document.createElement('span'); |
| 2030 | tally.className = 'imp-prop-tally'; |
| 2031 | tally.textContent = tOr('social.tally', '{yes} for, {no} against', |
| 2032 | { yes: p.votes.for, no: p.votes.against }); |
| 2033 | box.appendChild(tally); |
| 2034 | |
| 2035 | if (!p.asked) { |
| 2036 | var line = document.createElement('span'); |
| 2037 | line.className = 'imp-as'; |
| 2038 | line.textContent = tOr('social.vote_novoice', 'Set a voice to vote on this.'); |
| 2039 | box.appendChild(line); |
| 2040 | into.appendChild(box); |
| 2041 | return; |
| 2042 | } |
| 2043 | [['do', 1, tOr('social.do', 'Do this')], ['not', -1, tOr('social.not', 'Not this')]] |
| 2044 | .forEach(function (pair) { |
| 2045 | var b = button('imp-vote', 'improve-vote', pair[2], pair[2]); |
| 2046 | b.dataset.dir = pair[0]; |
| 2047 | if (p.mine === pair[1]) { |
| 2048 | b.classList.add('on'); |
| 2049 | b.setAttribute('aria-pressed', 'true'); |
| 2050 | b.title = tOr('social.vote_off', 'Press again to take your vote back off.'); |
| 2051 | } else { |
| 2052 | b.setAttribute('aria-pressed', 'false'); |
| 2053 | } |
| 2054 | box.appendChild(b); |
| 2055 | }); |
| 2056 | into.appendChild(box); |
| 2057 | } |
| 2058 | |
| 2059 | /// The amend control, which draws ONLY when the answer carried the flag. |
| 2060 | /// |
| 2061 | /// DARK UNTIL THE FORGE ANSWERS, exactly as the vote control was until it did. |
| 2062 | /// The test is `=== true` and never `!p.amendable`, and the difference is the |
| 2063 | /// whole reason the forge leaves the field OUT rather than answering `false`: |
| 2064 | /// absent means no voice asked, `false` means a voice asked and is not the |
| 2065 | /// author, and a reader with no voice at all must never be shown a control that |
| 2066 | /// belongs to somebody. `null` cannot be read as an answer; a coerced `false` |
| 2067 | /// can, and would be the same bug wearing the right shape. |
| 2068 | /// |
| 2069 | /// A SETTLED PROPOSAL IS THIS SIDE'S JUDGEMENT and is asked separately. The |
| 2070 | /// forge answers who the author is, which is a replicated fact; whether a |
| 2071 | /// declined proposal may still be revised is an opinion about a screen, and |
| 2072 | /// folding the two into one flag would put a reversible decision inside a fold |
| 2073 | /// rule that cannot be reversed. |
| 2074 | function drawAmendControl(p, into) { |
| 2075 | if (!canAmend(p)) return; |
| 2076 | if (p.state !== 'open') return; |
| 2077 | if (!_amending[String(p.n)]) { |
| 2078 | var acts = document.createElement('div'); |
| 2079 | acts.className = 'imp-acts'; |
| 2080 | acts.appendChild(button('imp-note-copy', 'improve-amend-open', |
| 2081 | tOr('social.amend', 'Revise this'), |
| 2082 | tOr('social.amend_help', 'Replace what this proposal says; everyone sees the new words.'))); |
| 2083 | into.appendChild(acts); |
| 2084 | return; |
| 2085 | } |
| 2086 | var title = document.createElement('input'); |
| 2087 | title.type = 'text'; |
| 2088 | title.className = 'imp-box imp-amend-title'; |
| 2089 | title.value = p.title; |
| 2090 | title.setAttribute('aria-label', tOr('social.amend_title_ph', 'The one line this proposal is about')); |
| 2091 | title.placeholder = tOr('social.amend_title_ph', 'The one line this proposal is about'); |
| 2092 | into.appendChild(title); |
| 2093 | try { if (drafts()) drafts().bind(title, draftKey('amend', p.n, 'title')); } catch (e) { /* storage blocked */ } |
| 2094 | |
| 2095 | var body = document.createElement('textarea'); |
| 2096 | body.className = 'imp-box imp-amend-body'; |
| 2097 | body.rows = 4; |
| 2098 | body.value = p.body; |
| 2099 | body.setAttribute('aria-label', tOr('social.amend_body_ph', 'What happened, and what was expected instead')); |
| 2100 | body.placeholder = tOr('social.amend_body_ph', 'What happened, and what was expected instead'); |
| 2101 | into.appendChild(body); |
| 2102 | try { if (drafts()) drafts().bind(body, draftKey('amend', p.n, 'body')); } catch (e) { /* storage blocked */ } |
| 2103 | |
| 2104 | var row = document.createElement('div'); |
| 2105 | row.className = 'imp-acts'; |
| 2106 | row.appendChild(button('imp-send', 'improve-amend-save', |
| 2107 | tOr('social.amend_save', 'Publish the revision'), |
| 2108 | tOr('social.amend_save_help', 'Sends exactly these two boxes. Nothing else.'))); |
| 2109 | row.appendChild(button('imp-keep', 'improve-amend-cancel', t('common.cancel'))); |
| 2110 | into.appendChild(row); |
| 2111 | } |
| 2112 | |
| 2113 | /// One proposal, as a row that opens what it names: a coloured dot, the |
| 2114 | /// title, and the tally as its value — the admin panel's shape exactly, |
| 2115 | /// which is what the guide's "row" entry describes. |
| 2116 | function drawProps() { |
| 2117 | var list = el('improve-props'), asAt = el('improve-asat'); |
| 2118 | if (!list) return; |
| 2119 | // WHAT SOMEBODY IS HALF-WAY THROUGH TYPING SURVIVES THE REDRAW, for the |
| 2120 | // same reason an open row does: a vote or a language change redraws this |
| 2121 | // whole list, and a reply box emptied by it would take the words with it. |
| 2122 | var typed = {}; |
| 2123 | list.querySelectorAll('.imp-prop').forEach(function (r) { |
| 2124 | var box = r.querySelector('.imp-reply'); |
| 2125 | if (box && box.value) typed[r.dataset.prop] = box.value; |
| 2126 | }); |
| 2127 | list.innerHTML = ''; |
| 2128 | |
| 2129 | if (asAt) { |
| 2130 | // WHAT THIS PANEL WILL NEVER DO, said on the surface rather than in a |
| 2131 | // help page. Contract §7: there is no change feed, so a tester is never |
| 2132 | // TOLD their proposal was answered -- they find out by looking. A panel |
| 2133 | // that implied otherwise, with a badge or an unread count it cannot |
| 2134 | // honour, would be promising something nothing behind it can deliver. |
| 2135 | asAt.textContent = tOr('social.live_note', |
| 2136 | 'These are read from the forge as you look at them. Nothing tells you when a proposal is answered; look again to find out.'); |
| 2137 | } |
| 2138 | |
| 2139 | if (_list.err) { |
| 2140 | var err = document.createElement('div'); |
| 2141 | err.className = 'rail-note imp-err'; |
| 2142 | err.dataset.why = _list.err.why || ''; |
| 2143 | err.textContent = saying(_list.err); |
| 2144 | list.appendChild(err); |
| 2145 | } |
| 2146 | |
| 2147 | if (!_order.length) { |
| 2148 | var none = document.createElement('div'); |
| 2149 | none.className = 'rail-note'; |
| 2150 | none.textContent = _list.loading |
| 2151 | ? tOr('social.loading', 'Reading the proposals…') |
| 2152 | // NOT `social.no_props`, whose English in the catalogue says |
| 2153 | // proposals "arrive with a new build". They do not any more: they |
| 2154 | // arrive when somebody opens one, and a translated sentence that is |
| 2155 | // now false is worse than an English one that is true. |
| 2156 | : (_list.err |
| 2157 | ? tOr('social.none_shown', 'Nothing could be read just now.') |
| 2158 | : tOr('social.none_yet', 'No proposals here yet. Yours would be the first.')); |
| 2159 | list.appendChild(none); |
| 2160 | return; |
| 2161 | } |
| 2162 | |
| 2163 | _order.forEach(function (n) { |
| 2164 | var p = _by[n]; |
| 2165 | if (!p) return; |
| 2166 | var row = document.createElement('div'); |
| 2167 | row.className = 'imp-prop'; |
| 2168 | row.dataset.prop = String(p.n); |
| 2169 | row.dataset.state = p.state; |
| 2170 | |
| 2171 | var head = document.createElement('button'); |
| 2172 | head.type = 'button'; |
| 2173 | head.className = 'imp-prop-row'; |
| 2174 | head.dataset.act = 'improve-open'; |
| 2175 | |
| 2176 | var dot = document.createElement('span'); |
| 2177 | dot.className = 'imp-dot'; |
| 2178 | dot.title = stateWord(p.state); |
| 2179 | head.appendChild(dot); |
| 2180 | |
| 2181 | var title = document.createElement('span'); |
| 2182 | title.className = 'imp-prop-title'; |
| 2183 | title.textContent = p.title; |
| 2184 | head.appendChild(title); |
| 2185 | |
| 2186 | // The row's value is the tally, and there is no tally until the forge |
| 2187 | // answers one. A row that showed a zero there would be reporting a |
| 2188 | // count nothing has taken. |
| 2189 | if (p.votes) { |
| 2190 | var tally = document.createElement('span'); |
| 2191 | tally.className = 'imp-prop-tally'; |
| 2192 | tally.textContent = String(p.votes.for); |
| 2193 | tally.title = tOr('social.tally', '{yes} for, {no} against', |
| 2194 | { yes: p.votes.for, no: p.votes.against }); |
| 2195 | head.appendChild(tally); |
| 2196 | } |
| 2197 | row.appendChild(head); |
| 2198 | |
| 2199 | var body = document.createElement('div'); |
| 2200 | body.className = 'imp-prop-body'; |
| 2201 | // WHICH ROWS WERE OPEN SURVIVES THE REDRAW. Casting a vote redraws the |
| 2202 | // list, and the first build of this closed the proposal the user was |
| 2203 | // reading at the moment they pressed a button on it -- the answer |
| 2204 | // vanishing along with the question. |
| 2205 | body.hidden = !_open[String(p.n)]; |
| 2206 | head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true'); |
| 2207 | |
| 2208 | var says = document.createElement('p'); |
| 2209 | says.className = 'imp-prop-says'; |
| 2210 | says.textContent = p.detail |
| 2211 | ? p.body |
| 2212 | : tOr('social.reading', 'Reading it…'); |
| 2213 | body.appendChild(says); |
| 2214 | |
| 2215 | var facts = document.createElement('div'); |
| 2216 | facts.className = 'imp-prop-facts'; |
| 2217 | var parts = [stateWord(p.state)]; |
| 2218 | if (p.author) parts.push(tOr('social.by', 'from {who}', { who: p.author })); |
| 2219 | if (p.opened) parts.push(fmtWhen(p.opened)); |
| 2220 | parts.push(tnOr('social.said_n', p.comments, '{n} reply', '{n} replies', { n: p.comments })); |
| 2221 | // AN EMPTY LIST SAYS NOTHING HERE, and that is not the same as saying |
| 2222 | // nothing about it: `[]` is the forge's answer that this proposal has |
| 2223 | // never been revised, `null` is a listing record that was never asked. |
| 2224 | // Drawing "revised 0 times" would be reporting a fact nobody wanted; the |
| 2225 | // two silences differ where it matters, in `revisions` itself. |
| 2226 | if (p.revisions && p.revisions.length) { |
| 2227 | parts.push(tnOr('social.revised_n', p.revisions.length, |
| 2228 | 'revised once', 'revised {n} times', { n: p.revisions.length })); |
| 2229 | } |
| 2230 | if (p.build) parts.push(tOr('social.built_on', 'written on build {build}', { build: p.build })); |
| 2231 | if (p.mark) parts.push(tOr('social.closed_by', 'closed by mark {mark}', { mark: p.mark })); |
| 2232 | facts.textContent = parts.join(' · '); |
| 2233 | body.appendChild(facts); |
| 2234 | |
| 2235 | // WHERE A READER MEETS THE CASE, not in a help page. A proposal that |
| 2236 | // names a mark is one whose code has moved, and "did my note follow |
| 2237 | // it?" is the question a reader has at exactly this moment. Contract §5. |
| 2238 | if (p.mark) { |
| 2239 | var floor = document.createElement('p'); |
| 2240 | floor.className = 'imp-prop-says imp-floor'; |
| 2241 | floor.textContent = tOr('social.move_floor', |
| 2242 | 'A note follows its content across files only when the change counts as a move — the floor is ' |
| 2243 | + '64 bytes. Cut less, and the history holds a delete and an insert, so the note reports its ' |
| 2244 | + 'content deleted.'); |
| 2245 | body.appendChild(floor); |
| 2246 | } |
| 2247 | |
| 2248 | if (p.discussion && p.discussion.length) { |
| 2249 | var disc = document.createElement('div'); |
| 2250 | disc.className = 'imp-disc'; |
| 2251 | p.discussion.forEach(function (d) { |
| 2252 | var one = document.createElement('div'); |
| 2253 | one.className = 'imp-disc-one'; |
| 2254 | var who = document.createElement('span'); |
| 2255 | who.className = 'imp-note-state'; |
| 2256 | who.textContent = d.author + (d.when ? ' · ' + fmtWhen(d.when) : ''); |
| 2257 | var said = document.createElement('p'); |
| 2258 | said.className = 'imp-prop-says'; |
| 2259 | said.textContent = d.said; |
| 2260 | one.appendChild(who); |
| 2261 | one.appendChild(said); |
| 2262 | disc.appendChild(one); |
| 2263 | }); |
| 2264 | body.appendChild(disc); |
| 2265 | } |
| 2266 | |
| 2267 | drawVoteControl(p, body); |
| 2268 | // A REVISION CHANGES ITS TILE IN PLACE AND NEVER FLOATS TO THE TOP, and |
| 2269 | // nothing here sorts. `_order` is the walk's order, which is the forge's: |
| 2270 | // proposal number descending, with `from` a CEILING on the number rather |
| 2271 | // than a since-cursor, because an offset does not stay stable while new |
| 2272 | // proposals arrive. `changed` orders nothing, filters nothing and pages |
| 2273 | // nothing -- the forge never consults it and neither does this. |
| 2274 | // |
| 2275 | // Anybody minded to add "recently revised first" here should not: a |
| 2276 | // re-sort inside a page is right within the page and wrong across pages, |
| 2277 | // and the symptom is indistinguishable from a stale cache. It would also |
| 2278 | // be the wrong behaviour -- a revision is a correction to something |
| 2279 | // already said, not new news, so a triage pass that revised eight |
| 2280 | // proposals would bury everything genuinely new underneath them. |
| 2281 | drawAmendControl(p, body); |
| 2282 | |
| 2283 | // Saying something back. Offered only with a voice, because the forge |
| 2284 | // refuses a comment without one and a box that cannot be sent is a box |
| 2285 | // that teaches people to distrust every box. |
| 2286 | if (p.detail && hasVoice()) { |
| 2287 | var reply = document.createElement('textarea'); |
| 2288 | reply.className = 'imp-box imp-reply'; |
| 2289 | reply.rows = 2; |
| 2290 | reply.placeholder = tOr('social.reply_ph', 'Say something about this proposal.'); |
| 2291 | reply.setAttribute('aria-label', tOr('social.reply_ph', 'Say something about this proposal.')); |
| 2292 | if (typed[String(p.n)]) reply.value = typed[String(p.n)]; |
| 2293 | body.appendChild(reply); |
| 2294 | // The redraw is already survived by `typed` above. THE RELOAD is what |
| 2295 | // this adds, and it is the same words and the same loss: a reply |
| 2296 | // three sentences in, and a refresh takes it. Bound after the row is |
| 2297 | // in the tree so a restored value is on screen rather than on a node |
| 2298 | // nobody has attached. |
| 2299 | try { if (drafts()) drafts().bind(reply, draftKey('reply', p.n)); } catch (e) { /* storage blocked */ } |
| 2300 | var acts = document.createElement('div'); |
| 2301 | acts.className = 'imp-acts'; |
| 2302 | acts.appendChild(button('imp-send', 'improve-comment', |
| 2303 | tOr('social.reply', 'Say it'), |
| 2304 | tOr('social.reply_help', 'Sends exactly this box. Nothing else.'))); |
| 2305 | body.appendChild(acts); |
| 2306 | } |
| 2307 | |
| 2308 | row.appendChild(body); |
| 2309 | list.appendChild(row); |
| 2310 | }); |
| 2311 | |
| 2312 | // The foot: how many there are, and the one control that walks downwards. |
| 2313 | var foot = document.createElement('div'); |
| 2314 | foot.className = 'rail-note imp-foot'; |
| 2315 | var count = document.createElement('span'); |
| 2316 | count.id = 'improve-count'; |
| 2317 | count.textContent = tnOr('social.count', _list.total, |
| 2318 | '{n} proposal', '{n} proposals', { n: _list.total }); |
| 2319 | foot.appendChild(count); |
| 2320 | if (!_list.done) { |
| 2321 | foot.appendChild(button('imp-note-copy', 'improve-more', |
| 2322 | _list.loading ? tOr('social.loading', 'Reading the proposals…') : tOr('social.more', 'Show older'))); |
| 2323 | } |
| 2324 | list.appendChild(foot); |
| 2325 | } |
| 2326 | |
| 2327 | /// The Settings view: the voice this device posts with, and the drafts the |
| 2328 | /// model prepared. Both used to sit beside the compose box; the owner moved them |
| 2329 | /// out so the box is just a box (#7). `drawVoice` fills the voice section; |
| 2330 | /// "Forget this plan" clears anything the model drafted that has not been sent. |
| 2331 | function drawSettings() { |
| 2332 | var host = el('improve-settings'); |
| 2333 | if (!host) return; |
| 2334 | host.innerHTML = ''; |
| 2335 | |
| 2336 | host.appendChild(line('imp-with-label imp-set-head', tOr('social.set_voice', 'Your voice'))); |
| 2337 | drawVoice(); |
| 2338 | |
| 2339 | host.appendChild(line('imp-with-label imp-set-head', tOr('social.set_drafts', 'Prepared drafts'))); |
| 2340 | host.appendChild(line('imp-as imp-set-note', tOr('social.set_drafts_note', |
| 2341 | 'Forgets any proposals the model drafted from your notes that you have not sent. ' |
| 2342 | + 'Your notes waiting to send are not touched.'))); |
| 2343 | var acts = document.createElement('div'); |
| 2344 | acts.className = 'imp-acts'; |
| 2345 | acts.appendChild(button('imp-note-copy', 'improve-forget-plan', |
| 2346 | tOr('social.triage_clear', 'Forget this plan'), |
| 2347 | tOr('social.triage_clear_help', 'Clear the drafts. Nothing is sent.'))); |
| 2348 | host.appendChild(acts); |
| 2349 | } |
| 2350 | |
| 2351 | // ── The proposer's returned notes (a declined proposal comes back) ── |
| 2352 | // |
| 2353 | // A proposal this device raised may be DECLINED by an operator, and the decline |
| 2354 | // carries a one-line reason back to the proposer. It is the one thing the forge |
| 2355 | // tells a proposer without being looked at (contract §7's "look again" holds for |
| 2356 | // the proposals list, not for a refusal), because a note that was refused is |
| 2357 | // news the writer would not otherwise find. It is read here, in the capture view |
| 2358 | // beside the box the note was written in, and cleared per entry once seen. |
| 2359 | // |
| 2360 | // THE TWO DOORS SIT BEHIND ONE CONSTANT so a path firm-up is a one-line change: |
| 2361 | // the GET reads what is waiting, the ACK is the same door with `&ack=1` and a |
| 2362 | // FORM body naming the `when` keys the reader has seen. The forge derives the |
| 2363 | // caller from the voice, holds that caller's own inbox and TTL-prunes old notes |
| 2364 | // -- so this side never sends a name and never deletes whole: it acks the keys |
| 2365 | // of the notes it has shown. |
| 2366 | var RETURNED = 'returned=1'; |
| 2367 | |
| 2368 | var _returned = { notes: [], read: false, loading: false }; |
| 2369 | |
| 2370 | /// One returned note off the wire, kept to the three fields it is and no more. |
| 2371 | /// A note with no `when` is dropped: the key is what an ack names, so one that |
| 2372 | /// could not be acked would sit until the forge's TTL took it. |
| 2373 | function cleanReturned(r) { |
| 2374 | if (!r || typeof r !== 'object') return null; |
| 2375 | var when = whole(r.when); |
| 2376 | if (when < 1) return null; |
| 2377 | return { |
| 2378 | when: when, |
| 2379 | title: (typeof r.title === 'string') ? r.title : '', |
| 2380 | reason: (typeof r.reason === 'string') ? r.reason : '', |
| 2381 | }; |
| 2382 | } |
| 2383 | |
| 2384 | /// The returned notes an answer carries, cleaned and newest first. |
| 2385 | function cleanReturnedList(data) { |
| 2386 | var raw = (data && Array.isArray(data.returned)) ? data.returned : []; |
| 2387 | var out = []; |
| 2388 | raw.forEach(function (r) { var c = cleanReturned(r); if (c) out.push(c); }); |
| 2389 | out.sort(function (a, b) { return b.when - a.when; }); |
| 2390 | return out; |
| 2391 | } |
| 2392 | |
| 2393 | /// Read the notes a decline left for this proposer. Needs the device's own |
| 2394 | /// voice; without one there is no inbox and the call is a no-op. A refusal is |
| 2395 | /// quiet -- the inbox is a courtesy beside the box, not the panel itself. |
| 2396 | async function loadReturned() { |
| 2397 | if (_returned.loading) return false; |
| 2398 | if (!hasVoice()) { _returned.notes = []; _returned.read = false; drawReturned(); return false; } |
| 2399 | _returned.loading = true; |
| 2400 | var a = await ask(route(RETURNED), { method: 'GET' }); |
| 2401 | _returned.loading = false; |
| 2402 | if (!a.ok) { drawReturned(); return false; } |
| 2403 | _returned.notes = cleanReturnedList(a.data); |
| 2404 | _returned.read = true; |
| 2405 | drawReturned(); |
| 2406 | return true; |
| 2407 | } |
| 2408 | |
| 2409 | /// Acknowledge the notes named by their `when` keys: the forge drops them and |
| 2410 | /// answers the remainder, which becomes the shown list. THE BODY IS A FORM, not |
| 2411 | /// JSON -- `acked=<when>[,<when>…]`, the shape the machine surface reads. Per |
| 2412 | /// entry, never delete-whole: only keys the reader has seen are named. |
| 2413 | async function ackReturned(whens) { |
| 2414 | var keys = (Array.isArray(whens) ? whens : [whens]) |
| 2415 | .map(function (w) { return whole(w); }) |
| 2416 | .filter(function (w) { return w > 0; }); |
| 2417 | if (!keys.length) return false; |
| 2418 | var f = new URLSearchParams(); |
| 2419 | f.set('acked', keys.join(',')); |
| 2420 | var a = await ask(route(RETURNED + '&ack=1'), { |
| 2421 | method: 'POST', |
| 2422 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 2423 | body: f.toString(), |
| 2424 | }); |
| 2425 | if (!a.ok) { flash(saying(a)); return false; } |
| 2426 | _returned.notes = cleanReturnedList(a.data); |
| 2427 | drawReturned(); |
| 2428 | return true; |
| 2429 | } |
| 2430 | |
| 2431 | /// Draw the returned notes into the capture view, or nothing when there are |
| 2432 | /// none. Terse: one line per note, and a dismiss that acks its own `when` key. |
| 2433 | function drawReturned() { |
| 2434 | var host = el('improve-returned'); |
| 2435 | if (!host) return; |
| 2436 | host.innerHTML = ''; |
| 2437 | var notes = _returned.notes; |
| 2438 | host.hidden = !notes.length; |
| 2439 | if (!notes.length) return; |
| 2440 | notes.forEach(function (r) { |
| 2441 | var row = document.createElement('div'); |
| 2442 | row.className = 'imp-returned-one'; |
| 2443 | row.dataset.when = String(r.when); |
| 2444 | var said = document.createElement('p'); |
| 2445 | said.className = 'imp-returned-said'; |
| 2446 | said.textContent = r.reason |
| 2447 | ? tOr('social.returned_one', "Your proposal '{title}' was declined: {reason}", |
| 2448 | { title: r.title, reason: r.reason }) |
| 2449 | : tOr('social.returned_bare', "Your proposal '{title}' was declined.", { title: r.title }); |
| 2450 | row.appendChild(said); |
| 2451 | var x = button('imp-returned-x', 'improve-returned-dismiss', |
| 2452 | tOr('common.dismiss', 'Dismiss'), |
| 2453 | tOr('social.returned_dismiss', 'Dismiss this note.')); |
| 2454 | x.dataset.when = String(r.when); |
| 2455 | row.appendChild(x); |
| 2456 | host.appendChild(row); |
| 2457 | }); |
| 2458 | } |
| 2459 | |
| 2460 | function render() { |
| 2461 | bindNoteBox(); |
| 2462 | drawContext(); |
| 2463 | drawReturned(); |
| 2464 | drawAs(); |
| 2465 | drawQueue(); |
| 2466 | // The approve-list draws only where its host exists; with the Notes view |
| 2467 | // gone it is dormant, but kept called so a build that restores a batch |
| 2468 | // surface needs no change here. Same for the triage row. |
| 2469 | try { if (window.DaimondApproveList) DaimondApproveList.draw(); } catch (e) { log('the approve-list would not draw', e); } |
| 2470 | try { if (window.DaimondTriage) DaimondTriage.draw(); } catch (e) { log('the triage row would not draw', e); } |
| 2471 | drawProps(); |
| 2472 | drawSettings(); |
| 2473 | } |
| 2474 | |
| 2475 | // ── WHAT IS HALF-WRITTEN SURVIVES A RELOAD ───────────────── |
| 2476 | // |
| 2477 | // A screen refresh used to empty the note box, and the words were gone with |
| 2478 | // nothing anywhere saying anything had been lost. Reported by the owner: |
| 2479 | // "I would expect all live text input to persist." |
| 2480 | // |
| 2481 | // THIS IS NOT THE QUEUE §4 FORBIDS, and the reasoning is in `drafts.js`'s |
| 2482 | // header rather than restated here. In one line: nothing is kept for later |
| 2483 | // SENDING, because nothing here sends -- `outgoing()` still reads the box at |
| 2484 | // the moment of the press, and restoring the box is what puts the words in |
| 2485 | // front of the person who has to press it. The draft is dropped by every |
| 2486 | // path that empties the box, so a sent note is never also a draft of itself. |
| 2487 | |
| 2488 | function drafts() { return window.DaimondDrafts || null; } |
| 2489 | |
| 2490 | /// The key one box's draft is kept under. Namespaced by SURFACE, so that |
| 2491 | /// `dropUnder` can forget everything belonging to one proposal when it is |
| 2492 | /// gone without knowing how many boxes that proposal drew. |
| 2493 | function draftKey() { |
| 2494 | var parts = ['social']; |
| 2495 | for (var i = 0; i < arguments.length; i++) parts.push(String(arguments[i])); |
| 2496 | return parts.join('/'); |
| 2497 | } |
| 2498 | |
| 2499 | function bindNoteBox() { |
| 2500 | var d = drafts(), box = el('improve-box'); |
| 2501 | if (d && box) d.bind(box, draftKey('note')); |
| 2502 | } |
| 2503 | |
| 2504 | // ── A REFERENCE, DRAWN AS A CHIP ─────────────────────────── |
| 2505 | // |
| 2506 | // Four things a message may point at — a proposal, a build, a panel, a guide |
| 2507 | // page — and the five rules the code has to keep. They are written out here |
| 2508 | // because every one of them is a rule somebody would otherwise "simplify" |
| 2509 | // away, and four of the five look like extra work until the thing they |
| 2510 | // prevent happens. |
| 2511 | // |
| 2512 | // R1 RESOLVED BY THE READER, NEVER RENDERED BY THE SENDER. What travels |
| 2513 | // is `{ kind, id, fallback_label }`. The title on screen is read from |
| 2514 | // the forge, this build's own stamp, this build's own panel table or |
| 2515 | // this build's own guide — by the reader, now. A title supplied by the |
| 2516 | // sender is a lie waiting to happen, because proposals get renamed and |
| 2517 | // closed, AND it is an injection surface: arbitrary text drawn as |
| 2518 | // though it were a forge record. `fallback_label` is drawn ONLY when |
| 2519 | // the resolution fails, as plain text, and framed as the sender's own |
| 2520 | // description rather than as the name of anything. |
| 2521 | // R2 NEVER DISCLOSE THE EXISTENCE OF WHAT THE READER CANNOT SEE. The nine |
| 2522 | // refusal wordings are `saying()`'s, unchanged and not re-worded here: |
| 2523 | // `absent` covers both "no such repository" and "it is private", which |
| 2524 | // is exactly why it must not be sharpened. A signed-out reader is |
| 2525 | // refused by the gateway before the forge is asked, so they are told to |
| 2526 | // sign in and NEVER that a thing was not found. |
| 2527 | // R3 A REFERENCE IS NOT A URL. There is no `href` in this file. Every chip |
| 2528 | // is a `<button>` that calls into this app. |
| 2529 | // R4 AT MOST FOUR PER MESSAGE. Enforced here as well as in the payload, so |
| 2530 | // a sender that got past the seal still cannot draw a fifth. |
| 2531 | // R5 RESOLUTION IS LAZY AND CACHED. Nothing is fetched until somebody |
| 2532 | // presses the chip open, and an answer is kept. `improve.rs` meters per |
| 2533 | // tester: ten proposal chips resolved eagerly on an inbox opening is |
| 2534 | // ten metered requests against that reader's OWN Improve allowance, |
| 2535 | // which could throttle them out of the Improve half of this panel |
| 2536 | // entirely. That is the whole reason R5 exists. |
| 2537 | |
| 2538 | /// The four kinds. An enum, not a string test: a fifth arrives by being |
| 2539 | /// added here and nowhere else, and a `kind` this build does not know draws |
| 2540 | /// the sender's description and no control at all. |
| 2541 | // i18n-family: ref.kind_ = proposal build panel guide |
| 2542 | var REFS = { proposal: 'Proposal', build: 'Build', panel: 'Panel', guide: 'Guide' }; |
| 2543 | |
| 2544 | /// Resolutions already paid for, by `kind + ':' + id`. R5's cache. Kept for |
| 2545 | /// the life of the tab: a proposal's title moving under a reader who is |
| 2546 | /// looking at a chip costs nothing, and a second metered request does. |
| 2547 | var _refs = {}; |
| 2548 | |
| 2549 | /// The one place a reference off the wire is read. Everything else in this |
| 2550 | /// section takes the result of this and never the raw thing. |
| 2551 | /// |
| 2552 | /// A reference is three fields and there is no fourth. Anything else on the |
| 2553 | /// object is dropped here rather than ignored later, which is the difference |
| 2554 | /// between a field that cannot be smuggled and one that merely is not read. |
| 2555 | function cleanRef(r) { |
| 2556 | if (!r || typeof r !== 'object') return null; |
| 2557 | var kind = (typeof r.kind === 'string') ? r.kind : ''; |
| 2558 | if (!REFS[kind]) return null; |
| 2559 | var id = (r.id == null) ? '' : String(r.id); |
| 2560 | if (!id || id.length > 128) return null; |
| 2561 | var said = (typeof r.fallback_label === 'string') ? r.fallback_label.slice(0, 200) : ''; |
| 2562 | return { kind: kind, id: id, said: said }; |
| 2563 | } |
| 2564 | |
| 2565 | /// The references a message carries, cleaned and capped. R4. |
| 2566 | function cleanRefs(list) { |
| 2567 | if (!Array.isArray(list)) return []; |
| 2568 | var out = []; |
| 2569 | for (var i = 0; i < list.length && out.length < 4; i++) { |
| 2570 | var r = cleanRef(list[i]); |
| 2571 | if (r) out.push(r); |
| 2572 | } |
| 2573 | return out; |
| 2574 | } |
| 2575 | |
| 2576 | /// Whether this reader has a session at all. Without one `improve.rs` refuses |
| 2577 | /// before the forge is asked, so a proposal chip must say "sign in" rather |
| 2578 | /// than anything about whether the proposal is there. |
| 2579 | function signedIn() { |
| 2580 | try { |
| 2581 | if (window.DaimondGateway && DaimondGateway.hasSession) return !!DaimondGateway.hasSession(); |
| 2582 | if (window.DaimondIdentity && DaimondIdentity.unlocked) return !!DaimondIdentity.unlocked(); |
| 2583 | } catch (e) { /* neither module in this build */ } |
| 2584 | return true; // not knowable here: let the refusal say it instead |
| 2585 | } |
| 2586 | |
| 2587 | /// Resolve one reference. Answers `{ ok, title, note, act }` or |
| 2588 | /// `{ ok: false, why }` with `why` already a SENTENCE from `saying()`. |
| 2589 | async function resolve(ref) { |
| 2590 | var key = ref.kind + ':' + ref.id; |
| 2591 | if (_refs[key]) return _refs[key]; |
| 2592 | var out; |
| 2593 | if (ref.kind === 'proposal') { |
| 2594 | var n = parseInt(ref.id, 10); |
| 2595 | if (!(n > 0)) out = { ok: false, why: tOr('ref.unopenable', 'There is no opening this here.') }; |
| 2596 | else if (!signedIn()) out = { ok: false, why: tOr('ref.signin', 'Sign in to open this.') }; |
| 2597 | else { |
| 2598 | var a = await ask(route('n=' + n), { method: 'GET' }); |
| 2599 | if (!a.ok) out = { ok: false, why: saying(a) }; |
| 2600 | else { |
| 2601 | var p = cleanProp(a.data); |
| 2602 | out = p |
| 2603 | ? { |
| 2604 | ok: true, |
| 2605 | title: p.title, |
| 2606 | note: tnOr('ref.said_n', p.comments, '{n} comment, public', |
| 2607 | '{n} comments, public', { n: p.comments }), |
| 2608 | act: tOr('ref.open_proposal', 'Open the proposal'), |
| 2609 | // The browse list lives in the Improve hub now (js/tracker.js), |
| 2610 | // not on this capture surface, so a proposal reference opens |
| 2611 | // there. Falls back to this panel where the hub is absent. |
| 2612 | go: function () { |
| 2613 | try { |
| 2614 | if (window.DaimondPanels && window.DaimondTracker) { |
| 2615 | DaimondPanels.show('tracker'); |
| 2616 | DaimondTracker.open(p.n); |
| 2617 | return; |
| 2618 | } |
| 2619 | } catch (e) { /* no hub in this build */ } |
| 2620 | try { if (window.DaimondPanels) DaimondPanels.show('social'); } catch (e2) { /* no engine */ } |
| 2621 | show('proposals'); |
| 2622 | }, |
| 2623 | } |
| 2624 | : { ok: false, why: saying(null) }; |
| 2625 | } |
| 2626 | } |
| 2627 | } else if (ref.kind === 'build') { |
| 2628 | // Not a request: this build's own stamp is already in hand, and the |
| 2629 | // reader's own is the only other half of the answer. |
| 2630 | var here = (_build && _build === ref.id); |
| 2631 | out = { |
| 2632 | ok: true, |
| 2633 | title: tOr('ref.build', 'Build {id}', { id: ref.id }), |
| 2634 | note: here |
| 2635 | ? tOr('ref.build_here', 'This is the build you are on.') |
| 2636 | : tOr('ref.build_other', 'You are on build {id}.', { id: _build || '?' }), |
| 2637 | act: here ? '' : tOr('ref.build_update', 'Update to it'), |
| 2638 | go: here ? null : function () { |
| 2639 | try { if (window.DaimondUpdater) DaimondUpdater.check(); } catch (e) { /* no updater */ } |
| 2640 | }, |
| 2641 | }; |
| 2642 | } else if (ref.kind === 'panel') { |
| 2643 | // A surface, not an object. It discloses nothing and needs no |
| 2644 | // resolution machinery -- but a panel this build does not have is |
| 2645 | // still a chip that would always fail, so it is asked for by name. |
| 2646 | var host = /^[a-z0-9_-]+$/i.test(ref.id) |
| 2647 | ? document.querySelector('[data-panel="' + ref.id + '"]') |
| 2648 | : null; |
| 2649 | out = host |
| 2650 | ? { |
| 2651 | ok: true, |
| 2652 | title: tOr('ref.panel', 'The {name} panel', |
| 2653 | { name: host.dataset.label || ref.id }), |
| 2654 | note: '', |
| 2655 | act: tOr('ref.open_panel', 'Open it'), |
| 2656 | go: function () { try { DaimondPanels.show(ref.id); } catch (e) { /* no engine */ } }, |
| 2657 | } |
| 2658 | : { ok: false, why: tOr('ref.unopenable', 'There is no opening this here.') }; |
| 2659 | } else { |
| 2660 | var page = /^[a-z0-9-]+\.html(#[a-z0-9-]+)?$/i.test(ref.id) ? ref.id : ''; |
| 2661 | out = page |
| 2662 | ? { |
| 2663 | ok: true, |
| 2664 | title: tOr('ref.guide', 'Guide: {page}', { page: page.replace(/\.html.*$/, '') }), |
| 2665 | note: '', |
| 2666 | act: tOr('ref.open_guide', 'Open the page'), |
| 2667 | // The guide renders IN the app. "Never link out" honoured |
| 2668 | // rather than dodged: this is the same route the header's own |
| 2669 | // guide button takes. |
| 2670 | go: function () { |
| 2671 | try { if (window.DaimondWeb && DaimondWeb.guide) DaimondWeb.guide(page); } |
| 2672 | catch (e) { /* no web panel in this build */ } |
| 2673 | }, |
| 2674 | } |
| 2675 | : { ok: false, why: tOr('ref.unopenable', 'There is no opening this here.') }; |
| 2676 | } |
| 2677 | _refs[key] = out; |
| 2678 | return out; |
| 2679 | } |
| 2680 | |
| 2681 | /// What a chip is called before anything has been read. |
| 2682 | /// |
| 2683 | /// The id is this app's own words only where it is SHAPED like an id of that |
| 2684 | /// kind: a proposal number, a build stamp, a panel this build has, a guide |
| 2685 | /// page. Anything else and the name is EMPTY -- the kind label beside it |
| 2686 | /// already says what sort of thing this is, and repeating it there says |
| 2687 | /// nothing twice. Because an id is the one field a sender fills in, and a |
| 2688 | /// hundred and twenty-eight characters of their choosing sitting where a name |
| 2689 | /// goes is R1 defeated by the back door. |
| 2690 | function refName(ref) { |
| 2691 | if (ref.kind === 'proposal') { |
| 2692 | return /^[0-9]{1,9}$/.test(ref.id) |
| 2693 | ? tOr('ref.proposal', 'Proposal #{n}', { n: ref.id }) : ''; |
| 2694 | } |
| 2695 | if (ref.kind === 'build') { |
| 2696 | return /^[0-9a-f]{6,64}$/i.test(ref.id) |
| 2697 | ? tOr('ref.build', 'Build {id}', { id: ref.id }) : ''; |
| 2698 | } |
| 2699 | if (ref.kind === 'panel') { |
| 2700 | var p = /^[a-z0-9_-]+$/i.test(ref.id) |
| 2701 | ? document.querySelector('[data-panel="' + ref.id + '"]') : null; |
| 2702 | return p ? tOr('ref.panel', 'The {name} panel', { name: p.dataset.label || ref.id }) : ''; |
| 2703 | } |
| 2704 | return /^[a-z0-9-]+\.html(#[a-z0-9-]+)?$/i.test(ref.id) |
| 2705 | ? tOr('ref.guide', 'Guide: {page}', { page: ref.id.replace(/\.html.*$/, '') }) : ''; |
| 2706 | } |
| 2707 | |
| 2708 | /// One chip, SHUT until somebody opens it. The shape is `attachTile`'s: a |
| 2709 | /// kind, a name, a reason and a note, with `shut` meaning "there is no |
| 2710 | /// opening this" -- the same thing an unresolvable reference is. |
| 2711 | function refChip(ref) { |
| 2712 | var box = document.createElement('div'); |
| 2713 | box.className = 'ref-chip'; |
| 2714 | box.dataset.kind = ref.kind; |
| 2715 | box.dataset.ref = ref.id; |
| 2716 | |
| 2717 | var kind = document.createElement('span'); |
| 2718 | kind.className = 'ref-kind'; |
| 2719 | kind.textContent = tOr('ref.kind_' + ref.kind, REFS[ref.kind]); |
| 2720 | box.appendChild(kind); |
| 2721 | |
| 2722 | // What it is called BEFORE anything has been read: the kind and the id. |
| 2723 | // Never the sender's words -- and never a raw id either unless it is |
| 2724 | // SHAPED like an id of that kind. An id is the one field a sender fills |
| 2725 | // in, so an unrecognisable one is drawn as nothing at all rather than as |
| 2726 | // 128 characters of their choosing sitting where a name goes. |
| 2727 | var name = document.createElement('span'); |
| 2728 | name.className = 'ref-name'; |
| 2729 | name.textContent = refName(ref); |
| 2730 | box.appendChild(name); |
| 2731 | |
| 2732 | var note = document.createElement('span'); |
| 2733 | note.className = 'ref-note'; |
| 2734 | box.appendChild(note); |
| 2735 | |
| 2736 | var act = document.createElement('button'); |
| 2737 | act.type = 'button'; |
| 2738 | act.className = 'ref-act'; |
| 2739 | act.textContent = tOr('ref.expand', 'Show what this is'); |
| 2740 | box.appendChild(act); |
| 2741 | |
| 2742 | var done = false; |
| 2743 | act.addEventListener('click', async function () { |
| 2744 | if (done) return; |
| 2745 | done = true; |
| 2746 | act.disabled = true; |
| 2747 | note.textContent = tOr('ref.reading', 'Reading it…'); |
| 2748 | var r = await resolve(ref); |
| 2749 | if (!r.ok) { |
| 2750 | box.classList.add('shut'); |
| 2751 | note.textContent = r.why; |
| 2752 | // R1: the sender's description, drawn only now, as plain text and |
| 2753 | // said to be theirs. `textContent` and not markup, which is the |
| 2754 | // other half of why a sender-supplied title is refused. |
| 2755 | if (ref.said) { |
| 2756 | var said = document.createElement('span'); |
| 2757 | said.className = 'ref-said'; |
| 2758 | said.textContent = tOr('ref.said', 'Described as: {text}', { text: ref.said }); |
| 2759 | box.appendChild(said); |
| 2760 | } |
| 2761 | act.remove(); |
| 2762 | return; |
| 2763 | } |
| 2764 | if (r.title) name.textContent = r.title; |
| 2765 | note.textContent = r.note || ''; |
| 2766 | if (r.act && r.go) { |
| 2767 | act.disabled = false; |
| 2768 | act.textContent = r.act; |
| 2769 | act.onclick = r.go; |
| 2770 | } else act.remove(); |
| 2771 | }); |
| 2772 | return box; |
| 2773 | } |
| 2774 | |
| 2775 | /// Draw a message's references into `host`. What a message renderer calls. |
| 2776 | function drawRefs(host, list) { |
| 2777 | if (!host) return 0; |
| 2778 | host.innerHTML = ''; |
| 2779 | var refs = cleanRefs(list); |
| 2780 | refs.forEach(function (r) { host.appendChild(refChip(r)); }); |
| 2781 | return refs.length; |
| 2782 | } |
| 2783 | |
| 2784 | // ── The chips on the head ────────────────────────────────── |
| 2785 | // |
| 2786 | // The panel is Social. It holds five things -- Messages, People, Share, |
| 2787 | // Proposals, Settings. Note-capture MERGED INTO PROPOSALS: the standalone Notes |
| 2788 | // view is gone, its compose box now sits at the top of the Proposals view, and |
| 2789 | // Settings is the new fifth. It defaults to Proposals, which is where a person |
| 2790 | // both writes and reads. |
| 2791 | // |
| 2792 | // The count is deliberately not in the heading. A heading that names a number |
| 2793 | // goes stale the next time somebody adds a chip, and the panel is the only |
| 2794 | // honest count. |
| 2795 | // |
| 2796 | // The views are looked up by NAME rather than listed twice: a chip is a |
| 2797 | // `data-view` on the head and an element id in the table below, and a sixth |
| 2798 | // chip is one line here. |
| 2799 | |
| 2800 | var VIEWS = { |
| 2801 | messages: 'social-messages', |
| 2802 | people: 'social-people', |
| 2803 | // js/share.js renders into `#social-share-list` the way post.js renders |
| 2804 | // into the messages list; this file shows and hides it and nothing more. |
| 2805 | share: 'social-share', |
| 2806 | proposals: 'improve-props-view', |
| 2807 | // This file's own, drawn by `drawSettings`: the voice and the drafts. |
| 2808 | settings: 'social-settings', |
| 2809 | }; |
| 2810 | |
| 2811 | var _view = 'proposals'; |
| 2812 | |
| 2813 | /// Callbacks a lane registers to be told its own view was opened, so it can |
| 2814 | /// read what it needs LAZILY. Ten chips resolved on panel open is ten |
| 2815 | /// requests nobody asked for. |
| 2816 | var _watch = []; |
| 2817 | |
| 2818 | function show(view) { |
| 2819 | _view = VIEWS[view] ? view : 'proposals'; |
| 2820 | Object.keys(VIEWS).forEach(function (v) { |
| 2821 | var e = el(VIEWS[v]); |
| 2822 | if (e) e.hidden = (v !== _view); |
| 2823 | }); |
| 2824 | document.querySelectorAll('#panel-social .imp-chip').forEach(function (c) { |
| 2825 | var on = c.dataset.view === _view; |
| 2826 | c.classList.toggle('on', on); |
| 2827 | c.setAttribute('aria-pressed', on ? 'true' : 'false'); |
| 2828 | }); |
| 2829 | if (_view === 'proposals') { |
| 2830 | if (!_list.read && !_list.loading) loadList(false); |
| 2831 | else drawProps(); |
| 2832 | } else if (_view === 'settings') { |
| 2833 | drawSettings(); |
| 2834 | } else drawProps(); |
| 2835 | _watch.forEach(function (f) { try { f(_view); } catch (e) { /* one lane's fault is its own */ } }); |
| 2836 | } |
| 2837 | |
| 2838 | /// A lane says how many rows it drew in its own view. The honest line under |
| 2839 | /// the chip goes away exactly when there is something else to read there, and |
| 2840 | /// comes back when there is not — so an emptied list never leaves a blank. |
| 2841 | function filled(view, n) { |
| 2842 | var off = el('social-' + view + '-off'); |
| 2843 | if (off) off.hidden = !!(n | 0); |
| 2844 | } |
| 2845 | |
| 2846 | // ── Wiring ───────────────────────────────────────────────── |
| 2847 | |
| 2848 | /// The panel was opened. The listing is read again, because there is no change |
| 2849 | /// feed and looking IS how a tester finds out. |
| 2850 | function onOpen() { |
| 2851 | render(); |
| 2852 | // A declined proposal's note is read on open, the same "looking is how you |
| 2853 | // find out" the proposals list keeps. Quiet without a voice or an inbox. |
| 2854 | loadReturned(); |
| 2855 | if (_view === 'proposals') loadList(false); |
| 2856 | // Opening the panel is a good moment to drain anything that could not be |
| 2857 | // sent while it was shut, so a queue does not sit full when the network is |
| 2858 | // plainly back. The reconnect event is the main path; this is the belt. |
| 2859 | if (onLine()) { try { flushQueue(); } catch (e) { /* best effort */ } } |
| 2860 | _watch.forEach(function (f) { try { f(_view); } catch (e) { /* as above */ } }); |
| 2861 | } |
| 2862 | |
| 2863 | document.addEventListener('click', function (e) { |
| 2864 | var host = e.target && e.target.closest ? e.target.closest('#panel-social') : null; |
| 2865 | if (!host) return; |
| 2866 | var chip = e.target.closest('.imp-chip'); |
| 2867 | if (chip) { e.preventDefault(); show(chip.dataset.view); return; } |
| 2868 | var b = e.target.closest('[data-act]'); |
| 2869 | if (!b) return; |
| 2870 | var act = b.dataset.act; |
| 2871 | if (act === 'improve-post') { e.preventDefault(); submit('verbatim'); return; } |
| 2872 | if (act === 'improve-polish') { e.preventDefault(); submit('polish'); return; } |
| 2873 | if (act === 'improve-open-hub') { e.preventDefault(); openHub(); return; } |
| 2874 | if (act === 'improve-more') { e.preventDefault(); loadList(true); return; } |
| 2875 | if (act === 'improve-forget-plan') { |
| 2876 | e.preventDefault(); |
| 2877 | try { if (window.DaimondTriage) DaimondTriage.clear(); } catch (err) { /* no triage in this build */ } |
| 2878 | try { if (window.DaimondApproveList) DaimondApproveList.clear(); } catch (err) { /* no queue */ } |
| 2879 | return; |
| 2880 | } |
| 2881 | if (act === 'improve-voice-get') { e.preventDefault(); provision(false); return; } |
| 2882 | if (act === 'improve-voice-reissue'){ e.preventDefault(); reissueVoice(); return; } |
| 2883 | if (act === 'improve-voice-open') { e.preventDefault(); _voiceOpen = true; _voiceAlready = false; drawVoice(); return; } |
| 2884 | if (act === 'improve-voice-cancel') { e.preventDefault(); _voiceOpen = false; drawVoice(); return; } |
| 2885 | if (act === 'improve-voice-save') { e.preventDefault(); saveVoice(); return; } |
| 2886 | if (act === 'improve-voice-forget') { e.preventDefault(); forgetVoice(); return; } |
| 2887 | if (act === 'improve-with-off') { |
| 2888 | e.preventDefault(); |
| 2889 | var row = el('improve-with'); |
| 2890 | if (row) { row.dataset.off = '1'; row.hidden = true; } |
| 2891 | return; |
| 2892 | } |
| 2893 | if (act === 'improve-returned-dismiss') { |
| 2894 | e.preventDefault(); |
| 2895 | // The reader has SEEN this one: ack its own `when` key, never the whole |
| 2896 | // inbox. The forge answers the remainder, which redraws. |
| 2897 | ackReturned(Number(b.dataset.when)); |
| 2898 | return; |
| 2899 | } |
| 2900 | var noteEl = b.closest('.imp-note'); |
| 2901 | if (noteEl) { |
| 2902 | if (act === 'improve-copy') { e.preventDefault(); copy(noteEl.dataset.note); return; } |
| 2903 | if (act === 'improve-resend') { e.preventDefault(); resend(noteEl.dataset.note); return; } |
| 2904 | if (act === 'improve-drop') { e.preventDefault(); drop(noteEl.dataset.note); return; } |
| 2905 | } |
| 2906 | var propEl = b.closest('.imp-prop'); |
| 2907 | if (propEl) { |
| 2908 | var n = Number(propEl.dataset.prop); |
| 2909 | if (act === 'improve-open') { |
| 2910 | e.preventDefault(); |
| 2911 | var body = propEl.querySelector('.imp-prop-body'); |
| 2912 | var shut = !body || body.hidden; |
| 2913 | if (body) body.hidden = !shut; |
| 2914 | b.setAttribute('aria-expanded', shut ? 'true' : 'false'); |
| 2915 | if (shut) { |
| 2916 | _open[String(n)] = 1; |
| 2917 | // Opening it is what reads it: one proposal, one request, and |
| 2918 | // only for the one somebody asked to see. |
| 2919 | if (_by[n] && !_by[n].detail) loadOne(n); |
| 2920 | } else delete _open[String(n)]; |
| 2921 | return; |
| 2922 | } |
| 2923 | if (act === 'improve-vote') { e.preventDefault(); vote(n, b.dataset.dir); return; } |
| 2924 | if (act === 'improve-comment') { e.preventDefault(); comment(n); return; } |
| 2925 | if (act === 'improve-amend-open') { e.preventDefault(); _amending[String(n)] = 1; drawProps(); return; } |
| 2926 | if (act === 'improve-amend-cancel') { |
| 2927 | e.preventDefault(); |
| 2928 | delete _amending[String(n)]; |
| 2929 | // Cancelling is a decision not to revise, so the revision goes with it. |
| 2930 | // A draft that survived Cancel would reappear the next time the control |
| 2931 | // was opened, which is the app arguing with a person who said no. |
| 2932 | try { if (drafts()) drafts().dropUnder(draftKey('amend', n)); } catch (err) { /* storage blocked */ } |
| 2933 | drawProps(); |
| 2934 | return; |
| 2935 | } |
| 2936 | if (act === 'improve-amend-save') { e.preventDefault(); amend(n); return; } |
| 2937 | } |
| 2938 | }); |
| 2939 | |
| 2940 | // The row that says what goes with a note is a PREVIEW of the line, kept in |
| 2941 | // step while somebody is looking at it. What travels is redrawn and read at |
| 2942 | // the press, in `outgoing()`, so being late here costs a stale preview and |
| 2943 | // never a stale note. |
| 2944 | // |
| 2945 | // `daimond:layout` and `daimond:theme` used to be in this list and NOTHING IN |
| 2946 | // THE TREE HAS EVER DISPATCHED EITHER -- `setTheme` in daimond.js writes the |
| 2947 | // palette and says nothing, and the layout engine's `apply()` runs on every |
| 2948 | // panel open with no announcement. So the two facts most likely to move under |
| 2949 | // an open panel were the two this row never heard about, which is the defect |
| 2950 | // the redraw in `outgoing()` closes. |
| 2951 | try { window.addEventListener('resize', function () { if (!contextOff()) drawContext(); }); } |
| 2952 | catch (e) { /* no window */ } |
| 2953 | |
| 2954 | // And on the way to the button. A hand coming back to this panel to press Send |
| 2955 | // passes through it, so the last state of the preview a person can read is the |
| 2956 | // state that was in force when they pressed. |
| 2957 | ['pointerdown', 'focusin'].forEach(function (ev) { |
| 2958 | try { |
| 2959 | document.addEventListener(ev, function (e) { |
| 2960 | var t = e.target; |
| 2961 | if (!t || typeof t.closest !== 'function' || !t.closest('#panel-social')) return; |
| 2962 | if (!contextOff()) drawContext(); |
| 2963 | }, true); |
| 2964 | } catch (err) { /* no document */ } |
| 2965 | }); |
| 2966 | |
| 2967 | // Writing a fresh note takes the "Raised" confirmation down, so it never sits |
| 2968 | // stale over a compose that has moved on. |
| 2969 | try { |
| 2970 | document.addEventListener('input', function (e) { |
| 2971 | if (e.target && e.target.id === 'improve-box') hideRaised(); |
| 2972 | }, true); |
| 2973 | } catch (e) { /* no document */ } |
| 2974 | |
| 2975 | // Another tab wrote a note, or an account switch emptied the store. |
| 2976 | window.addEventListener('storage', function (e) { |
| 2977 | if (e.key !== KEY && !(e.key && e.key.indexOf(KEY) !== -1)) return; |
| 2978 | _st = null; |
| 2979 | render(); |
| 2980 | }); |
| 2981 | |
| 2982 | // Say the panel's own words again in a new language. Every string on a row is |
| 2983 | // built here rather than marked up, so a language change reaches none of them |
| 2984 | // unless this surface is registered. |
| 2985 | try { |
| 2986 | DaimondI18n.surface(function () { return document.getElementById('panel-social'); }, |
| 2987 | function () { render(); }); |
| 2988 | } catch (e) { /* no i18n in this build */ } |
| 2989 | |
| 2990 | function start() { |
| 2991 | if (!el('panel-social')) return; // this build has no Improve panel |
| 2992 | readBuild().then(function () { render(); }, function () { render(); }); |
| 2993 | show('notes'); |
| 2994 | render(); |
| 2995 | } |
| 2996 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); |
| 2997 | else start(); |
| 2998 | |
| 2999 | /// The panel SHELL, which is what the app and the other lanes talk to. |
| 3000 | /// |
| 3001 | /// Separate from `DaimondImprove` below on purpose: the Social panel holds |
| 3002 | /// four things and this file owns two of them. A lane rendering into |
| 3003 | /// `#social-messages-list` or `#social-people-list` needs the chips, the |
| 3004 | /// view switch and the empty line, and has no business with a note or a |
| 3005 | /// proposal. |
| 3006 | window.DaimondSocial = { |
| 3007 | /// The panel was shown. Every view is told, so a lane can read lazily. |
| 3008 | onOpen: onOpen, |
| 3009 | /// Switch to one of `messages`, `people`, `notes`, `proposals`. |
| 3010 | show: show, |
| 3011 | /// Show the panel AND switch to a view. What a reference chip presses. |
| 3012 | open: function (view) { |
| 3013 | try { if (window.DaimondPanels) DaimondPanels.show('social'); } catch (e) { /* no engine */ } |
| 3014 | show(view); |
| 3015 | }, |
| 3016 | /// Which view is showing. |
| 3017 | view: function () { return _view; }, |
| 3018 | /// A lane drew `n` rows in `view`; the honest empty line follows. |
| 3019 | filled: filled, |
| 3020 | /// Be told when a view is opened, by name. Called on every switch and on |
| 3021 | /// every panel open, so a lane refreshes when somebody looks. |
| 3022 | watch: function (fn) { if (typeof fn === 'function') _watch.push(fn); }, |
| 3023 | /// THE DAIMON'S DOOR ONTO THIS PANEL, which the engine binds to by this |
| 3024 | /// name (`src/wasm/social.rs`). Three methods and the split between them |
| 3025 | /// is the point; the section above `socialRead` says why. |
| 3026 | /// |
| 3027 | /// ON THE PANEL'S OWN OBJECT and not on a second global beside it. The |
| 3028 | /// first draft of this lane installed `window.DaimondSocial` afresh and |
| 3029 | /// clobbered everything above -- `Panels.show('social')` calls `onOpen()` |
| 3030 | /// with no guard on the method, so the panel threw on its first open. A |
| 3031 | /// surface has one object; what a model may do with it is part of it. |
| 3032 | read: socialRead, |
| 3033 | compose: socialCompose, |
| 3034 | commit: socialCommit, |
| 3035 | /// What is composed and not yet sent, for a verifier. A token nobody has |
| 3036 | /// approved is a publication that has not happened. |
| 3037 | drafts: function () { return Object.keys(_drafts); }, |
| 3038 | }; |
| 3039 | |
| 3040 | /// References, for whatever renders a message. Kept here rather than in the |
| 3041 | /// module that draws messages, because a proposal reference resolves through |
| 3042 | /// THIS file's `route()`, `ask()` and `saying()` — the nine refusal wordings |
| 3043 | /// exist once, and a second copy of them is a second copy to get wrong. |
| 3044 | window.DaimondRefs = { |
| 3045 | /// Draw the references a message carries into `host`; answers how many |
| 3046 | /// were drawn, which is at most four. |
| 3047 | draw: drawRefs, |
| 3048 | /// One chip, for a caller placing them itself. |
| 3049 | chip: refChip, |
| 3050 | /// What a wire reference reduces to, for a verifier and for a sender |
| 3051 | /// that wants to know what will survive. Everything else is dropped. |
| 3052 | clean: cleanRefs, |
| 3053 | /// Resolve one, for a verifier. Cached exactly as the chip's own press is. |
| 3054 | resolve: function (r) { |
| 3055 | var c = cleanRef(r); |
| 3056 | return c ? resolve(c) : Promise.resolve({ ok: false, why: '' }); |
| 3057 | }, |
| 3058 | /// Forget what has been resolved, for a test that wants a cold cache. |
| 3059 | forget: function () { _refs = {}; }, |
| 3060 | }; |
| 3061 | |
| 3062 | // ── The daimon's door onto this panel ────────────────────── |
| 3063 | // |
| 3064 | // WHY THIS EXISTS, WHICH IS NOT WHAT IT DOES. On 2026-08-24 two real daimons, |
| 3065 | // on two accounts and two different models, were each asked to do one side of |
| 3066 | // this panel's work -- one to report a defect in Daimond, one to find that |
| 3067 | // report and agree with it. Neither could reach the panel, and neither said |
| 3068 | // so. One told its user to go and open "Daimond's feedback/issue reporting |
| 3069 | // interface (typically accessible from a menu in the app)", which is this |
| 3070 | // panel, and which it had just failed to find. The other spent eighteen calls |
| 3071 | // searching the workspace for "where the Social panel stores its reports" and |
| 3072 | // finished by telling the user "The gateway isn't running." The gateway was |
| 3073 | // running. |
| 3074 | // |
| 3075 | // A working surface the model cannot reach is a surface the model will deny. |
| 3076 | // That rule is `Tool::FileShow`'s and it is written out in full in |
| 3077 | // `src/tools.rs`; this file is its second instance. |
| 3078 | // |
| 3079 | // THREE METHODS AND THE SPLIT BETWEEN THEM IS THE POINT. |
| 3080 | // |
| 3081 | // read answers what is on a view, in prose. Nobody is asked anything: |
| 3082 | // the owner ruled on 2026-08-24 that seeing is immediate, because |
| 3083 | // it is information and because a daimon that can see the panel |
| 3084 | // stops denying the panel is there. |
| 3085 | // compose works out exactly what one act would put on the wire, and mints |
| 3086 | // a token standing for those bytes. NOTHING IS SENT. |
| 3087 | // commit sends what a token holds, and only what it holds. |
| 3088 | // |
| 3089 | // COMPOSE AND COMMIT ARE TWO CALLS SO THAT CONSENT IS BOUND TO BYTES. The |
| 3090 | // user is shown what `compose` composed and answers about that; `commit` |
| 3091 | // sends that same payload. One call taking the model's arguments and asking |
| 3092 | // on the way past would mean the person approved a rendering and the app sent |
| 3093 | // a rebuild of it -- and the two part company at exactly the field somebody |
| 3094 | // would have wanted to see, the build identifier that travels with a note or |
| 3095 | // the title of the proposal a vote lands on. |
| 3096 | // |
| 3097 | // A TOKEN IS SPENT ONCE. Left spendable, a yes about one publication would be |
| 3098 | // a licence to publish it again, which is the per-host memory mistake this |
| 3099 | // panel must not repeat: what is being approved here is a payload, not a |
| 3100 | // destination. |
| 3101 | // |
| 3102 | // WHAT THIS FILE ANSWERS AND WHAT RUST ANSWERS. Every sentence about a |
| 3103 | // RECORD is composed here -- which proposals exist, their tallies, their |
| 3104 | // states, what a message says -- because this is where the record lives and |
| 3105 | // because a second renderer in Rust would disagree with the screen the user |
| 3106 | // is looking at the first time either changed. Every sentence that DECIDES |
| 3107 | // something is composed in Rust: the refusals about arguments, the refusal a |
| 3108 | // dispatched worker gets, and the question put to the user. |
| 3109 | |
| 3110 | var _drafts = Object.create(null); // tokens minted by compose(), spent by commit() |
| 3111 | var _draftN = 0; |
| 3112 | var DRAFT_LIFE = 300000; // five minutes for a person to read and answer |
| 3113 | |
| 3114 | /// Forget drafts nobody answered. |
| 3115 | /// |
| 3116 | /// A DECLINED DRAFT IS NEVER TOLD SO. The engine discards the token when the |
| 3117 | /// user says no and there is no message back to here, so without this a |
| 3118 | /// refused publication would sit in memory with a live handle on it for as |
| 3119 | /// long as the tab is open. Nothing can reach one -- the engine composes a |
| 3120 | /// fresh draft each time -- but "nothing can reach it" is an argument about |
| 3121 | /// today's callers, and the payload is a public post in somebody's name. |
| 3122 | function sweepDrafts() { |
| 3123 | var cut = Date.now() - DRAFT_LIFE; |
| 3124 | Object.keys(_drafts).forEach(function (k) { |
| 3125 | if (!_drafts[k] || _drafts[k].at < cut) delete _drafts[k]; |
| 3126 | }); |
| 3127 | } |
| 3128 | |
| 3129 | /// The request, as the engine wrote it. |
| 3130 | function req(json) { |
| 3131 | try { return JSON.parse(String(json || '{}')) || {}; } |
| 3132 | catch (e) { return {}; } |
| 3133 | } |
| 3134 | |
| 3135 | /// A refusal a model reads and acts on. The opening word is what the fold's |
| 3136 | /// ledger reads (see `call_outcome` in src/tools.rs), so a refusal that did |
| 3137 | /// not open with it would be booked as work that was done. |
| 3138 | function no(why) { return 'Refused: ' + why; } |
| 3139 | |
| 3140 | /// Why the forge would not, said for a model rather than for the screen. |
| 3141 | /// |
| 3142 | /// The nine tokens are the contract's and are stable; `saying()` beside this |
| 3143 | /// is prose for a person, translated eight ways and reworded whenever it |
| 3144 | /// reads badly. A model branching on that would branch on a translation. |
| 3145 | function whyNot(a) { |
| 3146 | var w = (a && a.why) || 'gateway'; |
| 3147 | if (w === 'unvoiced') { |
| 3148 | return 'this account has no voice on the forge, so it cannot write there. ' |
| 3149 | + 'Tell the user: the Social panel has a control for setting one.'; |
| 3150 | } |
| 3151 | if (w === 'unpermitted') { |
| 3152 | if (a && a.on === 'amend') { |
| 3153 | return 'only the person who opened that proposal may revise it, and this ' |
| 3154 | + 'account did not open it. The forge wrote nothing.'; |
| 3155 | } |
| 3156 | return 'this account\'s voice is not allowed to do that on the forge.'; |
| 3157 | } |
| 3158 | if (w === 'throttled') { |
| 3159 | return 'the forge is rate-limiting this account' |
| 3160 | + (a.because ? ' (' + a.because + ')' : '') + '. Wait rather than retrying now.'; |
| 3161 | } |
| 3162 | if (w === 'no_proposal' || w === 'absent') { |
| 3163 | return 'the forge has no such proposal. Read the proposals again -- the number may ' |
| 3164 | + 'have been wrong.'; |
| 3165 | } |
| 3166 | if (w === 'offline') return 'the request never reached the forge.'; |
| 3167 | if (w === 'gateway') return 'Daimond\'s gateway would not carry it' |
| 3168 | + (a && a.status ? ' (' + a.status + ')' : '') + '.'; |
| 3169 | return 'the forge answered \'' + w + '\'.'; |
| 3170 | } |
| 3171 | |
| 3172 | /// One proposal as a model should read it: the number first, because that is |
| 3173 | /// what every later call is aimed with. |
| 3174 | function sayProp(p, full) { |
| 3175 | var out = '#' + p.n + ' ' + (p.title || '(no title)') |
| 3176 | + ' [' + p.state + ']'; |
| 3177 | if (p.votes) { |
| 3178 | out += ' ' + p.votes.for + ' for, ' + p.votes.against + ' against'; |
| 3179 | if (p.asked && p.mine === 1) out += ' (this account voted for it)'; |
| 3180 | if (p.asked && p.mine === -1) out += ' (this account voted against it)'; |
| 3181 | } |
| 3182 | if (p.author) out += ' by ' + p.author; |
| 3183 | if (p.comments) out += ' ' + p.comments + ' comment' + (p.comments === 1 ? '' : 's'); |
| 3184 | if (full && p.body) out += '\n' + p.body; |
| 3185 | if (full && p.discussion && p.discussion.length) { |
| 3186 | out += '\n--- discussion ---'; |
| 3187 | p.discussion.forEach(function (d) { |
| 3188 | out += '\n' + (d.author || 'somebody') + ': ' + d.said; |
| 3189 | }); |
| 3190 | } |
| 3191 | return out; |
| 3192 | } |
| 3193 | |
| 3194 | /// Read one view of the panel. |
| 3195 | /// |
| 3196 | /// THE PANEL IS DRIVEN AND THEN READ, rather than a second request being made |
| 3197 | /// beside it. What the model is told is therefore what is on the user's |
| 3198 | /// screen -- which is the whole point of a daimon being able to see this at |
| 3199 | /// all, and it is also why a listing here can never drift from the listing |
| 3200 | /// somebody is looking at. |
| 3201 | async function socialRead(reqJson) { |
| 3202 | var r = req(reqJson); |
| 3203 | var view = String(r.view || 'proposals'); |
| 3204 | var limit = Math.max(1, Math.min(50, r.limit | 0 || 12)); |
| 3205 | if (view === 'proposals') { |
| 3206 | var ok = await loadList(false); |
| 3207 | if (!ok) return no('nothing was read: ' + whyNot(_list.err)); |
| 3208 | // A PAGE IS NOT A LISTING, and the difference only shows on a busy |
| 3209 | // repository. `loadList(false)` fetches PAGE records and the panel |
| 3210 | // offers a button for the rest; a tool call has no button, so a |
| 3211 | // daimon asking for 50 was answered with 25 and never saw the older |
| 3212 | // ones at all -- while this tool's own description tells it to read |
| 3213 | // the proposals first so it does not open a second one about |
| 3214 | // something already there. It could not. |
| 3215 | // |
| 3216 | // The walk is the PANEL'S walk, called again rather than written |
| 3217 | // again: every guard on it -- never `from=0`, stop on a short page, |
| 3218 | // stop on a page that did not descend -- is why a client of this |
| 3219 | // contract does not loop for ever, and a second walk here would be a |
| 3220 | // second set of them to keep right. Bounded by the limit, which the |
| 3221 | // schema caps at 50, so it is at most two more requests. |
| 3222 | var steps = 0; |
| 3223 | while (_order.length < limit && !_list.done && steps++ < 8) { |
| 3224 | if (!(await loadList(true))) break; |
| 3225 | } |
| 3226 | var rows = _order.slice(0, limit).map(function (n) { return sayProp(_by[n], false); }); |
| 3227 | if (!rows.length) { |
| 3228 | return 'Nobody has proposed anything about Daimond yet. Yours would be the ' |
| 3229 | + 'first: social_send with act "propose".'; |
| 3230 | } |
| 3231 | return 'What people have reported or asked for about Daimond, newest first ' |
| 3232 | + '(' + _list.total + ' in all, ' + rows.length + ' shown). Read one in full ' |
| 3233 | + 'with view "proposal" and its number; back one with social_send.\n\n' |
| 3234 | + rows.join('\n'); |
| 3235 | } |
| 3236 | if (view === 'proposal') { |
| 3237 | var n = r.n | 0; |
| 3238 | var got = await loadOne(n); |
| 3239 | if (!got) return no('nothing was read: ' + whyNot(_list.err)); |
| 3240 | var p = _by[n]; |
| 3241 | if (!p) return no('the forge answered about no proposal numbered ' + n + '.'); |
| 3242 | return sayProp(p, true); |
| 3243 | } |
| 3244 | if (view === 'notes') { |
| 3245 | var notes = load().notes.slice(0, limit); |
| 3246 | if (!notes.length) { |
| 3247 | return 'This device has no notes waiting to send.'; |
| 3248 | } |
| 3249 | return 'Notes waiting to send on this device (' + notes.length + '):\n\n' |
| 3250 | + notes.map(function (rec) { |
| 3251 | return '[' + (rec.mode === 'polish' ? 'to polish & post' : 'to post') + '] ' + rec.text; |
| 3252 | }).join('\n\n'); |
| 3253 | } |
| 3254 | if (view === 'messages') { |
| 3255 | if (!window.DaimondPost) return no('this build has no messaging.'); |
| 3256 | var msgs = (DaimondPost.list() || []).slice(0, limit); |
| 3257 | var tray = (DaimondPost.tray() || []).length; |
| 3258 | if (!msgs.length) { |
| 3259 | return 'This account\'s message list is empty.' |
| 3260 | + (tray ? ' ' + tray + ' are waiting to be accepted, which only the user can do.' : ''); |
| 3261 | } |
| 3262 | return 'Messages on this account (' + msgs.length + ' shown' |
| 3263 | + (tray ? ', ' + tray + ' more waiting to be accepted' : '') + '):\n\n' |
| 3264 | + msgs.map(function (m) { |
| 3265 | return (m.dir === 'out' ? 'to ' : 'from ') |
| 3266 | + (m.dir === 'out' ? (m.to || m.gid || '?') : (m.from || '?')) |
| 3267 | + ': ' + String(m.body || '').slice(0, 400); |
| 3268 | }).join('\n'); |
| 3269 | } |
| 3270 | if (view === 'people') { |
| 3271 | if (!window.DaimondPost) return no('this build has no messaging.'); |
| 3272 | var who = (DaimondPost.people() || []).slice(0, limit); |
| 3273 | if (!who.length) { |
| 3274 | return 'Nobody is in this account\'s directory yet, so there is nobody to write to.'; |
| 3275 | } |
| 3276 | return 'People this account can reach (' + who.length + '):\n\n' |
| 3277 | + who.map(function (p) { return (p.label || '(unnamed)') + ' [' + p.state + ']'; }).join('\n'); |
| 3278 | } |
| 3279 | return no('\'' + view + '\' is not one of this panel\'s views.'); |
| 3280 | } |
| 3281 | |
| 3282 | /// Work out what one act would publish, and mint a token standing for it. |
| 3283 | /// |
| 3284 | /// The characters in `shown` are what the user is asked about, so everything |
| 3285 | /// that would travel is in them -- including the sealed build identifier, |
| 3286 | /// which the user's own box carries and which a person approving a report in |
| 3287 | /// their name is entitled to see before it goes. |
| 3288 | async function socialCompose(reqJson) { |
| 3289 | var r = req(reqJson); |
| 3290 | var act = String(r.act || ''); |
| 3291 | if (!hasVoice()) { |
| 3292 | return JSON.stringify({ refusal: no('nothing was composed: this account has no voice ' |
| 3293 | + 'on the forge, so it cannot publish there. Tell the user, and say what you ' |
| 3294 | + 'wanted to publish -- the Social panel has a control for setting a voice.') }); |
| 3295 | } |
| 3296 | var shown = '', payload = null; |
| 3297 | if (act === 'propose') { |
| 3298 | // The build identifier travels with a note the user sends, so it travels |
| 3299 | // with this one -- and it is therefore SHOWN. Consent to a report that |
| 3300 | // silently also names the build would be consent to something the person |
| 3301 | // did not read. |
| 3302 | var build = contextOff() ? '' : _build; |
| 3303 | payload = { act: 'propose', title: String(r.title || ''), body: String(r.body || ''), build: build }; |
| 3304 | shown = 'A NEW PROPOSAL at ' + FORGE_HOST + ', under this account\'s voice name.\n\n' |
| 3305 | + payload.title + '\n' + payload.body |
| 3306 | + (build ? '\n\nand the build identifier ' + build : ''); |
| 3307 | } else if (act === 'vote') { |
| 3308 | var n = r.n | 0; |
| 3309 | var p = _by[n]; |
| 3310 | if (!p) { |
| 3311 | var got = await loadOne(n); |
| 3312 | if (!got) return JSON.stringify({ refusal: no('nothing was composed: ' + whyNot(_list.err)) }); |
| 3313 | p = _by[n]; |
| 3314 | } |
| 3315 | if (!p) return JSON.stringify({ refusal: no('there is no proposal numbered ' + n + '.') }); |
| 3316 | var d = (r.d | 0); |
| 3317 | payload = { act: 'vote', n: n, d: d }; |
| 3318 | shown = (d === 1 ? 'A VOTE FOR' : d === -1 ? 'A VOTE AGAINST' : 'TAKING BACK THE VOTE ON') |
| 3319 | + ' proposal #' + n + ' at ' + FORGE_HOST + ', under this account\'s voice name.\n\n' |
| 3320 | + (p.title || '(no title)') |
| 3321 | + (p.votes ? '\n\nIt stands at ' + p.votes.for + ' for and ' + p.votes.against + ' against.' : ''); |
| 3322 | } else if (act === 'comment') { |
| 3323 | var cn = r.n | 0; |
| 3324 | payload = { act: 'comment', n: cn, said: String(r.said || '') }; |
| 3325 | shown = 'A COMMENT on proposal #' + cn + ' at ' + FORGE_HOST |
| 3326 | + ', under this account\'s voice name.\n\n' + payload.said; |
| 3327 | } else { |
| 3328 | return JSON.stringify({ refusal: no('\'' + act + '\' is not an act this panel has.') }); |
| 3329 | } |
| 3330 | var token = 'd' + (++_draftN) + '-' + Math.random().toString(36).slice(2, 10); |
| 3331 | _drafts[token] = { at: Date.now(), payload: payload }; |
| 3332 | sweepDrafts(); |
| 3333 | return JSON.stringify({ shown: shown, token: token }); |
| 3334 | } |
| 3335 | |
| 3336 | /// Publish what a token holds. The token is spent whatever happens: a yes was |
| 3337 | /// a yes to ONE publication, and a failed send does not license a second |
| 3338 | /// attempt nobody was asked about. |
| 3339 | async function socialCommit(token) { |
| 3340 | sweepDrafts(); |
| 3341 | var held = _drafts[String(token || '')]; |
| 3342 | delete _drafts[String(token || '')]; |
| 3343 | var d = held && held.payload; |
| 3344 | if (!d) { |
| 3345 | return no('nothing was published: that draft is not one this panel composed, or it ' |
| 3346 | + 'has already been sent. Compose it again, and the user will be asked again.'); |
| 3347 | } |
| 3348 | if (d.act === 'propose') { |
| 3349 | var text = d.body ? (d.title + '\n' + d.body) : d.title; |
| 3350 | var rec = store(text, 'verbatim'); |
| 3351 | render(); |
| 3352 | var a = await through(rec, { title: d.title, body: d.body, build: d.build }); |
| 3353 | render(); |
| 3354 | if (!a.ok) return no('nothing was published: ' + whyNot(a) |
| 3355 | + ' The note is kept on this device and nothing was retried.'); |
| 3356 | return 'Published as proposal #' + rec.n + ' on the Daimond forge. It is on the ' |
| 3357 | + 'user\'s Social panel now, and other people can read and vote on it.'; |
| 3358 | } |
| 3359 | if (d.act === 'vote') { |
| 3360 | var body = voteBody(d.d); |
| 3361 | if (!body) return no('nothing was published: ' + d.d + ' is not a vote.'); |
| 3362 | var av = await ask(route('n=' + d.n + '&vote=1'), { |
| 3363 | method: 'POST', |
| 3364 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 3365 | body: body, |
| 3366 | }); |
| 3367 | if (!av.ok) { _list.err = av; drawProps(); return no('nothing was published: ' + whyNot(av)); } |
| 3368 | var pv = absorb(cleanProp(av.data)); |
| 3369 | _list.err = null; |
| 3370 | drawProps(); |
| 3371 | return 'The vote is cast on proposal #' + d.n + '. It now stands at ' |
| 3372 | + ((pv && pv.votes) ? (pv.votes.for + ' for and ' + pv.votes.against + ' against') |
| 3373 | : 'whatever the forge reports') + '.'; |
| 3374 | } |
| 3375 | if (d.act === 'comment') { |
| 3376 | var f = new URLSearchParams(); |
| 3377 | f.set('said', d.said); |
| 3378 | var ac = await ask(route('n=' + d.n), { |
| 3379 | method: 'POST', |
| 3380 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 3381 | body: f.toString(), |
| 3382 | }); |
| 3383 | if (!ac.ok) { _list.err = ac; drawProps(); return no('nothing was published: ' + whyNot(ac)); } |
| 3384 | absorb(cleanProp(ac.data)); |
| 3385 | _list.err = null; |
| 3386 | drawProps(); |
| 3387 | return 'The comment is on proposal #' + d.n + ', where everybody reading it can see it.'; |
| 3388 | } |
| 3389 | return no('nothing was published: that draft names no act.'); |
| 3390 | } |
| 3391 | |
| 3392 | window.DaimondImprove = { |
| 3393 | onOpen: onOpen, |
| 3394 | render: render, |
| 3395 | show: show, |
| 3396 | /// The two compose verbs, and the queue. Published so a verifier drives the |
| 3397 | /// same path a person does rather than a second one written for it. |
| 3398 | /// `submit('verbatim')` posts the words as they are; `submit('polish')` has |
| 3399 | /// the model rewrite them first. Both queue then try to send. |
| 3400 | submit: submit, |
| 3401 | resend: resend, |
| 3402 | drop: drop, |
| 3403 | /// Drain the queue: send every waiting note in its own mode. Called by |
| 3404 | /// js/daimond.js on the browser's `online` event, and on panel open. |
| 3405 | flushQueue: flushQueue, |
| 3406 | /// The exact characters a Send would put on the wire right now, and the |
| 3407 | /// cut that turns them into a proposal. The verifier compares both against |
| 3408 | /// what actually left. |
| 3409 | outgoing: outgoing, |
| 3410 | split: split, |
| 3411 | /// Reading the forge, and the walk downwards through it. |
| 3412 | load: loadList, |
| 3413 | one: loadOne, |
| 3414 | /// The proposer's returned-notes inbox: read the notes a decline left, ack |
| 3415 | /// the ones seen (per `when` key, form-encoded), and the record for a test. |
| 3416 | loadReturned: loadReturned, |
| 3417 | ackReturned: ackReturned, |
| 3418 | returned: function () { return _returned.notes.slice(); }, |
| 3419 | /// Voting and saying something. Both go straight to the forge; neither is |
| 3420 | /// kept here. |
| 3421 | vote: vote, |
| 3422 | comment: comment, |
| 3423 | voteBody: voteBody, |
| 3424 | /// Is a pull voice held on this device? The Improve hub (js/tracker.js) asks |
| 3425 | /// before it offers a vote or comment control, so it shows the "set a voice" |
| 3426 | /// affordance rather than a button that the forge would refuse. Presence only. |
| 3427 | hasVoice: hasVoice, |
| 3428 | /// Get this device a pull voice, on a press: the same one-tap flow the |
| 3429 | /// Settings view draws. Published so the hub can send an unvoiced reader here |
| 3430 | /// rather than growing its own copy of the provisioning path. |
| 3431 | provision: function () { return provision(false); }, |
| 3432 | /// Revising one's own proposal: the PANEL ACTION, which reads the two boxes |
| 3433 | /// under the open row. `forge.amend` below is the door it sends through. |
| 3434 | amend: amend, |
| 3435 | /// What is drawn, for a verifier that wants the record rather than the |
| 3436 | /// pixels. |
| 3437 | proposal: function (n) { return _by[n] ? JSON.parse(JSON.stringify(_by[n])) : null; }, |
| 3438 | listing: function () { return { total: _list.total, shown: _order.slice(), done: _list.done, err: _list.err ? _list.err.why : '' }; }, |
| 3439 | /// The store, for a verifier and for an account switch. |
| 3440 | notes: function () { return load().notes.slice(); }, |
| 3441 | /// Whether the forge holds this note's own characters, which is the one |
| 3442 | /// question the cap in `save()` turns on. Published so a verifier asks it |
| 3443 | /// rather than inferring it from which note survived. |
| 3444 | delivered: delivered, |
| 3445 | /// Mark notes as folded into a proposal a draft was written from. What |
| 3446 | /// js/triage.js calls after the forge took a draft, and nothing else. |
| 3447 | fold: fold, |
| 3448 | /// The proposal numbers this DEVICE raised, de-duped. The Improve hub's |
| 3449 | /// (js/tracker.js) "Mine" filter reads this: the local voice has no name, so |
| 3450 | /// "raised here" is matched by number, never by author. |
| 3451 | raisedProposalNumbers: raisedProposalNumbers, |
| 3452 | /// THE FORGE, AS THIS PANEL REACHES IT, for the module that drafts from the |
| 3453 | /// whole list of notes at once. |
| 3454 | /// |
| 3455 | /// One door and one copy of it. `route()`, `ask()` and `saying()` live |
| 3456 | /// here; the nine refusal wordings exist once, and a second module holding |
| 3457 | /// its own copy of them is a second copy to get wrong -- which is the same |
| 3458 | /// argument `DaimondRefs` is kept in this file by. Every function here |
| 3459 | /// takes CHARACTERS and returns the panel's own `{ ok, data }` or |
| 3460 | /// `{ ok: false, why }`. |
| 3461 | forge: { |
| 3462 | /// Open a proposal from a draft's own characters, already cut in two. |
| 3463 | open: post, |
| 3464 | /// Say something on one proposal. |
| 3465 | say: say, |
| 3466 | /// Vote on one proposal: `1` for, `-1` against, `0` withdraws. The door |
| 3467 | /// the Improve hub's card sends through, so the POST and the pull voice it |
| 3468 | /// carries live in one place and the two surfaces cannot drift apart about |
| 3469 | /// what a vote is. Answers with the record it changed, tally and `mine` and |
| 3470 | /// all -- the caller absorbs it into its own store. |
| 3471 | vote: voteWire, |
| 3472 | /// Revise one proposal, from characters somebody else read. The door |
| 3473 | /// `amend` sends through as well, so the panel and the triage plan |
| 3474 | /// cannot drift apart about what a revision is. |
| 3475 | amend: revise, |
| 3476 | /// What a refusal says on the screen, in the reader's language. |
| 3477 | saying: saying, |
| 3478 | /// The proposals as they are drawn, newest first. A copy, so nothing |
| 3479 | /// outside this file can move the record the panel is showing. |
| 3480 | props: function () { return _order.map(function (n) { return _by[n] ? JSON.parse(JSON.stringify(_by[n])) : null; }).filter(Boolean); }, |
| 3481 | /// Read the listing, and walk downwards through it. |
| 3482 | list: loadList, |
| 3483 | /// Whether the forge has said this asker may revise proposal `n`. |
| 3484 | /// ABSENT is not false: a proposal nobody asked about answers `false` |
| 3485 | /// here and `false` from `askedAmend`, and the control is drawn on |
| 3486 | /// neither. |
| 3487 | mayAmend: function (n) { return canAmend(_by[n]); }, |
| 3488 | /// Fold a forge answer into the panel's proposal store, and hand back the |
| 3489 | /// record it landed as. |
| 3490 | /// |
| 3491 | /// A door PUTS a write on the wire and answers with the DETAIL SHAPE of the |
| 3492 | /// record it changed; the panel's own send, comment and vote each absorb |
| 3493 | /// that answer, so a DOOR CALLER must too. The approve-list's batch did not, |
| 3494 | /// and so a proposal it opened never entered `_by`/`_order` -- the Proposals |
| 3495 | /// view silently omitted every one the queue sent, which is why a run of |
| 3496 | /// eight drafts showed only the one proposal some other path had absorbed. |
| 3497 | /// Defended by `cleanProp`, so an answer shaped in a way this build does not |
| 3498 | /// know is dropped rather than drawn. |
| 3499 | absorb: function (data) { return absorb(cleanProp(data)); }, |
| 3500 | }, |
| 3501 | // `_amending` with the rest: a row left in amend mode across an account |
| 3502 | // switch would offer somebody else's proposal with this account's boxes |
| 3503 | // already open on it. |
| 3504 | reset: function () { _st = null; _by = {}; _order = []; _open = {}; _amending = {}; _returned = { notes: [], read: false, loading: false }; _list = { total: 0, lowest: null, done: false, loading: false, err: null, read: false }; }, |
| 3505 | }; |
| 3506 | })(); |