Oregami
Repositories/oxedyne/daimond

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