Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/lapse.js

17.8 KiB, 1 run

created by r2519314175:1389, 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/* lapse.js — telling the user, in the app, before something of theirs lapses.
2 *
3 * Two clauses of the Terms promise a notice on this screen, and until now
4 * nothing on this screen gave one:
5 *
6 * Terms §13 (Termination), and Privacy §9 (Retention), on stored file data:
7 * "…the stored data above the free allowance IS DELETED. We say 'is', not
8 * 'may be', because that is what happens. YOU WILL BE TOLD IN THE APP
9 * BEFORE IT HAPPENS, while there is still time to top up or to bring those
10 * files down onto your own device."
11 *
12 * Terms §7 (Credits and the Pro licence), on the five-year term:
13 * "Five years after you buy, the licence ends, and you decide then whether
14 * to buy another." The app never said when that was.
15 *
16 * So this file holds one surface and two facts. It is small, and it is written
17 * against a rule: SAY WHAT THE TERMS SAY, AND NOTHING MORE GENEROUS. A notice
18 * that softens a deletion, or implies a grace nobody promised, is worse than no
19 * notice at all — it is the app telling the user they are safe when they are
20 * not. Every sentence below can be traced to a sentence in landing/terms.html,
21 * and dev/verify_legalreach.mjs checks that the two agree on the three facts
22 * that matter: what switches off, what does not, and what is never deleted.
23 *
24 * ── Where the facts come from ───────────────────────────────────────
25 *
26 * The LICENCE half works today. `/api/licence` returns the signed licence
27 * record, which carries `issued_ts`, and the term is five years from purchase —
28 * that is the published policy, so the date is arithmetic and needs nothing new
29 * from the gateway. Where the gateway states an expiry itself (`expires_ts`),
30 * that is the authority and is used instead: the server enforces the term, and
31 * a client that computed a different date from a rule would be arguing with it.
32 *
33 * The STORAGE half cannot work until the gateway says so, and does not pretend
34 * to. `grace_start` is written and read only inside gateway/src/storage.rs; no
35 * handler returns it, so the browser has no way to know an account is in grace.
36 * This file asks `/api/balance` for three fields (`storage_grace_start`,
37 * `storage_grace_secs`, `storage_paid_bytes`) and draws nothing at all until
38 * they arrive. The client half is finished and proved; the promise is not kept
39 * until the gateway answers, and saying so plainly is better than a surface
40 * that looks built.
41 *
42 * ── Why it cannot be dismissed for good ─────────────────────────────
43 *
44 * A × that silences a deletion notice for ever is a × the user will press by
45 * reflex on the day they most needed to read it. Dismissal lasts a day, and is
46 * keyed to the DATE in the notice, so a deadline that moves says so again at
47 * once.
48 */
49(function () {
50 'use strict';
51
52 /// What the app says, falling back to English while a key has no translation.
53 /// The twin of `tOr` in daimond.js: this file is a classic script and cannot
54 /// reach into that closure. `{name}` in either the translation or the
55 /// fallback is filled from `vars`.
56 function t(k, fallback, vars) {
57 var i18n = window.DaimondI18n;
58 if (i18n && i18n.has && i18n.has(k)) return i18n.t(k, vars);
59 if (!vars) return fallback;
60 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
61 return vars[name] != null ? String(vars[name]) : whole;
62 });
63 }
64
65 // ── What is asked, and how often ────────────────────────────────
66 //
67 // `/api/balance` reconciles the account before it answers, so it is not a
68 // free call and is not polled tightly. Once per session, then every six
69 // hours, and again when the tab is brought back after being away that long.
70 // A deadline measured in days needs nothing finer.
71
72 var EVERY_MS = 6 * 3600 * 1000;
73 var FIRST_MS = 8000; // after the unlock settles, not during it
74
75 /// How long before a licence ends the app starts saying so. The Terms fix no
76 /// notice period for this one — the [TO CONFIRM] on notice periods is about
77 /// the storage deletion — so a month is chosen here: long enough to decide
78 /// and to buy again, short enough not to be furniture.
79 var LICENCE_LEAD_DAYS = 30;
80
81 /// The Pro term, in years. Terms §7: "It is a single payment for a five-year
82 /// licence". Used only when the gateway states no expiry of its own.
83 var TERM_YEARS = 5;
84
85 /// How long a dismissal lasts.
86 var HUSH_MS = 24 * 3600 * 1000;
87
88 var HUSH_KEY = 'daimond-lapse-hushed';
89
90 var state = {
91 /// Whether the account is in grace at all.
92 storageOn: false,
93 /// Unix ms the stored data is deleted, or 0 when the gateway has not
94 /// said how long the grace runs.
95 storageAt: 0,
96 /// Bytes above the free allowance, as the gateway last reported them.
97 paidBytes: -1,
98 /// Unix ms the Pro licence ends, or 0 when no licence is held.
99 licenceAt: 0,
100 };
101
102 var timer = null;
103 var last = 0; // when the gateway was last asked, ms
104
105 // ── Words ───────────────────────────────────────────────────────
106
107 /// A date in the language the app is in — not the browser's. A reader who has
108 /// put Daimond into German reads every other word of the notice in German.
109 function fmtDate(ms) {
110 var loc;
111 try { loc = window.DaimondI18n ? DaimondI18n.locale() : undefined; }
112 catch (e) { loc = undefined; }
113 try {
114 return new Date(ms).toLocaleDateString(loc || undefined,
115 { day: 'numeric', month: 'long', year: 'numeric' });
116 } catch (e) { return ''; }
117 }
118
119 /// A size a person reads. The twin of `fmtBytes` in trash.js and daimond.js;
120 /// this file is a classic script and cannot reach into either closure.
121 function fmtBytes(n) {
122 if (!n) return '0 B';
123 var u = ['B', 'KB', 'MB', 'GB'], i = 0;
124 while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
125 return (i === 0 ? n : n.toFixed(1)) + ' ' + u[i];
126 }
127
128 // ── Being quiet for a day ───────────────────────────────────────
129
130 function hushed() {
131 try { return JSON.parse(localStorage.getItem(HUSH_KEY) || '{}') || {}; }
132 catch (e) { return {}; }
133 }
134
135 /// Is this exact notice — this kind, this deadline — put away for now?
136 function isHushed(kind, at) {
137 var h = hushed()[kind + ':' + at];
138 return !!h && (Date.now() - h) < HUSH_MS;
139 }
140
141 function hush(kind, at) {
142 var h = hushed();
143 h[kind + ':' + at] = Date.now();
144 // Anything about a deadline that has passed out of the window is dropped,
145 // so this key cannot grow for ever.
146 Object.keys(h).forEach(function (k) {
147 if (Date.now() - h[k] >= HUSH_MS) delete h[k];
148 });
149 try { localStorage.setItem(HUSH_KEY, JSON.stringify(h)); } catch (e) { /* full, or private */ }
150 render();
151 }
152
153 // ── Reading the gateway ─────────────────────────────────────────
154
155 /// One session-authed GET, through the gateway's own wrapper so a lapsed
156 /// session costs a renewal and not the answer.
157 async function ask(path) {
158 var g = window.DaimondGateway;
159 if (!g || !g.gwFetch) return null;
160 var r = await g.gwFetch(path, {
161 credentials: 'same-origin',
162 headers: { 'x-daimond-api': String(g.clientApi ? g.clientApi() : '') },
163 });
164 if (!r || !r.ok) return null;
165 var j = null;
166 try { j = await r.json(); } catch (e) { j = null; }
167 if (!j || j.ok === false) return null;
168 return j;
169 }
170
171 /// When this licence ends, in unix ms, or 0 if there is none.
172 ///
173 /// The gateway's own `expires_ts` wins wherever it is given: the server
174 /// enforces the term, so a date computed here from the published rule must
175 /// never be shown in preference to the one being enforced.
176 function expiryOf(j) {
177 if (!j) return 0;
178 if (typeof j.expires_ts === 'number' && j.expires_ts > 0) return j.expires_ts * 1000;
179 var lic = j.licence;
180 if (!lic || typeof lic !== 'object') return 0;
181 var issued = Number(lic.issued_ts);
182 if (!issued || issued <= 0) return 0;
183 // Five calendar years from the purchase, which is what "five years after
184 // you buy" means to the person who bought it.
185 var d = new Date(issued * 1000);
186 d.setFullYear(d.getFullYear() + TERM_YEARS);
187 return d.getTime();
188 }
189
190 /// Ask the gateway both questions and redraw. Never throws: a gateway that
191 /// cannot be reached leaves the last answer standing, which is the honest
192 /// state — nothing has been learned, so nothing has changed.
193 async function check() {
194 var g = window.DaimondGateway;
195 if (!g || !g.state || !g.state().authed) return state;
196 last = Date.now();
197
198 try {
199 var bal = await ask('/api/balance');
200 if (bal) {
201 // The figure moved on the server while it reconciled; the one place
202 // that owns the app's balance is told, rather than this file keeping
203 // a second copy of it.
204 try { if (g.noteBalance) g.noteBalance(bal); } catch (e) { /* not fatal */ }
205 if (typeof bal.storage_grace_start === 'number') {
206 var start = bal.storage_grace_start;
207 var len = Number(bal.storage_grace_secs) || 0;
208 // TWO FACTS, NOT ONE. Whether the account is in grace and when
209 // the grace ends arrive together and could arrive apart, and a
210 // missing LENGTH must not silence a notice about a DELETION —
211 // the user is owed the warning even where the day cannot be
212 // named. No date is ever guessed at: the notice says what it
213 // knows and no more.
214 state.storageOn = start > 0;
215 state.storageAt = (start > 0 && len > 0) ? (start + len) * 1000 : 0;
216 state.paidBytes = (typeof bal.storage_paid_bytes === 'number')
217 ? bal.storage_paid_bytes : -1;
218 }
219 }
220 } catch (e) { /* offline, paused, or refused: say nothing new */ }
221
222 try {
223 var lic = await ask('/api/licence');
224 if (lic) state.licenceAt = expiryOf(lic);
225 } catch (e) { /* as above */ }
226
227 render();
228 return state;
229 }
230
231 // ── The notice ──────────────────────────────────────────────────
232
233 /// One notice card: a heading, what happens, and what can be done about it.
234 function card(spec) {
235 var box = document.createElement('div');
236 box.className = 'lapse-note lapse-' + spec.kind;
237 box.setAttribute('role', 'status');
238 box.dataset.kind = spec.kind;
239 box.dataset.at = String(spec.at);
240
241 var head = document.createElement('div');
242 head.className = 'lapse-head';
243 head.textContent = spec.head;
244 box.appendChild(head);
245
246 spec.body.forEach(function (words) {
247 var p = document.createElement('p');
248 p.className = 'lapse-body';
249 p.textContent = words;
250 box.appendChild(p);
251 });
252
253 var acts = document.createElement('div');
254 acts.className = 'lapse-acts';
255 spec.acts.forEach(function (a) { acts.appendChild(a); });
256 box.appendChild(acts);
257
258 var x = document.createElement('button');
259 x.type = 'button';
260 x.className = 'lapse-x';
261 x.setAttribute('aria-label', t('lapse.hide', 'Hide until tomorrow'));
262 x.title = t('lapse.hide', 'Hide until tomorrow');
263 x.textContent = '×';
264 x.addEventListener('click', function () { hush(spec.kind, spec.at); });
265 box.appendChild(x);
266
267 return box;
268 }
269
270 /// A button in a notice. Only ever drawn for something that really happens:
271 /// see the note on renewal in `licenceSpec`.
272 function act(words, go, primary) {
273 var b = document.createElement('button');
274 b.type = 'button';
275 b.className = 'lapse-act' + (primary ? ' lapse-act-primary' : '');
276 b.textContent = words;
277 b.addEventListener('click', go);
278 return b;
279 }
280
281 /// A link into the clause the notice is quoting, drawn by js/legal.js so it
282 /// opens in Daimond's own panel rather than leaving the app.
283 function clause(which, anchor, words) {
284 if (window.DaimondLegal && DaimondLegal.link) {
285 var a = DaimondLegal.link(which, words, anchor);
286 // It is one of this notice's controls as well as a link, so it wears
287 // the class the row is styled and asserted through.
288 a.className += ' lapse-act';
289 return a;
290 }
291 var span = document.createElement('span');
292 span.className = 'lapse-act';
293 span.textContent = words;
294 return span;
295 }
296
297 /// The storage notice, or null when there is nothing to say.
298 ///
299 /// Shown for the WHOLE grace period rather than a few days before the end.
300 /// The promise is a notice "while there is still time to top up or to bring
301 /// those files down", and the honest reading of that is the earliest moment
302 /// the app knows — the day the meter pauses — not the last.
303 function storageSpec() {
304 if (!state.storageOn) return null;
305 var when = state.storageAt ? fmtDate(state.storageAt) : '';
306 var body = [
307 t('lapse.storage_why',
308 'Your credits will not cover the cloud storage you are holding, so the '
309 + 'metering has paused. Nothing is being back-charged, and you can still '
310 + 'read everything you have stored.'),
311 t('lapse.storage_what',
312 'If the balance is not restored by then, the stored data above the free '
313 + 'allowance is deleted. Files on this device are untouched.'),
314 ];
315 if (state.paidBytes > 0) {
316 body.splice(1, 0, t('lapse.storage_size',
317 'About {size} is held above the free allowance.',
318 { size: fmtBytes(state.paidBytes) }));
319 }
320 var acts = [];
321 if (window.DaimondAdmin && DaimondAdmin.credits) {
322 acts.push(act(t('lapse.top_up', 'Top up credits'), function () {
323 DaimondAdmin.credits(t('lapse.credits_pitch',
324 'Topping up stops the stored data above the free allowance being deleted.'));
325 }, true));
326 }
327 acts.push(clause('terms', 'storage-lapse', t('lapse.read_clause', 'What the Terms say')));
328 return {
329 kind: 'storage',
330 at: state.storageAt,
331 // Named where it is known, and honestly vague where it is not. Both
332 // sentences leave "by then" in the next paragraph with something to
333 // refer to.
334 head: when
335 ? t('lapse.storage_head',
336 'Stored files above the free allowance will be deleted on {date}.',
337 { date: when })
338 : t('lapse.storage_head_undated',
339 'Stored files above the free allowance will be deleted when the grace period ends.'),
340 body: body,
341 acts: acts,
342 };
343 }
344
345 /// The licence notice, or null when there is nothing to say.
346 ///
347 /// NO RENEW BUTTON, deliberately. `/api/checkout/pro` answers 409 while a
348 /// licence record exists for the account, so a Buy again drawn here would be
349 /// a button that refuses — and a control that does not do what it appears to
350 /// do is the defect this app keeps shipping. When checkout accepts a second
351 /// purchase after a term ends, one belongs here.
352 function licenceSpec() {
353 if (!state.licenceAt) return null;
354 var now = Date.now();
355 var lead = LICENCE_LEAD_DAYS * 86400 * 1000;
356 if (state.licenceAt - now > lead) return null;
357 var over = state.licenceAt <= now;
358 var when = fmtDate(state.licenceAt);
359 return {
360 kind: 'licence',
361 at: state.licenceAt,
362 head: over
363 ? t('lapse.lic_head_past', 'Your Pro licence ended on {date}.', { date: when })
364 : t('lapse.lic_head', 'Your Pro licence ends on {date}.', { date: when }),
365 body: [
366 over
367 ? t('lapse.lic_off_past',
368 'Cross-device sync, cloud storage and Daimond Email are off, because each '
369 + 'of those is a service we run on our side.')
370 : t('lapse.lic_off',
371 'Cross-device sync, cloud storage and Daimond Email switch off then, because '
372 + 'each of those is a service we run on our side.'),
373 t('lapse.lic_keep',
374 'Everything on this device carries on exactly as before: your files, your chats, '
375 + 'your Diamonds, your identity and your own provider key. Nothing is deleted, '
376 + 'nothing is locked, and nothing you have made becomes unreadable.'),
377 t('lapse.lic_pull',
378 'Pulling down what you have already stored never stops, and your credits are '
379 + 'unaffected.'),
380 ],
381 acts: [clause('terms', 'five-years', t('lapse.read_clause', 'What the Terms say'))],
382 };
383 }
384
385 /// Draw whatever is true and not hushed, and take down whatever is not.
386 function render() {
387 var host = document.getElementById('lapse-notices');
388 var specs = [storageSpec(), licenceSpec()].filter(function (s) {
389 return s && !isHushed(s.kind, s.at);
390 });
391 if (!specs.length) {
392 if (host && host.parentNode) host.parentNode.removeChild(host);
393 return;
394 }
395 if (!host) {
396 host = document.createElement('div');
397 host.id = 'lapse-notices';
398 host.className = 'lapse-notices';
399 document.body.appendChild(host);
400 }
401 // Redrawn whole. There are at most two of these and they change about
402 // twice a year; keeping them in place would be machinery for nothing.
403 host.textContent = '';
404 specs.forEach(function (s) { host.appendChild(card(s)); });
405 }
406
407 // ── Running ─────────────────────────────────────────────────────
408
409 function start() {
410 if (timer) return;
411 timer = setInterval(function () { check(); }, EVERY_MS);
412 setTimeout(function () { check(); }, FIRST_MS);
413 }
414
415 // There is a session now — the same event sync and the credit header wait
416 // for. Before it, `/api/balance` has nobody to answer about.
417 window.addEventListener('daimond:authed', start);
418
419 // A tab that has been away for longer than the interval asks on its way back,
420 // because a background tab's timers are throttled to the point of stopping.
421 document.addEventListener('visibilitychange', function () {
422 if (document.hidden) return;
423 if (Date.now() - last >= EVERY_MS) check();
424 });
425
426 // The language can change under an open notice.
427 if (window.DaimondI18n && DaimondI18n.onChange) DaimondI18n.onChange(function () { render(); });
428
429 window.DaimondLapse = {
430 /// Ask the gateway now, and redraw. Returns what it learned.
431 check: check,
432 /// What it last learned, as a copy.
433 state: function () { return Object.assign({}, state); },
434 /// Redraw from what is already known.
435 render: render,
436 };
437})();