oxedyne/daimond/www/js/drafts.js
9.0 KiB, 1 run
created by r2519314175:1363, 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 — what somebody is half-way through typing (drafts.js) |
| 3 | ------------------------------------------------------------ |
| 4 | A screen refresh used to empty every box in the app. A note |
| 5 | part-written in the Social panel, a reply part-written on a |
| 6 | proposal, a message part-typed to a daimon: reload, and the |
| 7 | words were gone with nothing anywhere reporting that anything |
| 8 | had been lost. Reported by the owner in those terms -- "I would |
| 9 | expect all live text input to persist" -- and he is right: a |
| 10 | text box is the one place in an app where the user, not the |
| 11 | app, is holding the only copy. |
| 12 | |
| 13 | So every live box in the app registers here, and this keeps |
| 14 | what is in it on this device until it is sent or cleared. |
| 15 | |
| 16 | ── A DRAFT IS NOT A SEND QUEUE, AND THIS FILE IS NOT ONE ─── |
| 17 | |
| 18 | The next person to read this will think it breaks the Social |
| 19 | panel's central promise. It does not, and the difference is |
| 20 | worth stating rather than leaving to be re-derived. |
| 21 | |
| 22 | `dev/IMPROVE_CONTRACT.md` §4: "A note leaves this device only |
| 23 | when a person presses Send on that one note, and what leaves is |
| 24 | exactly the characters that are on the screen at that moment. |
| 25 | Nothing about a note is queued, retried, batched, synced or |
| 26 | kept for later sending." |
| 27 | |
| 28 | Every clause of that survives, because what is forbidden is |
| 29 | SENDING WITHOUT A PRESS and this file has no sender in it. It |
| 30 | holds no network code, imports none, and is reachable by |
| 31 | nothing that does. A draft here is not "waiting to go" -- it is |
| 32 | waiting to be LOOKED AT, by the person who typed it, on the |
| 33 | device they typed it on. `outgoing()` still reads the box at |
| 34 | the moment of the press, so what leaves is still what is on the |
| 35 | screen; restoring the box is what puts it on the screen in the |
| 36 | first place. |
| 37 | |
| 38 | The clause that would have been broken is "kept for later |
| 39 | SENDING", and the word is doing all the work. A queue outlives |
| 40 | the consent that filled it: the user said yes once, the app |
| 41 | remembered the yes, and the text goes later without anybody |
| 42 | present. Here nothing was consented to at all -- Send was never |
| 43 | pressed -- so there is no consent to outlive, and the text goes |
| 44 | only if a person comes back and presses the button while |
| 45 | looking at it. |
| 46 | |
| 47 | THREE RULES THAT KEEP IT THAT WAY, and each is a thing this |
| 48 | file deliberately does not do: |
| 49 | |
| 50 | - IT NEVER SYNCS. `sync.js` collects a parcel from named |
| 51 | stores; this key is in none of them, so a draft is one |
| 52 | device's and stays there. A draft that crossed to a phone |
| 53 | would be text moving without a press, which is the thing. |
| 54 | - IT NEVER SENDS, RETRIES OR SCHEDULES. There is no timer here |
| 55 | that does anything but write to disk, and nothing here reads |
| 56 | a draft except the box it came from. |
| 57 | - IT IS DROPPED THE MOMENT THE BOX IS. Sending, keeping and |
| 58 | clearing all drop the draft, so a sent note is not also a |
| 59 | draft of itself sitting in storage afterwards. |
| 60 | |
| 61 | ── WHERE IT LIVES ────────────────────────────────────────── |
| 62 | |
| 63 | One `daimond-drafts` record, so `accounts.js` namespaces the |
| 64 | whole of it per account with no call site aware of it -- two |
| 65 | people at one browser have two sets of drafts and neither can |
| 66 | see the other's. NOT one key per box: a box whose owner is gone |
| 67 | (a proposal nobody will open again, a chat that was deleted) |
| 68 | would leave a key nothing ever removes, and a hundred of those |
| 69 | is a storage quota spent on nothing. |
| 70 | |
| 71 | Not encrypted, and that is a decision rather than an oversight. |
| 72 | The identity may be locked when a box needs restoring -- the |
| 73 | whole point is that it survives a reload, which lands on the |
| 74 | passphrase gate -- so a wrapped draft could not be put back |
| 75 | until the user had unlocked, which is exactly the moment they |
| 76 | are looking at the box. It sits beside the chat store, which is |
| 77 | also plaintext and holds the same words once they are sent. |
| 78 | |
| 79 | Attaches one global, `window.DaimondDrafts`. |
| 80 | ============================================================ */ |
| 81 | (function () { |
| 82 | 'use strict'; |
| 83 | |
| 84 | var LS = 'daimond-drafts'; |
| 85 | |
| 86 | /// The most one box may keep. The Social panel's own note cap, because that is |
| 87 | /// the longest thing any of these boxes may legally send -- a draft larger |
| 88 | /// than what could be sent is a draft of something that would be refused. |
| 89 | var MAX = 20000; |
| 90 | |
| 91 | /// How long after the last keystroke the draft is written. |
| 92 | /// |
| 93 | /// A write on every character would put the whole box through `JSON.stringify` |
| 94 | /// and `localStorage` per keypress, which is synchronous and on the main |
| 95 | /// thread. Long enough to cost nothing while typing, short enough that a |
| 96 | /// reload a moment after stopping still finds the words. |
| 97 | var SETTLE = 400; |
| 98 | |
| 99 | var _all = null; // key -> text, lazily read |
| 100 | var _timer = 0; |
| 101 | |
| 102 | function read() { |
| 103 | if (_all) return _all; |
| 104 | _all = {}; |
| 105 | try { |
| 106 | var raw = localStorage.getItem(LS); |
| 107 | if (raw) { |
| 108 | var j = JSON.parse(raw); |
| 109 | if (j && typeof j === 'object' && j.d && typeof j.d === 'object') { |
| 110 | Object.keys(j.d).forEach(function (k) { |
| 111 | if (typeof j.d[k] === 'string' && j.d[k]) _all[k] = j.d[k]; |
| 112 | }); |
| 113 | } |
| 114 | } |
| 115 | } catch (e) { /* blocked or corrupt: everything starts empty, which is the old behaviour */ } |
| 116 | return _all; |
| 117 | } |
| 118 | |
| 119 | /// Write the whole record. Failure is SILENT and that is deliberate: a full |
| 120 | /// quota must not put an error in front of somebody who is typing, and the |
| 121 | /// worst case is exactly what the app did before this file existed. |
| 122 | function flush() { |
| 123 | _timer = 0; |
| 124 | try { localStorage.setItem(LS, JSON.stringify({ v: 1, d: read() })); } |
| 125 | catch (e) { /* private mode, or full */ } |
| 126 | } |
| 127 | |
| 128 | function later() { |
| 129 | if (_timer) return; |
| 130 | _timer = setTimeout(flush, SETTLE); |
| 131 | } |
| 132 | |
| 133 | /// What is kept under this key, or ''. |
| 134 | function get(key) { |
| 135 | var s = read()[String(key)]; |
| 136 | return typeof s === 'string' ? s : ''; |
| 137 | } |
| 138 | |
| 139 | /// Keep what is in a box. An empty value DROPS the entry rather than storing |
| 140 | /// one: a box somebody emptied on purpose must not come back full. |
| 141 | function set(key, text) { |
| 142 | var k = String(key); |
| 143 | var s = String(text == null ? '' : text); |
| 144 | if (s.length > MAX) s = s.slice(0, MAX); |
| 145 | var all = read(); |
| 146 | if (!s) { if (!(k in all)) return; delete all[k]; } |
| 147 | else { if (all[k] === s) return; all[k] = s; } |
| 148 | later(); |
| 149 | } |
| 150 | |
| 151 | /// Forget one draft, at once rather than on the timer. Called where a box is |
| 152 | /// sent or cleared, so a sent note is never also a draft of itself. |
| 153 | function drop(key) { |
| 154 | var all = read(); |
| 155 | if (!(String(key) in all)) return; |
| 156 | delete all[String(key)]; |
| 157 | flush(); |
| 158 | } |
| 159 | |
| 160 | /// Forget every draft whose key starts with `pre` -- one conversation's, one |
| 161 | /// proposal's -- for a caller that is deleting the thing they belong to. |
| 162 | function dropUnder(pre) { |
| 163 | var all = read(), p = String(pre), hit = false; |
| 164 | Object.keys(all).forEach(function (k) { |
| 165 | if (k.indexOf(p) === 0) { delete all[k]; hit = true; } |
| 166 | }); |
| 167 | if (hit) flush(); |
| 168 | } |
| 169 | |
| 170 | /// Attach a box to a key: put back what was kept, and keep what is typed. |
| 171 | /// |
| 172 | /// Returns the restored text, so a caller that has to do something else about |
| 173 | /// it -- resize a composer, redraw a counter -- can tell whether anything came |
| 174 | /// back without reading the box a second time. |
| 175 | /// |
| 176 | /// A box already bound to this key is not bound twice; a box bound to a |
| 177 | /// DIFFERENT key is re-pointed, which is what a reused element needs. |
| 178 | function bind(el, key) { |
| 179 | if (!el) return ''; |
| 180 | var k = String(key); |
| 181 | if (el.dataset.draftKey === k) return String(el.value || ''); |
| 182 | el.dataset.draftKey = k; |
| 183 | var had = get(k); |
| 184 | if (had && !el.value) el.value = had; |
| 185 | if (!el.dataset.draftBound) { |
| 186 | el.dataset.draftBound = '1'; |
| 187 | el.addEventListener('input', function () { |
| 188 | set(el.dataset.draftKey || '', el.value); |
| 189 | }); |
| 190 | } |
| 191 | return String(el.value || ''); |
| 192 | } |
| 193 | |
| 194 | // A reload can arrive before the settle timer has fired, and the words typed |
| 195 | // in that last fraction of a second are exactly the ones somebody is most |
| 196 | // annoyed to lose. `pagehide` rather than `unload`, which a browser back/forward |
| 197 | // cache does not fire. |
| 198 | if (typeof window !== 'undefined' && window.addEventListener) { |
| 199 | window.addEventListener('pagehide', function () { if (_timer) { clearTimeout(_timer); flush(); } }); |
| 200 | window.addEventListener('visibilitychange', function () { |
| 201 | if (document.visibilityState === 'hidden' && _timer) { clearTimeout(_timer); flush(); } |
| 202 | }); |
| 203 | } |
| 204 | |
| 205 | window.DaimondDrafts = { |
| 206 | MAX: MAX, |
| 207 | get: get, |
| 208 | set: set, |
| 209 | drop: drop, |
| 210 | dropUnder: dropUnder, |
| 211 | bind: bind, |
| 212 | /// Write now rather than on the settle timer. For a caller about to do |
| 213 | /// something that ends the page, and for a test that will not wait. |
| 214 | flush: function () { if (_timer) clearTimeout(_timer); flush(); }, |
| 215 | /// Everything kept, for a verifier. A copy, so reading cannot alter it. |
| 216 | all: function () { return JSON.parse(JSON.stringify(read())); }, |
| 217 | /// Forget the lot. For an account switch and for a test. |
| 218 | reset: function () { _all = null; if (_timer) { clearTimeout(_timer); _timer = 0; } }, |
| 219 | }; |
| 220 | })(); |