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