Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/trash.js

41.5 KiB, 1 run

created by r2519314175:1455, 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 Trash (trash.js)
3 ------------------------------------------------------------
4 WHY THIS EXISTS. "Delete all chats" shipped without it. A user
5 pressed it expecting to be able to undo, and could not: every
6 chat was tombstoned, the tombstone travelled, and both devices
7 agreed the work was gone. The dialog in front of it did not
8 help — it named a count, which is what somebody about to delete
9 fourteen chats already believes they want.
10
11 So deleting stops being an act and becomes a STATE. A chat or a
12 Diamond that is deleted is TRASHED: still stored, still synced,
13 out of the rail, out of the finders, out of every daimon's
14 reach, and listed in one panel with Restore beside it.
15
16 WHERE THE CEREMONY WENT. A reversible act needs LESS of it, so
17 trashing asks nothing at all. The two questions moved to the two
18 acts that cannot be taken back — "Delete permanently" on one
19 item, and "Empty trash", which names the count. A dialog in
20 front of a reversible act only teaches people to click through
21 dialogs, and then the irreversible one is clicked through too.
22
23 ── THE SYNC MODEL ──────────────────────────────────────────
24 Trashed here is trashed everywhere; restored here is restored
25 everywhere. Neither can be a bare flag on the record, because
26 two devices act at once and one of them has to lose.
27
28 So each item carries TWO monotone stamps:
29
30 { k: 'c' | 'd', at: <ms last trashed>, back: <ms last restored> }
31
32 and the item is in the trash exactly when `at > back`. Merging
33 two records takes the LATER of each stamp independently, which
34 makes the merge commutative, associative and idempotent: two
35 devices converge whatever order the parcels arrive in, and a
36 parcel applied twice changes nothing.
37
38 That is what stops the two failures worth naming:
39
40 * A DELETION CANNOT BE RESURRECTED. Permanent deletion is a
41 TOMBSTONE, exactly as it was before this file existed, and a
42 tombstone is honoured unconditionally by the chat and
43 Diamond merges. Nothing here outranks one. Restoring an item
44 another device has already destroyed raises `back` on a
45 record whose subject is gone, and the sweep below drops it.
46
47 * A RESTORE CANNOT BE BURIED. A device still holding `at` from
48 yesterday cannot re-trash what was restored today: `back` is
49 the later stamp, and taking the later of each is the whole
50 rule. Only a NEW trashing — a real act, with a stamp later
51 than the restore — puts it back, which is a person deciding
52 twice and is meant to work.
53
54 RETENTION IS A PURE FUNCTION OF `at`, deliberately. Thirty days
55 after it was trashed an item is destroyed for good, and every
56 device works that out from the same synced stamp without being
57 told. A device that was offline for six weeks therefore sweeps
58 its own trash on the boot it comes back on, and reaches the same
59 answer the others reached while it was away, rather than
60 depending on a tombstone that may have expired meanwhile.
61
62 ── AND A CHAT ARRIVES HERE ON ITS OWN ──────────────────────
63 A chat is throw-away. Untouched for the operator's few days it
64 is trashed without being asked, and from that moment it is an
65 ordinary trashed thing: it sits in the panel with Restore beside
66 it, and it is destroyed on the same retention rule as everything
67 else. The feature is therefore ONE new entry point, `expire`,
68 and not a second lifetime running alongside this one.
69
70 `expire` DOES NOT STAMP THE CLOCK, and that is the whole of why
71 two devices converge. `put` stamps `Date.now()`, because trashing
72 by hand is an act taken here at this moment. Expiry is not an
73 act; it is a DEADLINE PASSING, and the deadline is a pure
74 function of a stamp both devices already hold:
75
76 at = chat.updatedAt + <expire window>
77
78 So a device that notices on the day and a device that notices
79 three weeks later write the SAME record, byte for byte. The
80 union is a no-op, the parcel does not differ, and the retention
81 clock does not restart on whichever device happened to boot last.
82 Had expiry stamped `Date.now()` instead, a device coming back
83 from a fortnight away would have raised `at` above a restore the
84 user made by hand -- an unattended machine burying somebody's
85 decision, and burying it again after every restore.
86
87 `expire` refuses in two cases, and the refusals are the safety
88 rather than the arithmetic: it will not touch an item already in
89 the trash, so the retention clock cannot be pushed forward; and
90 it will not write an `at` that a restore already outranks, so an
91 automatic act can never defeat a deliberate one.
92
93 ── THE TWO RETENTIONS HAVE TO OUTLIVE A STALE PEER ─────────
94 Both numbers below were seven days, and both were too small by
95 the width of the retention itself.
96
97 A tombstone is the only thing that can defeat a peer's copy of a
98 record. Destroy an item by hand on day one and the peer still
99 holds it, still holds a trash record saying `at` = day nought,
100 and will go on packing both into every parcel until ITS own
101 thirty days are up. A tombstone pruned on day eight is therefore
102 a deletion that the next parcel undoes -- which is exactly what
103 was seen. The tombstone has to reach the peer's own verdict, so
104 it has to outlive the retention, not the parcel.
105
106 `BACK_TTL` is the same statement about restores. A restore record
107 is what stops a peer's stale trashing burying it; the stale
108 trashing lives until the peer's own retention date, so the proof
109 of the restore must live at least that long too.
110
111 Hence both are RETENTION PLUS A GRACE, and the grace is there for
112 a peer that has to boot, sweep and reach the same answer rather
113 than for any clock skew. Either branch then converges and there
114 is no third: a peer returning inside the term is told, and a peer
115 returning after it has already destroyed the item itself.
116 ============================================================ */
117
118/* ============================================================
119 The operator's policy
120 ------------------------------------------------------------
121 Two numbers the operator sets in the Dashboard -- how long an
122 untouched chat lives, and how long the trash keeps what it is
123 given -- served by the gateway at `GET /api/policy` and cached
124 here.
125
126 IT LIVES IN THIS FILE BECAUSE THIS FILE ALREADY OWNS THE
127 SENTENCE. `RETAIN_MS` was named once and read by the sweep, by
128 the tile that shows the date and by the panel's own explanation,
129 for the reason that a retention stated in two places is two
130 retentions. The expiry window is the same sentence one clause
131 earlier -- how long a chat lives before it is given to the trash
132 -- and splitting the pair across two modules would put the two
133 halves of one policy where they could disagree.
134
135 THE SHIPPED DEFAULTS STAND UNTIL THE GATEWAY SAYS OTHERWISE, and
136 they stand for ever on a device that has no gateway at all. A
137 user on their own provider keys and no account still has chats
138 that expire, because a policy that only worked when the network
139 did would be a policy that quietly stopped applying on an
140 aeroplane. The cache means the last answer heard is the answer
141 used, so a device offline for a month applies the operator's
142 figure rather than reverting to the shipped one.
143
144 TWO DEVICES HOLDING DIFFERENT FIGURES STILL CONVERGE on when a
145 chat is TRASHED, which is the property that lets this be cached
146 at all rather than agreed. The one holding the shorter window
147 expires first and writes the record; the other finds the item
148 already trashed and `expire` declines to touch it. The earlier
149 figure wins, the later device does nothing, and neither writes a
150 second record.
151
152 ── A RETENTION THAT CAN BE LOWERED ─────────────────────────
153 Making the retention settable breaks something the trash was
154 built on, and it has to be paid for rather than lived with.
155
156 The old sentence was "retention is a pure function of `at`" --
157 true when thirty days was a constant every device shared. With a
158 knob it is a function of `at` AND of whichever figure each device
159 last heard, so two devices destroy the same item on different
160 days. That is a device deleting somebody's work a week before its
161 own panel said it would.
162
163 So THE RETENTION IS PINNED ON THE RECORD, in `r`, as it stood at
164 the moment the item was trashed. `at + r` is again a pure
165 function of the synced record, every device reads the same
166 destruction date off the same bytes, and lowering the knob
167 governs what is trashed FROM NOW ON rather than reaching back and
168 shortening the term of things already in the bin. That is also
169 the honest reading of the setting: an operator lowering it is
170 saying what should happen next, not condemning what is already
171 there.
172
173 That leaves the tombstone, which is not on any record and so
174 cannot be pinned. A tombstone has to outlive whatever a PEER
175 still holds, and a peer that has not heard the new figure is
176 still working to the old one. Lower the knob from ninety days to
177 thirty and a device that adopted the change prunes its
178 tombstones sixty days before its peer stops offering the record
179 back -- hole one again, with the operator as its cause.
180
181 Hence `tombTtlMs` is a HIGH-WATER: the largest retention this
182 device has ever seen, from the policy and from the `r` of every
183 record it has ever adopted, plus the grace. Fed by evidence
184 rather than by guessing -- a peer's record stamped under the old
185 ninety days ARRIVES carrying `r` = 90, and adopting it raises
186 this device's tombstone term to cover the peer that sent it.
187 It never falls. A tombstone kept too long costs a few bytes in
188 the parcel; one dropped too early costs somebody's work, and
189 between those two there is no symmetry to trade on.
190 ============================================================ */
191(function () {
192 'use strict';
193
194 var KEY = 'daimond-policy';
195 // What Daimond ships believing. The gateway's own fallbacks say the same
196 // numbers (gateway/src/settings.rs), so a console never shows a figure the
197 // app would not actually apply.
198 var CHAT_EXPIRE_DAYS = 3;
199 var TRASH_RETAIN_DAYS = 30;
200 // How much longer than the retention a tombstone and a restore record are
201 // kept. It buys a peer the time to boot, sweep and reach the same verdict;
202 // see the header above for why the term is retention PLUS this and not the
203 // retention alone.
204 var GRACE_DAYS = 7;
205
206 var DAY = 24 * 3600 * 1000;
207 var _cache = null;
208
209 function log(/* ...args */) {
210 try { if (window.console && console.debug) console.debug.apply(console, ['[policy]'].concat([].slice.call(arguments))); }
211 catch (e) { /* no console */ }
212 }
213
214 /// A whole number of days, or `dflt`. A policy that arrived as nonsense is
215 /// not obeyed: these two numbers decide when somebody's work is destroyed,
216 /// and the shipped figure is a better answer than whatever was parsed.
217 function days(v, dflt) {
218 var n = (typeof v === 'string') ? parseInt(v, 10) : v;
219 if (typeof n !== 'number' || !isFinite(n) || n < 1) return dflt;
220 return Math.floor(n);
221 }
222
223 function load() {
224 if (_cache) return _cache;
225 _cache = { expire: CHAT_EXPIRE_DAYS, retain: TRASH_RETAIN_DAYS, high: TRASH_RETAIN_DAYS };
226 try {
227 var raw = JSON.parse(localStorage.getItem(KEY) || '{}') || {};
228 _cache.expire = days(raw.expire, CHAT_EXPIRE_DAYS);
229 _cache.retain = days(raw.retain, TRASH_RETAIN_DAYS);
230 _cache.high = Math.max(_cache.retain, days(raw.high, TRASH_RETAIN_DAYS));
231 } catch (e) { /* nothing cached: the shipped figures stand */ }
232 return _cache;
233 }
234
235 function save(p) {
236 try { localStorage.setItem(KEY, JSON.stringify({ v: 1, expire: p.expire, retain: p.retain, high: p.high })); }
237 catch (err) { log('could not cache the policy', err); }
238 }
239
240 /// Adopt a policy, from the gateway or from a test. True when it moved,
241 /// which is what tells the trash to redraw the dates it has already drawn.
242 function set(expireDays, retainDays) {
243 var was = load(), e = days(expireDays, was.expire), r = days(retainDays, was.retain);
244 var h = Math.max(was.high, r);
245 if (e === was.expire && r === was.retain && h === was.high) return false;
246 _cache = { expire: e, retain: r, high: h };
247 save(_cache);
248 return true;
249 }
250
251 /// Remember that a retention of `d` days is in force SOMEWHERE -- read off
252 /// the `r` of a record that arrived from another device.
253 ///
254 /// This is the whole of how a lowered knob stays safe. The peer that sent
255 /// the record is still working to the term the record carries, so this
256 /// device's tombstones must reach it, and the record itself is the evidence
257 /// of how far. Monotone: it never comes back down, because the peer that
258 /// needed the longer term does not stop needing it when the parcel is over.
259 function noteRetain(d) {
260 var p = load(), n = days(d, 0);
261 if (!n || n <= p.high) return false;
262 p.high = n;
263 save(p);
264 return true;
265 }
266
267 /// Ask the gateway what the operator has set. Fire and forget: a failure
268 /// leaves the cached answer in place, which is the right answer to have.
269 ///
270 /// Unauthenticated, deliberately. It returns two integers the interface
271 /// states in plain words anyway, and a device has to be able to learn the
272 /// policy before it has an account -- otherwise a fresh install would apply
273 /// the shipped figures until somebody signed in, and the one moment the
274 /// operator most wants their policy in force is the first boot.
275 async function refresh() {
276 var j;
277 try {
278 var res = await fetch('/api/policy', { headers: { 'accept': 'application/json' } });
279 if (!res.ok) return false;
280 j = await res.json();
281 } catch (e) { return false; } // offline, or no gateway in this build
282 if (!j || j.ok !== true) return false;
283 return set(j.chat_expire_days, j.trash_retain_days);
284 }
285
286 window.DaimondPolicy = {
287 /// How long a chat may go untouched before the trash takes it, in ms.
288 chatExpireMs: function () { return load().expire * DAY; },
289 /// How long the trash keeps what it is given FROM NOW ON, in ms. What it
290 /// is keeping already goes by the term pinned on each record.
291 trashRetainMs: function () { return load().retain * DAY; },
292 /// The retention to pin on a record being trashed now, in days.
293 retainDays: function () { return load().retain; },
294 /// How long a tombstone -- and a restore record -- must be kept: long
295 /// enough to outlive the longest-lived peer this device knows of. See
296 /// the header on why this is a high-water and not the current figure.
297 tombTtlMs: function () { return (load().high + GRACE_DAYS) * DAY; },
298 noteRetain: noteRetain,
299 /// The two figures in days, for the sentences that quote them.
300 days: function () { var p = load(); return { expire: p.expire, retain: p.retain }; },
301 set: set,
302 refresh: refresh,
303 /// Drop the cache, for an account switch and for the verifiers.
304 reset: function () { _cache = null; },
305 };
306
307 // Ask once per boot. Nothing waits on it: the cached or shipped figures are
308 // already in force, and this only replaces them.
309 try { refresh(); } catch (e) { /* no fetch in this environment */ }
310})();
311(function () {
312 'use strict';
313
314 var KEY = 'daimond-trash'; // per account: accounts.js namespaces `daimond-*`.
315 var DAY = 24 * 3600 * 1000;
316
317 /// How long a thing trashed NOW is to be kept, in days. Pinned onto the
318 /// record at that moment; from then on the record's own `r` is the term, so
319 /// an operator moving the knob cannot shorten what is already in the bin.
320 function retainDays() {
321 try { return DaimondPolicy.retainDays(); }
322 catch (e) { return 30; } // no policy module: the shipped figure
323 }
324
325 /// How long a RESTORED record is kept after the restore. It is the
326 /// counterpart of a tombstone -- proof that a restore happened, so a stale
327 /// `at` from another device cannot union its way back in -- and it has to
328 /// outlive the peer holding that stale `at`, which holds it until its own
329 /// retention is up. Same term as `TOMB_TTL` in daimond.js, for exactly the
330 /// same reason, and both come from the one place that works it out.
331 function backTtl() {
332 try { return DaimondPolicy.tombTtlMs(); }
333 catch (e) { return 37 * DAY; }
334 }
335
336 var _items = null; // id -> { k, at, back, a, r }, or null before the first read
337 var _subs = []; // redraw callbacks
338
339 function log(/* ...args */) {
340 try { if (window.console && console.debug) console.debug.apply(console, ['[trash]'].concat([].slice.call(arguments))); }
341 catch (e) { /* no console */ }
342 }
343
344 /// A millisecond stamp, or 0. NOT `n | 0`: an epoch-ms value is far past 32
345 /// bits and the truncation is inconsistently wrong, so a fresher stamp can
346 /// come out smaller than an older one and the freshest side loses. daimond.js
347 /// keeps its own copy of this rule for the same reason.
348 function ms(v) {
349 return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0;
350 }
351
352 /// One record, defended against whatever arrived. A parcel is another
353 /// device's work and a merge must never be the thing that throws.
354 ///
355 /// A record with no `r` predates the retention being settable, or came from
356 /// a device that still does. It is given the retention in force here, which
357 /// is the best answer available: the alternative is a record with no
358 /// destruction date at all.
359 function clean(r) {
360 if (!r || typeof r !== 'object') return null;
361 var k = (r.k === 'd') ? 'd' : 'c';
362 var at = ms(r.at), back = ms(r.back);
363 if (!at && !back) return null;
364 var days = (typeof r.r === 'number' && isFinite(r.r) && r.r >= 1) ? Math.floor(r.r) : retainDays();
365 return { k: k, at: at, back: back, a: r.a ? 1 : 0, r: days };
366 }
367
368 function load() {
369 if (_items) return _items;
370 _items = {};
371 try {
372 var raw = JSON.parse(localStorage.getItem(KEY) || '{}') || {};
373 var items = raw.items || {};
374 Object.keys(items).forEach(function (id) {
375 var r = clean(items[id]);
376 if (r) _items[id] = r;
377 });
378 } catch (e) { _items = {}; }
379 return _items;
380 }
381
382 function save() {
383 try { localStorage.setItem(KEY, JSON.stringify({ v: 1, items: sorted(load()) })); }
384 catch (e) { log('could not write the trash record', e); }
385 }
386
387 /// The map with its ids in order and each record's fields in a fixed order.
388 ///
389 /// The parcel is compared byte-for-byte against the last one pushed, so a
390 /// section whose serialisation followed storage's enumeration order would
391 /// push for ever. This is the same discipline `DaimondPause.snapshot` keeps
392 /// and for exactly the same reason.
393 function sorted(items) {
394 var out = {};
395 Object.keys(items).sort().forEach(function (id) {
396 var r = items[id];
397 out[id] = { k: r.k, at: r.at, back: r.back, a: r.a ? 1 : 0, r: r.r };
398 });
399 return out;
400 }
401
402 function announce() {
403 _subs.forEach(function (f) { try { f(); } catch (e) { log('subscriber threw', e); } });
404 }
405
406 /// Is this id in the trash right now?
407 function has(id) {
408 if (!id) return false;
409 var r = load()[id];
410 return !!r && r.at > r.back;
411 }
412
413 /// Move something to the trash. `kind` is 'chat' or 'diamond'.
414 ///
415 /// The stamp is always NOW, never carried from anywhere: trashing is an act
416 /// taken on this device at this moment, and a stamp copied from an older
417 /// record would be a trashing that a restore elsewhere could not outrank.
418 function put(id, kind) {
419 if (!id) return false;
420 var items = load();
421 var r = items[id] || { k: 'c', at: 0, back: 0, a: 0, r: retainDays() };
422 r.k = (kind === 'diamond' || kind === 'd') ? 'd' : 'c';
423 r.at = Math.max(Date.now(), r.back + 1); // strictly later than any restore it must outrank
424 r.a = 0; // a person did this, whatever put it here before
425 r.r = retainDays(); // the term it is going in under, pinned now
426 items[id] = r;
427 save();
428 announce();
429 return true;
430 }
431
432 /// A chat whose time ran out, put in the trash by the clock rather than by
433 /// anybody. `at` is WHEN IT RAN OUT, which is usually in the past.
434 ///
435 /// THE STAMP IS THE CALLER'S AND IS NEVER `Date.now()`. The caller works it
436 /// out as `chat.updatedAt + <the expiry window>`, a pure function of a stamp
437 /// both devices already hold, so a device that notices on the day and a
438 /// device that notices three weeks later write the same record byte for
439 /// byte. Their union is a no-op and the retention clock does not restart.
440 /// See this file's header for what stamping `Date.now()` here would cost.
441 ///
442 /// It refuses twice, and the refusals are the safety:
443 ///
444 /// * ALREADY IN THE TRASH -- nothing to do, and doing it anyway would push
445 /// the destruction date out every time the sweep ran.
446 /// * A RESTORE OUTRANKS IT -- `at <= back` means a person took this back
447 /// out after the deadline being offered, and an unattended machine does
448 /// not overrule that. The chat has to be touched again (which it is, on
449 /// restore) before a later deadline can put it here.
450 ///
451 /// Returns true only when the record actually moved.
452 function expire(id, kind, at) {
453 if (!id) return false;
454 var when = ms(at);
455 if (!when) return false;
456 var items = load();
457 var r = items[id];
458 if (r && r.at > r.back) return false; // already in the trash
459 if (r && when <= r.back) return false; // a restore outranks this deadline
460 r = r || { k: 'c', at: 0, back: 0, a: 0, r: retainDays() };
461 r.k = (kind === 'diamond' || kind === 'd') ? 'd' : 'c';
462 r.at = when;
463 r.a = 1; // nobody pressed anything; the panel says so
464 r.r = retainDays();
465 items[id] = r;
466 save();
467 announce();
468 return true;
469 }
470
471 /// Take something back out. The mirror image of `put`, and the reason the
472 /// record survives the restore rather than being deleted: the record IS the
473 /// evidence that stops another device's stale trashing burying it.
474 function back(id) {
475 if (!id) return false;
476 var items = load();
477 var r = items[id];
478 if (!r) return false;
479 r.back = Math.max(Date.now(), r.at + 1);
480 save();
481 announce();
482 return true;
483 }
484
485 /// Forget the record entirely: the thing it is about no longer exists,
486 /// because it was destroyed for good or because it never arrived here.
487 function forget(id) {
488 var items = load();
489 if (!(id in items)) return false;
490 delete items[id];
491 save();
492 announce();
493 return true;
494 }
495
496 /// Which ids are in the trash, when each went in, when each stops existing,
497 /// and whether it was put there by a person or by the clock.
498 ///
499 /// `due` is carried rather than left to the caller because it is a function
500 /// of the record's OWN pinned retention and not of anything global -- the
501 /// one place that knows it is here.
502 function ids() {
503 var items = load(), out = [];
504 Object.keys(items).forEach(function (id) {
505 var r = items[id];
506 if (r.at > r.back) out.push({
507 id: id,
508 kind: r.k === 'd' ? 'diamond' : 'chat',
509 at: r.at,
510 due: r.at + r.r * DAY,
511 auto: !!r.a,
512 });
513 });
514 // Newest first, which is what the panel shows and what a person looking
515 // for the thing they just deleted expects to find at the top.
516 out.sort(function (a, b) { return b.at - a.at || (a.id < b.id ? -1 : 1); });
517 return out;
518 }
519
520 /// When one trashed item is destroyed for good, by id.
521 ///
522 /// Read off the RECORD, because the record carries the retention it went in
523 /// under. Asking the current policy instead would let an operator lowering
524 /// the knob move the destruction date of everything already in the bin --
525 /// forward, past dates the panel has already shown people.
526 function dueAt(id) {
527 var r = load()[id];
528 return r ? r.at + r.r * DAY : 0;
529 }
530
531 /// Which trashed ids are past their retention, and which restored records
532 /// have outlived their usefulness. The caller destroys the first list --
533 /// only it knows how to delete a chat or a Diamond -- and this drops the
534 /// second on the spot, since a record with nothing left to protect is only
535 /// weight in every parcel from now on.
536 function sweep() {
537 var items = load(), now = Date.now(), expired = [], dropped = 0, ttl = backTtl();
538 Object.keys(items).forEach(function (id) {
539 var r = items[id];
540 if (r.at > r.back) {
541 // The record's OWN term, not the current policy's: this item went
542 // in under a figure the panel has already shown, and lowering the
543 // knob must not bring that date forward.
544 if (now >= r.at + r.r * DAY) {
545 expired.push({ id: id, kind: r.k === 'd' ? 'diamond' : 'chat', at: r.at, auto: !!r.a });
546 }
547 return;
548 }
549 // Restored, and long enough ago that no peer can still be holding the
550 // trashing it outranked -- which is its own retention away, not the
551 // life of a parcel. See `backTtl`.
552 if (now - r.back >= ttl) { delete items[id]; dropped++; }
553 });
554 if (dropped) { save(); announce(); }
555 return expired;
556 }
557
558 // ── The sync parcel ────────────────────────────────────────
559
560 /// What travels. Stable bytes for stable state — see `sorted`.
561 function snapshot() { return { v: 1, items: sorted(load()) }; }
562
563 /// Merge a record from another device. True when this device moved.
564 ///
565 /// LATER OF EACH STAMP, INDEPENDENTLY. That is the whole merge, and it is
566 /// what makes the result the same whichever device runs it and whichever
567 /// order the parcels arrive in. Nothing here stamps on the way in: a device
568 /// that restamped what it adopted would push it straight back, and two
569 /// devices would tell each other about the same trashing for ever.
570 function adopt(rec) {
571 if (!rec || typeof rec !== 'object') return false;
572 var incoming = rec.items || {};
573 var items = load(), moved = false;
574 Object.keys(incoming).forEach(function (id) {
575 var r = clean(incoming[id]);
576 if (!r) return;
577 // Whatever term the sender is working to, this device's tombstones
578 // have to outlive it -- so the figure is taken off every record that
579 // arrives, whether or not the record itself is news. See the policy
580 // header on why the high-water is fed by evidence.
581 try { DaimondPolicy.noteRetain(r.r); } catch (e) { /* no policy module */ }
582 var mine = items[id];
583 if (!mine) { items[id] = r; moved = true; return; }
584 // `a` and `r` describe the TRASHING, so they travel with `at` and are
585 // taken exactly when it is. Taken independently they would describe a
586 // stamp that lost, which is how a record comes to say it was expired
587 // by a clock on a date somebody pressed a button.
588 if (r.at > mine.at) { mine.at = r.at; mine.a = r.a; mine.r = r.r; moved = true; }
589 else if (r.at === mine.at) {
590 // The same trashing reached here twice. Two devices can only
591 // disagree about it if one was a person and the other the clock --
592 // which happens when a chat is deleted by hand at the exact
593 // moment its deadline passed elsewhere. The person wins, because
594 // the panel would otherwise tell them a clock did what they did,
595 // and both devices reach that answer whichever way the parcel ran.
596 if (mine.a && !r.a) { mine.a = 0; moved = true; }
597 // And the LONGER term wins a tie -- the two devices cached
598 // different figures across the instant the knob moved. Longer,
599 // on the same asymmetry the whole file is built on: keeping
600 // something past its date costs a few bytes, destroying it before
601 // its date costs the work. It settles as soon as both refresh.
602 if (r.r > mine.r) { mine.r = r.r; moved = true; }
603 }
604 if (r.back > mine.back) { mine.back = r.back; moved = true; }
605 // A record that arrived naming a Diamond where this device thinks a
606 // chat is disagrees about the thing itself, not about its state. The
607 // far end is as likely to be right as this one, and the kind is only
608 // used to decide which store to look in — so the arriving one is taken
609 // and the lookup, which asks both stores anyway, settles it.
610 if (r.k !== mine.k) { mine.k = r.k; moved = true; }
611 });
612 if (moved) { save(); announce(); }
613 return moved;
614 }
615
616 /// Drop everything held, for an account switch: one account's trash must
617 /// never show in another's panel.
618 function reset() { _items = null; }
619
620 // Another tab moved the trash. localStorage fires this in the OTHER tabs
621 // only, which is exactly what is wanted: this one has already redrawn.
622 window.addEventListener('storage', function (e) {
623 if (e.key !== KEY && !(e.key && e.key.indexOf(KEY) !== -1)) return;
624 _items = null;
625 announce();
626 });
627
628 window.DaimondTrash = {
629 has: has,
630 put: put,
631 expire: expire,
632 back: back,
633 forget: forget,
634 ids: ids,
635 sweep: sweep,
636 dueAt: dueAt,
637 snapshot: snapshot,
638 adopt: adopt,
639 reset: reset,
640 subscribe: function (fn) { if (typeof fn === 'function') _subs.push(fn); },
641 /// How long a thing trashed NOW would be kept, in ms. Read by the panel
642 /// so the sentence a user sees and the rule the sweep applies are one
643 /// number. What is already in the bin goes by its own pinned term, which
644 /// is why every tile states its own date rather than sharing this one.
645 retainMs: function () { return retainDays() * DAY; },
646 /// Whether a trashed thing got there by itself.
647 isAuto: function (id) { var r = load()[id]; return !!(r && r.a && r.at > r.back); },
648 /// Every record, trashed or restored, for a verifier and for the sync
649 /// tests. The live API above answers questions; this shows the workings.
650 raw: function () { return sorted(load()); },
651 };
652})();
653
654/* ============================================================
655 The Trash panel
656 ------------------------------------------------------------
657 A dock panel like the others: tiles NEWEST FIRST, Restore and
658 Delete permanently on each, Restore all and Empty trash for the
659 lot, and — at the head — how much the trash is actually holding.
660
661 THE HEADER'S TOTAL IS NOT DECORATION. A trash that grows in
662 silence is how somebody's storage fills up with things they
663 believe they deleted, and the browser's quota does not care what
664 the user believes. So the panel says the number, and every tile
665 says the day its item stops existing.
666
667 THE TILES ARE THE ATTACHMENT TILES (ATTACH_CONTRACT.md §9). That
668 component already had to render an item that cannot be opened
669 and say why, for an attachment recorded against a workspace that
670 is not open; a trashed thing is the same shape of thing, and the
671 contract said in advance to build it once.
672
673 THE TWO QUESTIONS LIVE HERE, and nowhere else in the flow.
674 Deleting to the trash asks nothing at all. Destroying one item
675 asks, naming it; emptying the trash asks, naming the count. That
676 is the whole of the ceremony, spent where it buys something.
677 ============================================================ */
678(function () {
679 'use strict';
680
681 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
682 /// A string with the English written at the call site as its fallback.
683 ///
684 /// `t` answers with the KEY when the table has no entry, so a panel built
685 /// against a key the locale files have not been given yet reads
686 /// "trash.expired_why" on screen. The seven translations are routed
687 /// separately from the code that needs them and may land after it, so every
688 /// string this release adds goes through here: the English shows until the
689 /// tables catch up, and not one moment longer. The Search row in daimond.js
690 /// keeps the same discipline for the same reason.
691 function tOr(k, fallback, v) {
692 var s = t(k, v);
693 if (s !== k) return s;
694 if (!v) return fallback;
695 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
696 return v[name] != null ? String(v[name]) : whole;
697 });
698 }
699 function tn(k, n, v) { return window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k; }
700 function core() { return window.DaimondCore || null; }
701
702 var listEl = null, noteEl = null, countEl = null;
703 var drawing = false; // one render at a time; the store reads are async
704
705 function el(id) { return document.getElementById(id); }
706
707 /// A size, in the units a person reads. The twin of `fmtBytes` in
708 /// daimond.js -- this file is a classic script and cannot reach into that
709 /// closure, and a panel about how much storage is being held cannot be the
710 /// one that says "2411008".
711 function fmtBytes(n) {
712 if (!n) return '0 B';
713 var u = ['B', 'KB', 'MB', 'GB'], i = 0;
714 while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
715 return (i === 0 ? n : n.toFixed(1)) + ' ' + u[i];
716 }
717
718 /// The day something stops existing, in the language the APP is in. A date
719 /// and not "in 27 days": the question a person asks of a trash is whether
720 /// the thing will still be there when they get back on Monday.
721 ///
722 /// The locale comes from `DaimondI18n`, not from `undefined`. Left to the
723 /// browser, a reader who has put Daimond into German reads every other word
724 /// on this row in German and the date in whatever their browser was
725 /// installed as — which was "Sep 10, 2026" in the first screenshot of the
726 /// finished panel.
727 function fmtDate(ms) {
728 var loc;
729 try { loc = window.DaimondI18n ? DaimondI18n.locale() : undefined; }
730 catch (e) { loc = undefined; }
731 try { return new Date(ms).toLocaleDateString(loc || undefined, { day: 'numeric', month: 'short', year: 'numeric' }); }
732 catch (e) { return ''; }
733 }
734
735 /// Draw the panel from the store. Safe to call at any time; it is what every
736 /// action here ends with.
737 async function render() {
738 listEl = el('trash-list');
739 noteEl = el('trash-note');
740 countEl = el('trash-count');
741 if (!listEl || !core() || !core().trashList) return;
742 if (drawing) return;
743 drawing = true;
744 var items;
745 try { items = await core().trashList(); }
746 catch (e) { items = []; }
747 finally { drawing = false; }
748
749 listEl.innerHTML = '';
750 var bytes = items.reduce(function (a, i) { return a + (i.bytes || 0); }, 0);
751 var days = Math.round(DaimondTrash.retainMs() / 86400000);
752
753 if (countEl) countEl.textContent = items.length ? String(items.length) : '';
754 if (noteEl) {
755 // What it holds comes FIRST, before the rule: the number is the news
756 // and the retention is the standing explanation beside it.
757 noteEl.textContent = items.length
758 ? tn('trash.holding', items.length, { n: items.length, bytes: fmtBytes(bytes) })
759 + ' · ' + t('trash.kept_days', { days: days })
760 : t('trash.kept_days', { days: days });
761 }
762 // The two bulk controls are pressable exactly when there is something for
763 // them to act on -- disabled rather than hidden, so the head does not
764 // change shape as the list empties.
765 ['trash-restore-all', 'trash-empty'].forEach(function (id) {
766 var b = el(id);
767 if (b) b.disabled = !items.length;
768 });
769
770 if (!items.length) {
771 var none = document.createElement('div');
772 none.className = 'rail-note';
773 none.textContent = t('trash.nothing');
774 listEl.appendChild(none);
775 return;
776 }
777
778 items.forEach(function (it) {
779 listEl.appendChild(core().attachTile({
780 kind: t(it.kind === 'diamond' ? 'trash.kind_diamond' : 'trash.kind_chat'),
781 // A name, where an attachment passes a path. The component shows
782 // whatever it is given and does not care which.
783 path: it.name,
784 // There is no opening this one, and the component's own word for
785 // that is `shut` -- the same state an attachment wears when the
786 // workspace it was made in is not open. Built once, per §9.
787 shut: true,
788 // WHY it is here, and the two are not the same news. A chat that
789 // ran out of time was not deleted by anybody, and telling
790 // somebody they deleted something they did not is how they come
791 // to distrust the panel that is holding their work.
792 reason: it.auto
793 ? tOr('trash.expired_why', 'Its time ran out. It is only here.')
794 : t('trash.deleted_why'),
795 // The two facts that VARY per row, and the two the design asks
796 // for by name: the day this stops existing, and what it costs to
797 // keep. Their own line, below the reason.
798 note: t('trash.until', { date: fmtDate(it.due) }) + ' · ' + fmtBytes(it.bytes),
799 // STACK, always. The icon view is the attachment footers' choice
800 // and their toggle sets it; an 88px cell cannot show a date and a
801 // size, and this panel has no toggle to get back with.
802 view: 'stack',
803 actions: rowActions(it),
804 }));
805 });
806 }
807
808 /// What may be done to one row.
809 ///
810 /// A TRASHED CHAT CAN STILL BE KEPT, and that is the whole reason this list
811 /// is built rather than written out. A chat arrives here on its own now, so
812 /// the trash is where somebody meets a conversation they had forgotten and
813 /// realises it mattered after all -- and at that moment the useful act is not
814 /// to put it back on the rail to expire again in three days, it is to make a
815 /// Diamond of it. Restore is still there for the other case.
816 ///
817 /// Not offered on a Diamond: it is one already.
818 function rowActions(it) {
819 var out = [];
820 if (it.kind !== 'diamond' && core() && core().keepAsDiamond) {
821 out.push({
822 cls: 'trash-keep',
823 text: tOr('trash.keep', 'Keep'),
824 title: tOr('trash.keep_help',
825 'Make a Diamond of this chat, with the whole conversation as its first artefact.'),
826 aria: tOr('trash.keep_named', 'Keep {name} as a Diamond', { name: it.name }),
827 on: function () { keep(it); },
828 });
829 }
830 out.push({
831 cls: 'trash-restore',
832 text: t('trash.restore'),
833 title: t('trash.restore'),
834 aria: t('trash.restore_named', { name: it.name }),
835 on: function () { restore(it); },
836 });
837 out.push({
838 cls: 'arte-drop trash-purge',
839 text: '×',
840 title: t('trash.purge'),
841 aria: t('trash.purge_named', { name: it.name }),
842 on: function () { purge(it); },
843 });
844 return out;
845 }
846
847 /// Make a Diamond of a trashed chat, carrying its transcript in.
848 ///
849 /// The chat is LEFT IN THE TRASH afterwards, deliberately. Its content is in
850 /// the Diamond now, which is the durable thing; putting the chat back on the
851 /// rail as well would leave two copies of one conversation and one of them
852 /// on a three-day clock. The panel redraws either way, because the act may
853 /// have been cancelled at the name.
854 async function keep(it) {
855 if (!core() || !core().keepAsDiamond) return;
856 try { await core().keepAsDiamond(it.id); }
857 catch (e) { /* the core has already said so on screen */ }
858 await render();
859 }
860
861 /// Put one thing back. No question: it is the undo.
862 async function restore(it) {
863 if (!core() || !core().trashRestore) return;
864 await core().trashRestore(it.id);
865 await render();
866 }
867
868 /// Destroy one thing, ASKING FIRST and naming it. This is where the
869 /// ceremony that used to sit in front of an ordinary delete has gone.
870 async function purge(it) {
871 if (!core() || !core().trashPurge) return;
872 var ok = await core().confirm(
873 t('trash.purge_ask', { name: it.name }),
874 t('trash.purge_ok'),
875 { title: t('trash.purge') });
876 if (!ok) return;
877 await core().trashPurge(it.id);
878 await render();
879 }
880
881 /// Everything back on the rail, in one press.
882 async function restoreAll() {
883 if (!core() || !core().trashList) return;
884 var items = await core().trashList();
885 for (var i = 0; i < items.length; i++) await core().trashRestore(items[i].id);
886 await render();
887 }
888
889 /// Destroy the lot, ASKING FIRST and NAMING THE COUNT.
890 ///
891 /// The count is in the question because it is the only thing that
892 /// distinguishes emptying a trash holding one abandoned draft from emptying
893 /// one holding a fortnight's work. This is the same reasoning the deleted
894 /// "Delete all 14 chats?" dialog was written from -- it was simply attached
895 /// to the wrong act, where there was still a way back.
896 async function empty() {
897 if (!core() || !core().trashList) return;
898 var items = await core().trashList();
899 if (!items.length) return;
900 var ok = await core().confirm(
901 tn('trash.empty_ask', items.length, { n: items.length }),
902 t('trash.empty_ok'),
903 { title: t('trash.empty') });
904 if (!ok) return;
905 for (var i = 0; i < items.length; i++) await core().trashPurge(items[i].id);
906 await render();
907 }
908
909 /// The panel was opened. Retention is applied here as well as at the boot:
910 /// this is the moment somebody is reading the dates, so it is the last
911 /// moment a date that has passed may still be on screen.
912 function onOpen() {
913 if (core() && core().trashSweep) { core().trashSweep().then(render, render); return; }
914 render();
915 }
916
917 // The head's own two controls, bound once by delegation so a panel that has
918 // not been drawn yet still answers.
919 document.addEventListener('click', function (e) {
920 var b = e.target && e.target.closest ? e.target.closest('[data-act]') : null;
921 if (!b) return;
922 if (b.dataset.act === 'trash-restore-all') { e.preventDefault(); restoreAll(); }
923 else if (b.dataset.act === 'trash-empty') { e.preventDefault(); empty(); }
924 });
925
926 // Something was trashed or restored somewhere else -- another tab, or a
927 // parcel that has just landed. The panel is the one surface that must agree
928 // with the record at all times, since it is the only place the record is
929 // visible at all.
930 try {
931 DaimondTrash.subscribe(function () {
932 if (window.DaimondPanels && DaimondPanels.isOpen && DaimondPanels.isOpen('trash')) render();
933 });
934 } catch (e) { /* the store is not up; nothing to draw from */ }
935
936 // Say the panel's own words again in a new language. Every string on a tile
937 // is built here rather than marked up in the HTML — the reason, the date,
938 // the size, both buttons — so a language change reaches none of them unless
939 // this surface is registered. `surface` redraws only while the panel is
940 // showing, which is what it is for.
941 try {
942 // A function, not the node: this file is a classic script and the panel
943 // it draws into is markup further up the same document, so looking the
944 // node up at registration time is a bet on parse order.
945 DaimondI18n.surface(function () { return document.getElementById('panel-trash'); },
946 function () { render(); });
947 } catch (e) { /* no i18n in this build */ }
948
949 window.DaimondTrashPanel = {
950 onOpen: onOpen,
951 render: render,
952 restore: function (id) { return restore({ id: id }); },
953 restoreAll: restoreAll,
954 empty: empty,
955 };
956})();