oxedyne/daimond/www/js/group.js
64.0 KiB, 1 run
created by r2519314175:1373, 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 — groups (group.js) |
| 3 | ------------------------------------------------------------ |
| 4 | A GROUP IS A MEMBERSHIP LIST AND THERE IS NO GROUP KEY. |
| 5 | |
| 6 | A message to a group is one envelope sealed once per member, |
| 7 | which is the recipient-slot list in post.js doing the work it |
| 8 | was reserved for. Membership changes need no re-keying because |
| 9 | there is no key to change: a message is sealed to whoever is a |
| 10 | member at the moment it is sent. That single decision removes |
| 11 | group-key rotation, which is the part of group cryptography |
| 12 | that is hard. |
| 13 | |
| 14 | ── THE THREE THINGS THAT MUST BE ON SCREEN ───────────────── |
| 15 | |
| 16 | Each is a consequence of the design and none is a defect, so |
| 17 | each is said in the interface rather than left in a comment |
| 18 | where the only person who reads it is the next person to |
| 19 | write the code. |
| 20 | |
| 21 | 1. JOINING SHOWS NOTHING EARLIER. A new member cannot read |
| 22 | what was sent before they joined, because those envelopes |
| 23 | were never sealed to them. Drawn on the invitation, before |
| 24 | the Join press, not after it. |
| 25 | |
| 26 | 2. REMOVING SOMEBODY RETRACTS NOTHING. They keep every |
| 27 | message already delivered to them. The control therefore |
| 28 | says "stop sending to", never "remove", and the sentence |
| 29 | is beside it before the press. Anything else would be a |
| 30 | promise this design cannot keep. |
| 31 | |
| 32 | 3. CLOSING IS FINAL, AND IT DESTROYS NOTHING. A group is |
| 33 | closed by its creator writing a roster that names NOBODY |
| 34 | -- because a group IS its membership list, so a list with |
| 35 | nobody on it is the group being over. Everybody keeps |
| 36 | every message; nobody can write to it again, the creator |
| 37 | included. Said in one confirmation dialogue before the |
| 38 | press, because it cannot be undone. |
| 39 | |
| 40 | ── WHERE THE LIST LIVES, AND WHAT THE RELAY LEARNS ───────── |
| 41 | |
| 42 | In the clients, and nowhere else. A roster travels as an |
| 43 | ORDINARY SEALED MESSAGE through the relay that already |
| 44 | exists: the gateway gains no group record, no group endpoint |
| 45 | and no group table, and `gateway/src/handlers/post.rs` is |
| 46 | unchanged by this file. |
| 47 | |
| 48 | What the relay learns is therefore what it already learned by |
| 49 | routing: N envelopes sharing one `addr`, landing in N boxes |
| 50 | at one instant, which is a correlation anybody reading the |
| 51 | store can make. It does NOT learn the group's name, its id, |
| 52 | who created it, or that these deliveries are a group rather |
| 53 | than a broadcast. §12.6 says the member list is visible to |
| 54 | the relay and that is true — but by INFERENCE and not by |
| 55 | record, which is the better of the two positions: the blinded |
| 56 | mailbox ids §12.7 defers would remove the inference outright, |
| 57 | whereas a stored roster would have to be designed away. |
| 58 | |
| 59 | ── WHO MAY ADD AND REMOVE, AND WHY IT IS NOT A POLICY ────── |
| 60 | |
| 61 | The creator, and only the creator. That is §12.6's first cut, |
| 62 | and here it is not a rule a client could decline to enforce — |
| 63 | it is an identity: |
| 64 | |
| 65 | gid = SHA-256("daimond.group.id.v1" ‖ creatorPub ‖ salt) |
| 66 | |
| 67 | A roster is obeyed only where the id recomputed from the |
| 68 | op's OWN author key and its OWN salt equals the `to` the |
| 69 | signature covers. Nobody but the holder of that signing key |
| 70 | can produce a roster for that group, so "creator only" costs |
| 71 | no membership-operation schema, no signed-op ordering and no |
| 72 | story about two people editing membership at once — which is |
| 73 | the convergence problem §12.6 declines to open. |
| 74 | |
| 75 | It also makes the invitation self-authenticating: a device |
| 76 | that has never heard of a group can check the first roster it |
| 77 | is sent, because everything needed to check it is inside it. |
| 78 | |
| 79 | Attaches one global, `window.DaimondGroup`. |
| 80 | ============================================================ */ |
| 81 | (function () { |
| 82 | 'use strict'; |
| 83 | |
| 84 | // ── Saying things ────────────────────────────────────────── |
| 85 | |
| 86 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 87 | |
| 88 | /// A string from the table, or the English written at the call site where the |
| 89 | /// table has no entry for it yet. post.js's `tOr`, and for the same reason. |
| 90 | function tOr(k, fallback, v) { |
| 91 | var s = t(k, v); |
| 92 | if (s !== k) return s; |
| 93 | if (!v) return fallback; |
| 94 | return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) { |
| 95 | return v[name] != null ? String(v[name]) : whole; |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | function log(/* ...args */) { |
| 100 | try { |
| 101 | if (!window.DAIMOND_DEBUG) return; |
| 102 | console.log.apply(console, ['[group]'].concat([].slice.call(arguments))); |
| 103 | } catch (e) { /* no console */ } |
| 104 | } |
| 105 | |
| 106 | // ── The op ───────────────────────────────────────────────── |
| 107 | // |
| 108 | // A roster is a `daimond/post/0` payload like any other, because a new |
| 109 | // schema would mean fe2o3_sbj, the wasm write side and a rebuilt bundle for |
| 110 | // a record with five fields in it. What marks it is the first line of the |
| 111 | // body, and post.js refuses to SEND a body whose first line is this — so no |
| 112 | // message a person wrote can be mistaken for a roster. |
| 113 | // |
| 114 | // The marker is not a security boundary and is not asked to be one. A |
| 115 | // hostile client forging a roster is refused by the id derivation, which is |
| 116 | // where the authorisation actually is; the marker only keeps an honest |
| 117 | // person's prose out of the roster path. |
| 118 | |
| 119 | /// The first line of every roster body. |
| 120 | var MARK = 'daimond.group.op.v1'; |
| 121 | |
| 122 | /// The domain the group id is derived in. Not a prefix of any other tag. |
| 123 | var ID_INFO = 'daimond.group.id.v1'; |
| 124 | |
| 125 | /// The salt's exact width, in bytes. |
| 126 | var SALT = 16; |
| 127 | |
| 128 | /// The most members a roster may name. |
| 129 | /// |
| 130 | /// The slot count in post.js's envelope is ONE BYTE, so 255 is the hard |
| 131 | /// ceiling of the seal itself and not a policy. See the fan-out arithmetic |
| 132 | /// in post.js, which reaches its practical wall well before this. |
| 133 | var MEMBERS_MAX = 255; |
| 134 | |
| 135 | /// The most a group name may carry, in characters. A name is a label its |
| 136 | /// author chose, drawn beside the id for the same reason a card's label is |
| 137 | /// drawn beside a fingerprint. |
| 138 | var NAME_MAX = 64; |
| 139 | |
| 140 | // ── Encoding ─────────────────────────────────────────────── |
| 141 | |
| 142 | function utf8(s) { return new TextEncoder().encode(String(s)); } |
| 143 | |
| 144 | function hex(bytes) { |
| 145 | var s = ''; |
| 146 | for (var i = 0; i < bytes.length; i++) s += ('0' + bytes[i].toString(16)).slice(-2); |
| 147 | return s; |
| 148 | } |
| 149 | |
| 150 | function unhex(s) { |
| 151 | var str = String(s || ''); |
| 152 | var out = new Uint8Array(str.length >> 1); |
| 153 | for (var i = 0; i < out.length; i++) out[i] = parseInt(str.substr(i * 2, 2), 16); |
| 154 | return out; |
| 155 | } |
| 156 | |
| 157 | /// Whether a string is exactly `n` bytes of lowercase hexadecimal. |
| 158 | function isHex(s, n) { |
| 159 | return typeof s === 'string' && s.length === n * 2 && /^[0-9a-f]+$/.test(s); |
| 160 | } |
| 161 | |
| 162 | /// A millisecond timestamp, kept whole. `| 0` would coerce it to a signed |
| 163 | /// 32-bit integer, and `Date.now()` is about 1.79e12 -- see post.js's `ms`, |
| 164 | /// which says what that costs and why it is invisible for weeks at a time. |
| 165 | function ms(v) { |
| 166 | var n = Number(v); |
| 167 | return isFinite(n) ? Math.trunc(n) : 0; |
| 168 | } |
| 169 | |
| 170 | function cat(parts) { |
| 171 | var n = 0, i; |
| 172 | for (i = 0; i < parts.length; i++) n += parts[i].length; |
| 173 | var out = new Uint8Array(n), at = 0; |
| 174 | for (i = 0; i < parts.length; i++) { out.set(parts[i], at); at += parts[i].length; } |
| 175 | return out; |
| 176 | } |
| 177 | |
| 178 | function b64enc(buf) { |
| 179 | var b = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf); |
| 180 | var bin = ''; |
| 181 | for (var i = 0; i < b.length; i++) bin += String.fromCharCode(b[i]); |
| 182 | return btoa(bin); |
| 183 | } |
| 184 | |
| 185 | /// Standard base64 to the base64url the gateway binds an account by. |
| 186 | function b64url(b64) { |
| 187 | return String(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 188 | } |
| 189 | |
| 190 | /// A signing key in hex, as the base64url spelling the relay addresses by. |
| 191 | function pubOf(keyHex) { return b64url(b64enc(unhex(keyHex))); } |
| 192 | |
| 193 | // ── The identity of a group ──────────────────────────────── |
| 194 | |
| 195 | /// Derive a group's id from its creator's signing key and its salt. |
| 196 | /// |
| 197 | /// This is the whole of the authorisation model. The id is what the payload's |
| 198 | /// signed `to` carries, so a roster verifies for exactly one signing key and |
| 199 | /// nobody else can mint one for the same group without finding a SHA-256 |
| 200 | /// preimage. It also means a device seeing a group for the first time can |
| 201 | /// check the roster that introduces it without knowing anything beforehand. |
| 202 | async function deriveId(creatorHex, saltHex) { |
| 203 | if (!isHex(creatorHex, 32) || !isHex(saltHex, SALT)) return ''; |
| 204 | var bits = await crypto.subtle.digest('SHA-256', |
| 205 | cat([utf8(ID_INFO), unhex(creatorHex), unhex(saltHex)])); |
| 206 | return hex(new Uint8Array(bits)); |
| 207 | } |
| 208 | |
| 209 | // ── The store, which is post.js's ────────────────────────── |
| 210 | // |
| 211 | // Groups live in the message record and not in one of their own, and that is |
| 212 | // deliberate rather than convenient. post.js already wraps that record under |
| 213 | // the identity key at rest, re-seals it through `DaimondRekey` on a |
| 214 | // passphrase change, hangs it on the sync parcel and merges an arriving one. |
| 215 | // A second store would be a second thing to wrap, a second thing to re-seal |
| 216 | // and a second section for sync.js to carry — and the roster and the |
| 217 | // messages sealed under it are one account state, so they must merge |
| 218 | // together or a device can hold a message for a group it does not know it is |
| 219 | // in. |
| 220 | |
| 221 | /// The groups map, or null while the identity is locked. |
| 222 | async function all() { |
| 223 | if (!window.DaimondPost || !DaimondPost.groups) return null; |
| 224 | return await DaimondPost.groups(); |
| 225 | } |
| 226 | |
| 227 | /// One group's record, or null. |
| 228 | async function get(gid) { |
| 229 | var g = await all(); |
| 230 | return (g && g[String(gid)]) || null; |
| 231 | } |
| 232 | |
| 233 | /// Write one group's record back and save. |
| 234 | async function put(gid, rec) { |
| 235 | if (!window.DaimondPost || !DaimondPost.putGroup) return false; |
| 236 | return await DaimondPost.putGroup(String(gid), rec); |
| 237 | } |
| 238 | |
| 239 | // ── A record ─────────────────────────────────────────────── |
| 240 | // |
| 241 | // TWO CLOCKS, EACH WITH ONE WRITER, which is what makes the merge in |
| 242 | // post.js `adopt` converge without any ordering machinery: |
| 243 | // |
| 244 | // * the ROSTER half -- `at`, `salt`, `name`, `members` -- is the |
| 245 | // creator's, and only the creator writes it, so the higher `at` wins; |
| 246 | // * the LOCAL half -- `state`, `stateAt` -- is this account's own |
| 247 | // decision, and only this account writes it, so the higher `stateAt` |
| 248 | // wins. |
| 249 | // |
| 250 | // Neither clock is compared against the other and neither is a Lamport |
| 251 | // counter pretending to be a timestamp. A record is: |
| 252 | // |
| 253 | // { gid, creator, salt, name, at, addr, members: [ {k, e, n} ], |
| 254 | // state: 'invited' | 'joined' | 'left', stateAt } |
| 255 | |
| 256 | /// The three states a group is in on THIS device. |
| 257 | var STATE = { INVITED: 'invited', JOINED: 'joined', LEFT: 'left' }; |
| 258 | |
| 259 | /// Whether a state may compose to the group. |
| 260 | function canSend(st) { return st === STATE.JOINED; } |
| 261 | |
| 262 | /// Why nothing more can be sent to a closed group. |
| 263 | /// |
| 264 | /// One place, because three callers refuse for it -- `sealTo`, `roster` and |
| 265 | /// `close` -- and a sentence written out at each of them is three wordings |
| 266 | /// waiting for two of them to stop being read. The same argument that makes |
| 267 | /// `DaimondPost.shortfall` the single authority one level up. |
| 268 | function closedWhy() { |
| 269 | return tOr('group.err_closed', |
| 270 | 'This group has been closed, so nothing more can be sent to it. ' |
| 271 | + 'Every message already here stays where it is.'); |
| 272 | } |
| 273 | |
| 274 | /// Has this group been CLOSED? Its roster names nobody. |
| 275 | /// |
| 276 | /// CLOSED IS NOT A FOURTH FIELD, it is the membership list being empty, and |
| 277 | /// that is the whole reason it works. Three things fall out of the machinery |
| 278 | /// that already exists rather than having to be built: |
| 279 | /// |
| 280 | /// - IT IS AUTHORISED. Only the holder of the creator's signing key can |
| 281 | /// produce a roster for this id, so only they can produce the empty one. |
| 282 | /// - IT TRAVELS BOTH ROADS. `members` is a field `post.js` `adopt` already |
| 283 | /// copies on the roster half, so a second device of the same account |
| 284 | /// converges over the sync parcel; every member converges over the relay |
| 285 | /// through `consume`. A `closed` flag of its own would travel the relay and |
| 286 | /// NOT the parcel, and the creator's other phone is the one device the |
| 287 | /// relay never delivers their own roster to. |
| 288 | /// - IT ENFORCES ITSELF AT THE READERS. `accepts` walks the roster looking |
| 289 | /// for the author; an empty roster contains nobody, so a message to a |
| 290 | /// closed group is refused by every reader for the same reason a removed |
| 291 | /// member's is. There is no group key to rotate and the relay knows |
| 292 | /// nothing, so the readers are the only place it could be. |
| 293 | function isClosed(rec) { |
| 294 | return !!rec && Array.isArray(rec.members) && rec.members.length === 0; |
| 295 | } |
| 296 | |
| 297 | /// AND THE CASE THE THREE STATES DID NOT HAVE: this account wrote the roster. |
| 298 | /// |
| 299 | /// All three are answers to somebody else's invitation, and an author has no |
| 300 | /// invitation to answer -- writing the roster IS the answer. Until this was |
| 301 | /// read, `create` applied its own roster through `consume`, was filed |
| 302 | /// INVITED like any stranger's, and `sealTo` then refused the creator from a |
| 303 | /// group they had just made with "Join this group before writing to it." |
| 304 | /// Nothing in the app ever joined them: the panel's Make branch reports how |
| 305 | /// many people were told and stops. So the state machine was not missing a |
| 306 | /// FOURTH state, it was missing a rule about the three it has -- |
| 307 | /// |
| 308 | /// FOR A GROUP THIS ACCOUNT CREATED, THE STATE IS ALWAYS `joined`. |
| 309 | /// |
| 310 | /// -- which holds because the id is derived from the creator's own signing key |
| 311 | /// and `roster` puts them in every roster it writes, so there is no roster of |
| 312 | /// theirs that does not name them. `invited` is unreachable for an author and |
| 313 | /// so is `left`: both are contradicted by their own next roster. |
| 314 | /// |
| 315 | /// WITH EXACTLY ONE EXCEPTION, and it is the one this rule's own premise names: |
| 316 | /// THE CLOSING ROSTER, which names nobody at all (see `isClosed`). It is the |
| 317 | /// one roster of the creator's that does not name them, so it is the one that |
| 318 | /// puts an author in `left` -- and it has to, because "nobody can write to it |
| 319 | /// again" includes them. `consume` reaches it before this branch: an empty |
| 320 | /// roster fails the membership test first, and the chain below is ordered |
| 321 | /// membership, then authorship, then invitation for that reason. |
| 322 | /// |
| 323 | /// It is read HERE, at the one door a roster becomes a record, and not in |
| 324 | /// `create`, because `create` is one of four ways in. The other three are |
| 325 | /// `setMembers`, the creator's own copy arriving back off the relay, and a |
| 326 | /// second device of the same account reading the same bytes -- and a repair |
| 327 | /// bolted onto `create` would leave that last one filing the account's own |
| 328 | /// group as an invitation on its owner's other phone. |
| 329 | function authoredBy(rec, mineHex) { |
| 330 | return !!mineHex && !!rec && String(rec.creator).toLowerCase() === mineHex; |
| 331 | } |
| 332 | |
| 333 | /// This device's signing key in hex, or '' while the identity is unreadable. |
| 334 | /// |
| 335 | /// Four places asked this question with four copies of the same try/catch, and |
| 336 | /// '' is the answer that makes each of them fail closed: no key compares equal |
| 337 | /// to it, so an unreadable identity is nobody rather than everybody. |
| 338 | async function whoAmI() { |
| 339 | try { |
| 340 | var me = await DaimondIdentity.publicKeyRaw(); |
| 341 | return me ? hex(me) : ''; |
| 342 | } catch (e) { return ''; } |
| 343 | } |
| 344 | |
| 345 | // ── Reading a roster op ──────────────────────────────────── |
| 346 | |
| 347 | /// Whether a message body is a roster op rather than something somebody wrote. |
| 348 | function looksLikeOp(body) { |
| 349 | var s = String(body == null ? '' : body); |
| 350 | return s.slice(0, MARK.length) === MARK |
| 351 | && (s.length === MARK.length || s.charAt(MARK.length) === '\n'); |
| 352 | } |
| 353 | |
| 354 | /// Parse a roster op out of a body, or null. Shape only; the id derivation |
| 355 | /// below is what decides whether it may be obeyed. |
| 356 | function parseOp(body) { |
| 357 | if (!looksLikeOp(body)) return null; |
| 358 | var j = null; |
| 359 | try { j = JSON.parse(String(body).slice(MARK.length + 1)); } |
| 360 | catch (e) { return null; } |
| 361 | if (!j || typeof j !== 'object' || j.op !== 'roster') return null; |
| 362 | if (!isHex(j.salt, SALT)) return null; |
| 363 | // AN EMPTY ROSTER IS LEGAL AND MEANS CLOSED. This read `|| !j.members.length` |
| 364 | // and refused one, so the shape a close travels in was rejected at the door |
| 365 | // by the same function that lets every other roster through. It is still a |
| 366 | // list, still signed, and still verified by the id derivation -- so nobody |
| 367 | // but the creator can produce one. See `isClosed`. |
| 368 | if (!Array.isArray(j.members) || j.members.length > MEMBERS_MAX) return null; |
| 369 | var out = [], seen = {}, i; |
| 370 | for (i = 0; i < j.members.length; i++) { |
| 371 | var m = j.members[i]; |
| 372 | if (!m || !isHex(m.k, 32) || !isHex(m.e, 32)) return null; |
| 373 | // A roster naming one key twice would let a member be counted twice in |
| 374 | // a fan-out and, worse, would let one entry's sealing key disagree with |
| 375 | // another's for the same person. |
| 376 | if (seen[m.k]) return null; |
| 377 | seen[m.k] = 1; |
| 378 | out.push({ k: m.k, e: m.e, n: String(m.n || '').slice(0, NAME_MAX) }); |
| 379 | } |
| 380 | return { salt: j.salt, name: String(j.name || '').slice(0, NAME_MAX), members: out }; |
| 381 | } |
| 382 | |
| 383 | /// Build the body of a roster op. |
| 384 | function opBody(salt, name, members) { |
| 385 | return MARK + '\n' + JSON.stringify({ |
| 386 | op: 'roster', |
| 387 | salt: salt, |
| 388 | name: String(name || '').slice(0, NAME_MAX), |
| 389 | members: members.map(function (m) { return { k: m.k, e: m.e, n: m.n || '' }; }), |
| 390 | }); |
| 391 | } |
| 392 | |
| 393 | // ── What post.js asks this file ──────────────────────────── |
| 394 | |
| 395 | /// Whether an artefact addressed to `toHex` may be opened by this device. |
| 396 | /// |
| 397 | /// post.js calls this when a message's signed `to` is not this account's own |
| 398 | /// signing key, which is the ONE place a group message differs from any |
| 399 | /// other. Two answers are yes, and each carries its own reason: |
| 400 | /// |
| 401 | /// - THE INVITATION. The artefact is a roster op whose id, recomputed from |
| 402 | /// its own author and its own salt, is the `to` it was signed under. It |
| 403 | /// needs no prior knowledge, which is what makes a first roster carriable |
| 404 | /// over the ordinary relay with nothing arranged beforehand. |
| 405 | /// |
| 406 | /// - AN ORDINARY GROUP MESSAGE. There is a known group at that id, this |
| 407 | /// device is in it, and THE AUTHOR IS IN ITS CURRENT ROSTER. |
| 408 | /// |
| 409 | /// That last clause is where a removal is enforced, and it is the only place |
| 410 | /// it can be. There is no group key to rotate and the relay knows nothing, so |
| 411 | /// a member who has been dropped is refused BY EVERY READER rather than by |
| 412 | /// the transport. A client that skipped it would keep drawing messages from |
| 413 | /// somebody the creator removed, which is the one thing "stop sending to" |
| 414 | /// must actually mean. |
| 415 | async function accepts(toHex, got) { |
| 416 | var want = String(toHex || '').toLowerCase(); |
| 417 | if (!isHex(want, 32)) return null; |
| 418 | |
| 419 | // The invitation, checked against itself. |
| 420 | var op = parseOp(got && got.post && got.post.body); |
| 421 | if (op) { |
| 422 | var derived = await deriveId(String(got.author || '').toLowerCase(), op.salt); |
| 423 | if (derived && derived === want) return { op: true, gid: want }; |
| 424 | // A body that reads as a roster and does not verify is refused |
| 425 | // outright rather than falling through to be drawn as prose. A reader |
| 426 | // shown JSON in a message bubble has been shown a failure as content. |
| 427 | return null; |
| 428 | } |
| 429 | |
| 430 | var rec = await get(want); |
| 431 | if (!rec || rec.state === STATE.LEFT) return null; |
| 432 | var who = String(got && got.author || '').toLowerCase(); |
| 433 | var i; |
| 434 | // AND A CLOSED GROUP IS REFUSED BY THIS SAME WALK, with no line of its own. |
| 435 | // A closed group's roster names nobody (see `isClosed`), so nobody is found |
| 436 | // in it and every message to it is refused for the reason a removed |
| 437 | // member's is. An explicit `isClosed` guard was written above this loop |
| 438 | // first and then deleted: it could not be made to fail, because disabling |
| 439 | // it left the walk refusing exactly the same envelopes. A check that cannot |
| 440 | // go red is not a check, and the mechanism it was standing in front of is |
| 441 | // the one worth naming here. |
| 442 | // |
| 443 | // It matters that this holds WITHOUT the local half being right. The two |
| 444 | // halves of a record merge on separate clocks, and the local half only |
| 445 | // moves when the arriving `stateAt` is the higher -- so a member who |
| 446 | // pressed Join on a device whose clock runs ahead of the creator's can |
| 447 | // adopt the empty roster onto a record still saying `joined`. Clock skew |
| 448 | // between two people is not a fault either can see; the roster is. |
| 449 | for (i = 0; i < rec.members.length; i++) { |
| 450 | if (rec.members[i].k === who) { |
| 451 | return { op: false, gid: want, name: rec.name, state: rec.state }; |
| 452 | } |
| 453 | } |
| 454 | return null; |
| 455 | } |
| 456 | |
| 457 | /// Take one roster op and answer whether it moved this device. |
| 458 | /// |
| 459 | /// The op is NOT re-checked against the creator here: `accepts` has already |
| 460 | /// recomputed the id from the artefact's own author and salt, and calling |
| 461 | /// this with an artefact that did not pass it is a caller error rather than |
| 462 | /// a state to defend against. It is the same arrangement `openEnvelope` uses |
| 463 | /// for a signature. |
| 464 | async function consume(got) { |
| 465 | var op = parseOp(got && got.post && got.post.body); |
| 466 | if (!op) return false; |
| 467 | var gid = String(got.post.to).toLowerCase(); |
| 468 | var at = ms(got.time); |
| 469 | var rec = await get(gid); |
| 470 | |
| 471 | // A CLOSED GROUP CANNOT BE REOPENED, AND IT IS THE READERS THAT SAY SO. |
| 472 | // This is what makes the confirmation dialogue's "it cannot be undone" a |
| 473 | // property rather than a promise: a later roster from the creator naming |
| 474 | // everybody again is refused HERE, on every device, so a client that |
| 475 | // offered to reopen a group would find nobody obeying it. Before the `at` |
| 476 | // comparison, because a reopening roster is by definition the newer one. |
| 477 | if (isClosed(rec)) return false; |
| 478 | |
| 479 | // A roster no newer than the one held changes nothing. This is what makes |
| 480 | // a replay inert: an old op re-delivered by anybody is simply older. |
| 481 | if (rec && (at < ms(rec.at) |
| 482 | || (at === ms(rec.at) && String(got.address) <= String(rec.addr || '')))) { |
| 483 | return false; |
| 484 | } |
| 485 | |
| 486 | var mineHex = await whoAmI(); |
| 487 | |
| 488 | var inIt = false, i; |
| 489 | for (i = 0; i < op.members.length; i++) { |
| 490 | if (op.members[i].k === mineHex) { inIt = true; break; } |
| 491 | } |
| 492 | |
| 493 | var next = { |
| 494 | gid: gid, |
| 495 | creator: String(got.author).toLowerCase(), |
| 496 | salt: op.salt, |
| 497 | name: op.name, |
| 498 | at: at, |
| 499 | addr: String(got.address || ''), |
| 500 | members: op.members, |
| 501 | state: rec ? rec.state : STATE.INVITED, |
| 502 | stateAt: rec ? ms(rec.stateAt) : 0, |
| 503 | }; |
| 504 | // THE LOCAL HALF, decided in one chain so that the precedence is on the |
| 505 | // page rather than in the order two independent `if`s happen to sit in. |
| 506 | // Membership first, then authorship, then the invitation. |
| 507 | if (!inIt) { |
| 508 | // A roster that no longer names this device is the removal, arriving. It |
| 509 | // is sent to the dropped member deliberately (see `setMembers`), because |
| 510 | // a removal nobody is told about leaves somebody composing into a group |
| 511 | // that will refuse every word of it. |
| 512 | // |
| 513 | // NOTHING IS DELETED. Every message already collected stays exactly |
| 514 | // where it is, which is the second sentence this file exists to keep |
| 515 | // true. |
| 516 | if (next.state !== STATE.LEFT) { |
| 517 | next.state = STATE.LEFT; |
| 518 | next.stateAt = at; |
| 519 | } |
| 520 | } else if (authoredBy(next, mineHex)) { |
| 521 | // THE AUTHOR IS IN THEIR OWN GROUP AND IS NOT ASKED. See `authoredBy`: |
| 522 | // there is no invitation to answer, so there is no state but `joined` |
| 523 | // for a roster this account wrote and named itself in. Written with the |
| 524 | // ROSTER'S OWN STAMP rather than `Date.now()`, so the local half still |
| 525 | // merges on a real millisecond clock and this account's other devices |
| 526 | // converge on the same answer instead of on whichever read the bytes |
| 527 | // last. |
| 528 | if (next.state !== STATE.JOINED) { |
| 529 | next.state = STATE.JOINED; |
| 530 | next.stateAt = at; |
| 531 | } |
| 532 | } else if (next.state === STATE.LEFT) { |
| 533 | // And a roster that names this device again after a removal is a fresh |
| 534 | // invitation rather than a silent rejoin: joining is an act. |
| 535 | next.state = STATE.INVITED; |
| 536 | next.stateAt = at; |
| 537 | } |
| 538 | // The ANSWER IS THE WRITE, not the decision to write. A roster that was |
| 539 | // understood and could not be stored -- the identity locked between the |
| 540 | // two -- has moved nothing, and a caller told otherwise would count it as |
| 541 | // applied and never look at it again. |
| 542 | var wrote = await put(gid, next); |
| 543 | if (wrote) log('roster', gid.slice(0, 8), next.state, next.members.length, 'member(s)'); |
| 544 | return wrote; |
| 545 | } |
| 546 | |
| 547 | // ── Sealing to a group ───────────────────────────────────── |
| 548 | |
| 549 | /// Who a message to this group is sealed to, and who was left out. |
| 550 | /// |
| 551 | /// Answers `{ ok, why, gid, enc, to, skipped, name }`: |
| 552 | /// - `enc` is the sealing keys the envelope gets a slot for; |
| 553 | /// - `to` is the base64url signing keys the envelope is DELIVERED to; |
| 554 | /// - `skipped` is `[{ label, why }]`, and it is drawn, never swallowed. |
| 555 | /// |
| 556 | /// THE KEY-CHANGE RULE, which is the whole reason this is not a one-liner. |
| 557 | /// trust.js holds a state per key — `matched`, `new`, `changed`, `blocked` — |
| 558 | /// and a group message sealed to a CHANGED key is a group message sealed to |
| 559 | /// somebody who may not be the same person. Of the three things that could be |
| 560 | /// done about that: |
| 561 | /// |
| 562 | /// * seal to it anyway — hands the words to whoever now holds the key, and |
| 563 | /// tells the sender nothing; |
| 564 | /// * refuse the whole send — one member's rotated key silences a group of |
| 565 | /// forty for a reason nobody can see; |
| 566 | /// * seal to the rest and SAY WHO WAS LEFT OUT — the sender's screen names |
| 567 | /// them, and the message reaches everybody it safely can. |
| 568 | /// |
| 569 | /// The third. The first is the only one that is unsafe and the second is the |
| 570 | /// only one that is useless, so this is not a close call; what it costs is |
| 571 | /// that the sender must be shown the list, which is why `skipped` is a |
| 572 | /// returned value and not a log line. |
| 573 | /// |
| 574 | /// A roster's `e` is THE CREATOR'S CLAIM about a member's sealing key. Where |
| 575 | /// trust.js holds a card for the same signing key, that card wins and a |
| 576 | /// disagreement is treated exactly as a key change is — because that is what |
| 577 | /// it is, seen from the group's side. |
| 578 | async function sealTo(gid) { |
| 579 | var rec = await get(gid); |
| 580 | if (!rec) { |
| 581 | return { ok: false, why: tOr('group.err_unknown', |
| 582 | 'This device does not know that group.') }; |
| 583 | } |
| 584 | // CLOSED IS ASKED FIRST, and the order is the whole of why the answer is |
| 585 | // worth anything. A closed group leaves this account in `left`, so without |
| 586 | // this line the sender is told "You are no longer in this group" -- true of |
| 587 | // the record and wrong about what happened, and wrong in the one direction |
| 588 | // that matters: it reads as something done to them, when it is a group |
| 589 | // nobody is in any more. The creator gets that same sentence about a group |
| 590 | // they closed themselves. |
| 591 | if (isClosed(rec)) { |
| 592 | return { ok: false, gid: gid, name: rec.name, why: closedWhy() }; |
| 593 | } |
| 594 | if (!canSend(rec.state)) { |
| 595 | return { ok: false, why: rec.state === STATE.LEFT |
| 596 | ? tOr('group.err_left', |
| 597 | 'You are no longer in this group, so nothing can be sent to it. ' |
| 598 | + 'The messages already here stay where they are.') |
| 599 | : tOr('group.err_not_joined', |
| 600 | 'Join this group before writing to it.') }; |
| 601 | } |
| 602 | |
| 603 | var dir = {}; |
| 604 | try { |
| 605 | if (window.DaimondTrust && DaimondTrust.people) { |
| 606 | (await DaimondTrust.people() || []).forEach(function (p) { |
| 607 | if (p && p.key) dir[String(p.key).toLowerCase()] = p; |
| 608 | }); |
| 609 | } |
| 610 | } catch (e) { log('people projection failed', e); } |
| 611 | |
| 612 | var mineHex = await whoAmI(); |
| 613 | |
| 614 | var enc = [], to = [], skipped = [], i; |
| 615 | for (i = 0; i < rec.members.length; i++) { |
| 616 | var m = rec.members[i]; |
| 617 | var name = m.n || (dir[m.k] && dir[m.k].label) || m.k.slice(0, 8); |
| 618 | if (m.k === mineHex) { |
| 619 | // This device's own slot is post.js's business, and it adds one. |
| 620 | // Adding a second here would be a duplicate slot in every envelope. |
| 621 | continue; |
| 622 | } |
| 623 | var p = dir[m.k] || null; |
| 624 | if (p && p.state === 'blocked') { |
| 625 | skipped.push({ label: name, why: tOr('group.skip_blocked', |
| 626 | 'you blocked this key') }); |
| 627 | continue; |
| 628 | } |
| 629 | if (p && p.state === 'changed') { |
| 630 | skipped.push({ label: name, why: tOr('group.skip_changed', |
| 631 | 'their key changed and you have not matched the new one') }); |
| 632 | continue; |
| 633 | } |
| 634 | if (p && p.enc && String(p.enc).toLowerCase() !== m.e) { |
| 635 | // The roster and this device's own card disagree about a sealing |
| 636 | // key. That IS a key change, arriving by another road. |
| 637 | skipped.push({ label: name, why: tOr('group.skip_disagree', |
| 638 | 'the group\'s key for them is not the one you hold') }); |
| 639 | continue; |
| 640 | } |
| 641 | enc.push(unhex(m.e)); |
| 642 | to.push(pubOf(m.k)); |
| 643 | } |
| 644 | if (!enc.length) { |
| 645 | return { ok: false, gid: gid, name: rec.name, skipped: skipped, |
| 646 | why: tOr('group.err_nobody', |
| 647 | 'There is nobody in this group this device can seal to.') }; |
| 648 | } |
| 649 | return { ok: true, gid: gid, name: rec.name, enc: enc, to: to, skipped: skipped }; |
| 650 | } |
| 651 | |
| 652 | // ── Making and changing a group ──────────────────────────── |
| 653 | |
| 654 | /// Start a group, and send its first roster. |
| 655 | /// |
| 656 | /// The creator is a member of their own group and does not have to be named: |
| 657 | /// a group of one that the creator is not in is a group nobody can write to. |
| 658 | /// |
| 659 | /// `keys` are member signing keys in hex. Their sealing keys come from |
| 660 | /// trust.js, because that is the only place this app holds a key it has |
| 661 | /// re-verified; a member with no card cannot be added, and saying so is |
| 662 | /// better than adding somebody nothing can be sealed to. |
| 663 | async function create(name, keys) { |
| 664 | if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) { |
| 665 | return { ok: false, why: tOr('group.err_locked', |
| 666 | 'Unlock Daimond to make a group: its roster is signed with your own key.') }; |
| 667 | } |
| 668 | var salt = hex(crypto.getRandomValues(new Uint8Array(SALT))); |
| 669 | var mine = await DaimondIdentity.publicKeyRaw(); |
| 670 | var gid = await deriveId(hex(mine), salt); |
| 671 | if (!gid) return { ok: false, why: tOr('group.err_no_id', |
| 672 | 'This device could not name a group.') }; |
| 673 | return await roster(gid, salt, name, keys, []); |
| 674 | } |
| 675 | |
| 676 | /// Replace a group's membership and send the new roster. |
| 677 | /// |
| 678 | /// `keys` is the WHOLE membership, not a delta. A snapshot converges with no |
| 679 | /// ordering machinery because there is exactly one writer — the creator — so |
| 680 | /// the higher `at` is simply the later roster. A delta stream would need gap |
| 681 | /// detection and a story about a missing op, for a group that changes twice a |
| 682 | /// year. |
| 683 | async function setMembers(gid, name, keys) { |
| 684 | var rec = await get(gid); |
| 685 | if (!rec) return { ok: false, why: tOr('group.err_unknown', |
| 686 | 'This device does not know that group.') }; |
| 687 | var mine = await DaimondIdentity.publicKeyRaw(); |
| 688 | if (!mine || hex(mine) !== rec.creator) { |
| 689 | return { ok: false, why: tOr('group.err_not_creator', |
| 690 | 'Only the person who made a group can change who is in it.') }; |
| 691 | } |
| 692 | var keep = {}, i; |
| 693 | for (i = 0; i < keys.length; i++) keep[String(keys[i]).toLowerCase()] = 1; |
| 694 | var dropped = rec.members.filter(function (m) { return !keep[m.k]; }) |
| 695 | .map(function (m) { return m.k; }); |
| 696 | return await roster(gid, rec.salt, name == null ? rec.name : name, keys, dropped); |
| 697 | } |
| 698 | |
| 699 | /// Compose one roster, apply it here, and send it. |
| 700 | /// |
| 701 | /// `dropped` are members the new roster does not name. THEY ARE SENT IT TOO, |
| 702 | /// and that is the one place this file spends an envelope on somebody who is |
| 703 | /// not a member. A removal nobody is told about leaves a person composing |
| 704 | /// into a group where every reader will refuse them, with nothing on their |
| 705 | /// screen to explain it. The copy they get proves the second sentence rather |
| 706 | /// than only asserting it: they can read the roster that does not name them, |
| 707 | /// and every message they already hold is still there. |
| 708 | /// |
| 709 | /// `closing` writes the ONE roster that names nobody -- not even its author -- |
| 710 | /// which is how a group is closed. See `isClosed` and `close`. |
| 711 | async function roster(gid, salt, name, keys, dropped, closing) { |
| 712 | // A CLOSED GROUP TAKES NO FURTHER ROSTER, asked at the door and not only in |
| 713 | // `close`: `setMembers` comes through here too, and a membership change to a |
| 714 | // group that no longer has a membership is a roster no reader would obey |
| 715 | // (see `consume`). Refusing it here is what turns that silence into a |
| 716 | // sentence. |
| 717 | // |
| 718 | // AND IT IS ONLY THE SENTENCE. Measured, by deleting these five lines: the |
| 719 | // call still fails, because `consume` refuses the read-back and `roster` |
| 720 | // refuses any roster it could not apply -- so what moves is the reason, from |
| 721 | // "this device could not apply the roster it just wrote" to one a reader can |
| 722 | // act on. Worth having and worth being honest about the size of. |
| 723 | var was = await get(gid); |
| 724 | if (isClosed(was)) return { ok: false, why: closedWhy() }; |
| 725 | var mine = await DaimondIdentity.publicKeyRaw(); |
| 726 | var mineHex = hex(mine); |
| 727 | var dir = {}; |
| 728 | try { |
| 729 | if (window.DaimondTrust && DaimondTrust.people) { |
| 730 | (await DaimondTrust.people() || []).forEach(function (p) { |
| 731 | if (p && p.key && p.enc) dir[String(p.key).toLowerCase()] = p; |
| 732 | }); |
| 733 | } |
| 734 | } catch (e) { /* nobody */ } |
| 735 | |
| 736 | keys = keys || []; |
| 737 | var members = [], seen = {}, missing = [], bad = [], dupes = 0, i; |
| 738 | // The creator first, always, and from this device's own keys rather than |
| 739 | // from a card: asking a directory about a key this device holds the other |
| 740 | // half of is how the two spellings of one key get out of step. |
| 741 | var myEnc = DaimondIdentity.sealingKeyRaw(); |
| 742 | if (!myEnc) return { ok: false, why: tOr('group.err_no_sealing_key', |
| 743 | 'This device has no sealing key yet, so it cannot make a group.') }; |
| 744 | // EXCEPT WHEN CLOSING, which is the one roster the creator leaves themselves |
| 745 | // out of. The sealing key is still required: `compose` gives the author |
| 746 | // their own slot whatever the roster says, so the closing roster is |
| 747 | // re-readable on this account's other devices exactly as every other is. |
| 748 | if (!closing) { |
| 749 | members.push({ k: mineHex, e: hex(myEnc), |
| 750 | n: (window.DaimondIdentity.displayName && DaimondIdentity.displayName()) || '' }); |
| 751 | seen[mineHex] = 1; |
| 752 | } |
| 753 | |
| 754 | for (i = 0; i < keys.length; i++) { |
| 755 | var k = String(keys[i]).toLowerCase(); |
| 756 | // A KEY THAT IS NOT A KEY IS NAMED, NEVER SKIPPED. This line read |
| 757 | // `if (!isHex(k, 32) || seen[k]) continue;` and dropped both cases on the |
| 758 | // floor without adding either to `missing`, so a caller who mis-spelled |
| 759 | // one key was answered `ok:true, members:1, sent:0` -- a group of one, |
| 760 | // made silently, which is exactly what happened the first time anybody |
| 761 | // called this by hand. The spelling is carried out in `bad` rather than |
| 762 | // counted, because a count cannot be corrected and a spelling can. |
| 763 | if (!isHex(k, 32)) { bad.push(k.slice(0, 72)); continue; } |
| 764 | // A DUPLICATE IS A DIFFERENT CASE AND IS NOT AN ERROR. Naming somebody |
| 765 | // twice, or naming yourself, asks for a roster this one already is: the |
| 766 | // caller gets the membership they asked for, so it is counted and |
| 767 | // reported and refuses nothing. Reporting it as a fault would refuse a |
| 768 | // group over a request that was already granted. |
| 769 | if (seen[k]) { dupes++; continue; } |
| 770 | var p = dir[k]; |
| 771 | if (!p || !p.enc) { missing.push(k); continue; } |
| 772 | seen[k] = 1; |
| 773 | members.push({ k: k, e: String(p.enc).toLowerCase(), n: String(p.label || '') }); |
| 774 | } |
| 775 | if (members.length > MEMBERS_MAX) { |
| 776 | return { ok: false, why: tOr('group.err_too_many', |
| 777 | 'A group can hold at most {n} people.', { n: MEMBERS_MAX }) }; |
| 778 | } |
| 779 | // A KEY THAT COULD NOT GO IN REFUSES THE ROSTER, and both arrays come back |
| 780 | // so the caller can tell the two faults apart: `missing` is somebody real |
| 781 | // whose code has not been scanned, `bad` is a spelling that is not a key at |
| 782 | // all. |
| 783 | // |
| 784 | // TWO SENTENCES, BECAUSE THERE ARE TWO REPAIRS -- correct the spelling, or |
| 785 | // scan a code -- which is `shortfall`'s own rule about two lists in one |
| 786 | // answer. It was one sentence, `group.err_no_card` counting |
| 787 | // `bad.length + missing.length`, and that arithmetic made the sentence |
| 788 | // FALSE: one mis-typed key and one uncarded person read as "no sealing key |
| 789 | // for 2 of the people chosen", sending the reader to scan a code for a |
| 790 | // typing mistake. `{n}` is now the number the sentence is actually about. |
| 791 | // |
| 792 | // `group.err_bad_keys` IS PLURAL BECAUSE `bad` IS AN ARRAY. The singular |
| 793 | // `group.err_bad_key` with its one `{k}` could not say what happened -- |
| 794 | // `create('x', ['ABC…zz', 'not-a-key'])` refuses with two entries -- so it |
| 795 | // is gone rather than kept for a caller that does not exist. `{who}` is a |
| 796 | // joined list in the register `post.group_refused` already uses, and it |
| 797 | // carries the SPELLINGS, because a spelling is the thing that can be |
| 798 | // corrected and a count is not. |
| 799 | if (bad.length || missing.length) { |
| 800 | if (bad.length) log('keys that are not keys, refused', bad); |
| 801 | var why = ''; |
| 802 | if (bad.length) { |
| 803 | why += tOr('group.err_bad_keys', 'These are not keys: {who}.', |
| 804 | { who: bad.join(', ') }); |
| 805 | } |
| 806 | if (missing.length) { |
| 807 | why += (why ? ' ' : '') + tOr('group.err_no_card', |
| 808 | 'There is no sealing key for {n} of the people chosen, so they cannot ' |
| 809 | + 'be added. Scan their code first.', { n: missing.length }); |
| 810 | } |
| 811 | return { ok: false, bad: bad, missing: missing, why: why }; |
| 812 | } |
| 813 | |
| 814 | // Everybody the roster is sent to: the new membership, plus anybody it |
| 815 | // drops. The creator's own copy is post.js's Sent slot and is not a |
| 816 | // delivery. |
| 817 | var reach = members.filter(function (m) { return m.k !== mineHex; }) |
| 818 | .map(function (m) { return { k: m.k, e: m.e }; }); |
| 819 | for (i = 0; i < dropped.length; i++) { |
| 820 | var was = null, j; |
| 821 | for (j = 0; j < members.length; j++) { if (members[j].k === dropped[i]) was = 1; } |
| 822 | if (was) continue; |
| 823 | var old = dir[dropped[i]]; |
| 824 | if (old && old.enc) reach.push({ k: dropped[i], e: String(old.enc).toLowerCase() }); |
| 825 | } |
| 826 | |
| 827 | var body = opBody(salt, name, members); |
| 828 | var made; |
| 829 | try { |
| 830 | made = await DaimondPost.compose({ |
| 831 | body: body, |
| 832 | group: { id: unhex(gid), enc: reach.map(function (r) { return unhex(r.e); }) }, |
| 833 | }); |
| 834 | } catch (e) { return { ok: false, why: String(e && e.message || e) }; } |
| 835 | |
| 836 | // APPLIED HERE THROUGH THE SAME DOOR AN ARRIVING ONE TAKES. The creator's |
| 837 | // own copy comes back off the relay eventually, but a group that did not |
| 838 | // exist until a round trip completed would be a group a person could |
| 839 | // press twice. Reading the artefact back rather than writing the record |
| 840 | // straight means there is one path that turns a roster into a record, and |
| 841 | // a bug in it shows up for the creator first. |
| 842 | // |
| 843 | // AND A ROSTER THIS DEVICE DID NOT APPLY IS NOT SENT. The answer used to be |
| 844 | // a log line, so a read-back that failed left the caller holding `ok:true` |
| 845 | // for a group that is in nobody's record -- the same shape as the malformed |
| 846 | // key above, one function further down. `consume` answering false is the |
| 847 | // same failure as it throwing: either way this device would be fanning out |
| 848 | // a roster it does not itself hold, and would then refuse every message |
| 849 | // sent to the group it had just announced. |
| 850 | try { |
| 851 | var got = JSON.parse(DaimondPost.bridgeRead(made.artefact)); |
| 852 | got.address = got.address || made.addr; |
| 853 | if (!await consume(got)) { |
| 854 | throw new Error('this device could not apply the roster it just wrote,' |
| 855 | + ' so it was not sent'); |
| 856 | } |
| 857 | } catch (e) { |
| 858 | log('the creator could not read back their own roster', e); |
| 859 | return { ok: false, why: String(e && e.message || e) }; |
| 860 | } |
| 861 | |
| 862 | var sent = await DaimondPost.fanout(made, reach.map(function (r) { return pubOf(r.k); })); |
| 863 | await draw(); |
| 864 | // The composed roster comes back with the answer. A caller carrying it by |
| 865 | // hand -- a verifier proving this works between three devices with no |
| 866 | // server in the path -- must carry the SAME bytes the fan-out sent, not a |
| 867 | // second composition of the same roster. |
| 868 | // `refused` is drawn by the caller through `shortfallWords`, and `dupes` |
| 869 | // counts the keys that were already in the roster -- a request that was |
| 870 | // granted rather than a fault, so it is reported and refuses nothing. |
| 871 | return { ok: true, gid: gid, sent: sent.sent, refused: sent.refused, |
| 872 | dupes: dupes, members: members.length, addr: made.addr, |
| 873 | envelope: made.envelope }; |
| 874 | } |
| 875 | |
| 876 | /// Close a group for everybody, and tell everybody who was in it. |
| 877 | /// |
| 878 | /// THE CREATOR'S ACT, AND THE ONLY IRREVERSIBLE ONE IN THIS FILE. It writes the |
| 879 | /// roster that names nobody, which every reader obeys and no later roster can |
| 880 | /// undo (see `isClosed` and `consume`). Nothing is deleted anywhere: every |
| 881 | /// member keeps every message they hold, and the record stays on the list so the |
| 882 | /// transcript still has a name over it. |
| 883 | /// |
| 884 | /// EVERYBODY WHO WAS IN IT IS SENT IT, through the same door a removal takes: |
| 885 | /// the whole old membership is handed to `roster` as `dropped`, so each of them |
| 886 | /// gets the roster that closes the group rather than finding out by writing into |
| 887 | /// it and having every reader refuse them. A member whose card this device no |
| 888 | /// longer holds cannot be sealed to and so is not told -- the same limit the |
| 889 | /// removal path has, and for the same reason. |
| 890 | async function close(gid) { |
| 891 | var rec = await get(gid); |
| 892 | if (!rec) return { ok: false, why: tOr('group.err_unknown', |
| 893 | 'This device does not know that group.') }; |
| 894 | if (isClosed(rec)) return { ok: false, why: closedWhy() }; |
| 895 | var mine = await DaimondIdentity.publicKeyRaw(); |
| 896 | if (!mine || hex(mine) !== rec.creator) { |
| 897 | // Its own sentence rather than `group.err_not_creator`, which is about |
| 898 | // changing who is in a group. Closing one is not a membership change and |
| 899 | // being told it is sends the reader looking for a control that is not the |
| 900 | // one they pressed. |
| 901 | return { ok: false, why: tOr('group.err_close_not_creator', |
| 902 | 'Only the person who made a group can close it.') }; |
| 903 | } |
| 904 | var told = rec.members.map(function (m) { return m.k; }); |
| 905 | return await roster(gid, rec.salt, rec.name, [], told, true); |
| 906 | } |
| 907 | |
| 908 | /// Accept an invitation. Nothing earlier arrives with it, and the row said so. |
| 909 | /// |
| 910 | /// A CLOSED GROUP CANNOT BE JOINED, and the guard is at the door rather than |
| 911 | /// only on the control: the panel draws no Join on a closed row, but a panel is |
| 912 | /// one caller, and a device that adopted the empty roster with an older |
| 913 | /// `stateAt` (see `accepts`) would otherwise be one press from `joined` on a |
| 914 | /// group nothing can be sent to. |
| 915 | async function join(gid) { |
| 916 | var rec = await get(gid); |
| 917 | if (!rec) return false; |
| 918 | if (isClosed(rec)) return false; |
| 919 | if (rec.state === STATE.JOINED) return true; |
| 920 | rec.state = STATE.JOINED; |
| 921 | rec.stateAt = Date.now(); |
| 922 | await put(gid, rec); |
| 923 | // Messages that arrived while the invitation was open were sealed to this |
| 924 | // device and are its own; the tray was holding them until the invitation |
| 925 | // was answered, exactly as it holds a stranger's first message. |
| 926 | try { if (window.DaimondPost && DaimondPost.untrayGroup) await DaimondPost.untrayGroup(gid); } |
| 927 | catch (e) { /* no post module */ } |
| 928 | await draw(); |
| 929 | return true; |
| 930 | } |
| 931 | |
| 932 | /// Leave, or decline. Local, and it tells nobody: the same reasoning Ignore |
| 933 | /// is silent under. Nothing already held is deleted. |
| 934 | /// |
| 935 | /// A GROUP THIS ACCOUNT MADE CANNOT BE LEFT, and the guard is here rather than |
| 936 | /// only on the control that draws it. `left` is a state the creator's own next |
| 937 | /// roster contradicts -- `roster` names them in everything it writes and |
| 938 | /// `consume` reads authorship -- so the press would appear to work and the |
| 939 | /// next membership change would silently undo it. The invariant is worth more |
| 940 | /// at the door than in the panel: a panel is one caller. |
| 941 | /// |
| 942 | /// AND A CLOSED GROUP CANNOT BE LEFT EITHER, because there is nothing left to |
| 943 | /// leave: the closing roster already put this account out of it, on every device |
| 944 | /// that holds the roster. A press that wrote `left` again would move a stamp and |
| 945 | /// nothing else, and answering true for it would tell the caller an act happened. |
| 946 | async function leave(gid) { |
| 947 | var rec = await get(gid); |
| 948 | if (!rec) return false; |
| 949 | if (isClosed(rec)) return false; |
| 950 | if (authoredBy(rec, await whoAmI())) return false; |
| 951 | rec.state = STATE.LEFT; |
| 952 | rec.stateAt = Date.now(); |
| 953 | await put(gid, rec); |
| 954 | await draw(); |
| 955 | return true; |
| 956 | } |
| 957 | |
| 958 | // ── The panel ────────────────────────────────────────────── |
| 959 | // |
| 960 | // Drawn INSIDE post.js's own region, because the Social panel's views belong |
| 961 | // to improve.js and a third view would be an edit to a file this lane does |
| 962 | // not own. post.js calls `draw` once from its `render`, so a language change |
| 963 | // and a collect both reach this without a second surface registration. |
| 964 | |
| 965 | /// Where post.js parks this section, set by `mount`. |
| 966 | var _host = null; |
| 967 | |
| 968 | function elt(tag, cls, text) { |
| 969 | var e = document.createElement(tag); |
| 970 | if (cls) e.className = cls; |
| 971 | if (text != null) e.textContent = String(text); |
| 972 | return e; |
| 973 | } |
| 974 | |
| 975 | /// A group's name, drawn as the claim it is: the creator chose it, so it is |
| 976 | /// shown beside the first eight characters of the id, which nobody chose. |
| 977 | function title(rec) { |
| 978 | var name = rec.name || tOr('group.unnamed', 'A group'); |
| 979 | return name + ' · ' + String(rec.gid).slice(0, 8); |
| 980 | } |
| 981 | |
| 982 | /// THE FIRST OF THE TWO SENTENCES, and it is on the invitation rather than |
| 983 | /// after the press, because after the press it is an explanation and before |
| 984 | /// it is a fact somebody can act on. |
| 985 | function joiningSentence() { |
| 986 | return tOr('group.joining_shows_nothing', |
| 987 | 'Joining shows you nothing that was sent before you join. Those messages ' |
| 988 | + 'were never sealed to your key, so no device can open them for you.'); |
| 989 | } |
| 990 | |
| 991 | /// THE SECOND, beside the control that drops somebody, before it is pressed. |
| 992 | /// The control itself says "stop sending to" and never "remove", because |
| 993 | /// "remove" reads as though something is taken back and nothing is. |
| 994 | function removingSentence() { |
| 995 | return tOr('group.removing_retracts_nothing', |
| 996 | 'Taking somebody out takes nothing back. They keep every message already ' |
| 997 | + 'sent to them; they will not receive anything sent from now on.'); |
| 998 | } |
| 999 | |
| 1000 | /// THE THIRD, beside the control that closes a group, before it is pressed -- |
| 1001 | /// and again inside the confirmation dialogue, because a sentence read once on |
| 1002 | /// the way past is not consent for something that cannot be undone. |
| 1003 | function closingSentence() { |
| 1004 | return tOr('group.close_note', |
| 1005 | 'Closing a group closes it for everybody. Nobody can write to it again, ' |
| 1006 | + 'you included; every message already sent stays where it is. It cannot ' |
| 1007 | + 'be undone.'); |
| 1008 | } |
| 1009 | |
| 1010 | /// Draw the whole section into the host post.js gave this file. |
| 1011 | async function draw() { |
| 1012 | if (!_host) return 0; |
| 1013 | _host.textContent = ''; |
| 1014 | var g = await all(); |
| 1015 | if (!g) return 0; // locked; post.js has already said so |
| 1016 | |
| 1017 | // Whether this account made a group is asked ONCE, here, rather than |
| 1018 | // inside a row: the answer is a key comparison and a row that asked it |
| 1019 | // would be a row that has to be asynchronous to draw. |
| 1020 | var mineHex = await whoAmI(); |
| 1021 | |
| 1022 | var rows = Object.keys(g).map(function (k) { return g[k]; }) |
| 1023 | .sort(function (a, b) { return ms(b.at) - ms(a.at); }); |
| 1024 | // CLOSED IS ASKED BEFORE THE STATE, so a closed group cannot be drawn as an |
| 1025 | // invitation or as a group somebody may write to whatever the local half |
| 1026 | // says. `consume` moves that half to `left` when the closing roster lands, |
| 1027 | // but the two halves merge on separate clocks (see `accepts`) and the |
| 1028 | // roster is the half that carries the closing. |
| 1029 | var closed = rows.filter(isClosed); |
| 1030 | var open = rows.filter(function (r) { return !isClosed(r); }); |
| 1031 | |
| 1032 | // AND THE LOCAL HALF IS SETTLED WHERE IT DISAGREES, because ONE READER OF |
| 1033 | // THIS RECORD IS NOT IN THIS FILE: `post.js` `joinedGroups` builds the |
| 1034 | // recipient picker from `state === 'joined'` alone, so a record left saying |
| 1035 | // `joined` over an empty roster offers a destination that `sealTo` then |
| 1036 | // refuses -- a control that cannot do what it says, which this panel |
| 1037 | // deliberately does not draw anywhere else. Writing it here rather than in |
| 1038 | // `adopt` keeps the repair in the file that owns what a group record means. |
| 1039 | // The picker is drawn in the same pass as this, so it goes right on the NEXT |
| 1040 | // render; the sentence in between is `group.err_closed`, which is the true |
| 1041 | // one either way. |
| 1042 | var settle = closed.filter(function (r) { return r.state !== STATE.LEFT; }); |
| 1043 | for (var s = 0; s < settle.length; s++) { |
| 1044 | settle[s].state = STATE.LEFT; |
| 1045 | settle[s].stateAt = ms(settle[s].at); |
| 1046 | await put(settle[s].gid, settle[s]); |
| 1047 | } |
| 1048 | // After the settle, because `put` strips this flag off the record it is |
| 1049 | // handed and a row drawn without it is a row whose author sees nothing. |
| 1050 | rows.forEach(function (r) { r.iAmCreator = authoredBy(r, mineHex); }); |
| 1051 | var invites = open.filter(function (r) { return r.state === STATE.INVITED; }); |
| 1052 | var joined = open.filter(function (r) { return r.state === STATE.JOINED; }); |
| 1053 | var left = open.filter(function (r) { return r.state === STATE.LEFT; }); |
| 1054 | |
| 1055 | if (invites.length) { |
| 1056 | var isec = elt('section', 'post-tray'); |
| 1057 | isec.id = 'group-invites'; |
| 1058 | isec.appendChild(elt('h3', null, tOr('group.invites_head', 'Group invitations'))); |
| 1059 | invites.forEach(function (r) { isec.appendChild(drawInvite(r)); }); |
| 1060 | _host.appendChild(isec); |
| 1061 | } |
| 1062 | |
| 1063 | // TWO CLASSES, both of them already in improve.css: `post-list` for the |
| 1064 | // padding and `post-tray` for the one rule that styles a section heading. |
| 1065 | // A third class of its own would be a rule to add to a stylesheet this |
| 1066 | // lane does not own, for a heading that already has one. |
| 1067 | var lsec = elt('section', 'post-list post-tray'); |
| 1068 | lsec.id = 'group-list'; |
| 1069 | lsec.appendChild(elt('h3', null, tOr('group.head', 'Groups'))); |
| 1070 | if (!joined.length && !left.length && !closed.length) { |
| 1071 | // Two sentences, because "no groups yet" over a pending invitation is |
| 1072 | // a screen arguing with the row above it. |
| 1073 | lsec.appendChild(elt('p', 'post-empty', invites.length |
| 1074 | ? tOr('group.none_joined', |
| 1075 | 'None joined yet. Answer the invitation above, or make one below.') |
| 1076 | : tOr('group.none', |
| 1077 | 'No groups yet. A group is a list of people a message is sealed to ' |
| 1078 | + 'one by one — there is no shared key, and nothing is kept on the relay.'))); |
| 1079 | } |
| 1080 | joined.forEach(function (r) { lsec.appendChild(drawGroup(r)); }); |
| 1081 | left.forEach(function (r) { lsec.appendChild(drawLeft(r)); }); |
| 1082 | // A CLOSED GROUP KEEPS ITS PLACE ON THE LIST, and that is not a cosmetic |
| 1083 | // choice: `post.js` `drawMsg` puts the group's NAME over every message of |
| 1084 | // it, read out of this record (`groupRec`). Take the record away and the |
| 1085 | // transcript the feature promises to keep loses the one thing that says |
| 1086 | // which group it was. So closing is not removing, and removing is not |
| 1087 | // offered here -- see the note on `drawClosed`. |
| 1088 | closed.forEach(function (r) { lsec.appendChild(drawClosed(r)); }); |
| 1089 | lsec.appendChild(drawMake()); |
| 1090 | _host.appendChild(lsec); |
| 1091 | return rows.length; |
| 1092 | } |
| 1093 | |
| 1094 | /// An invitation, with the first sentence on it. |
| 1095 | function drawInvite(rec) { |
| 1096 | var row = elt('article', 'post-req'); |
| 1097 | row.dataset.gid = rec.gid; |
| 1098 | var who = elt('div', 'post-who'); |
| 1099 | who.appendChild(elt('span', 'post-name', title(rec))); |
| 1100 | row.appendChild(who); |
| 1101 | row.appendChild(elt('p', 'post-body', tOr('group.invited_by', |
| 1102 | '{n} people, invited by the person who made it.', { n: rec.members.length }))); |
| 1103 | row.appendChild(elt('p', 'post-audience', joiningSentence())); |
| 1104 | var acts = elt('div', 'post-acts'); |
| 1105 | [['group-join', tOr('group.join', 'Join')], |
| 1106 | ['group-decline', tOr('group.decline', 'Not now')]].forEach(function (p) { |
| 1107 | var b = elt('button', 'post-btn', p[1]); |
| 1108 | b.type = 'button'; |
| 1109 | b.dataset.act = p[0]; |
| 1110 | acts.appendChild(b); |
| 1111 | }); |
| 1112 | row.appendChild(acts); |
| 1113 | return row; |
| 1114 | } |
| 1115 | |
| 1116 | /// A group this device is in. |
| 1117 | function drawGroup(rec) { |
| 1118 | var row = elt('article', 'post-msg'); |
| 1119 | row.dataset.gid = rec.gid; |
| 1120 | var who = elt('div', 'post-who'); |
| 1121 | who.appendChild(elt('span', 'post-name', title(rec))); |
| 1122 | who.appendChild(elt('span', 'post-fp', tOr('group.count', |
| 1123 | '{n} people', { n: rec.members.length }))); |
| 1124 | row.appendChild(who); |
| 1125 | |
| 1126 | // The roster, on one line. A name is the creator's claim and the eight |
| 1127 | // characters beside it are not, which is the same pairing a card's label |
| 1128 | // and fingerprint are drawn in. |
| 1129 | row.appendChild(elt('p', 'post-body', rec.members.map(function (m) { |
| 1130 | return (m.n || tOr('post.someone', 'Someone new')) + ' · ' + m.k.slice(0, 8); |
| 1131 | }).join('\n'))); |
| 1132 | |
| 1133 | // The creator's own controls. Everybody else sees the roster and no |
| 1134 | // buttons over it, because a control that always refuses explains less |
| 1135 | // than its absence. |
| 1136 | if (rec.iAmCreator) { |
| 1137 | row.appendChild(elt('p', 'post-audience', removingSentence())); |
| 1138 | var acts = elt('div', 'post-acts'); |
| 1139 | rec.members.forEach(function (m) { |
| 1140 | if (m.k === rec.creator) return; |
| 1141 | var b = elt('button', 'post-btn', tOr('group.stop_sending', |
| 1142 | 'Stop sending to {who}', { who: m.n || m.k.slice(0, 8) })); |
| 1143 | b.type = 'button'; |
| 1144 | b.dataset.act = 'group-drop'; |
| 1145 | b.dataset.key = m.k; |
| 1146 | acts.appendChild(b); |
| 1147 | }); |
| 1148 | row.appendChild(acts); |
| 1149 | |
| 1150 | // AND THE ONE ACT THAT ENDS IT, which is the creator's alone for the same |
| 1151 | // reason changing the membership is: the id is derived from their signing |
| 1152 | // key, so nobody else can write the roster that closes it. Its own row of |
| 1153 | // controls, under its own sentence, and BELOW the per-member controls -- |
| 1154 | // an irreversible act does not sit next to a reversible one. |
| 1155 | row.appendChild(elt('p', 'post-audience', closingSentence())); |
| 1156 | var end = elt('div', 'post-acts'); |
| 1157 | var cl = elt('button', 'post-btn', tOr('group.close', 'Close this group')); |
| 1158 | cl.type = 'button'; |
| 1159 | cl.dataset.act = 'group-close'; |
| 1160 | end.appendChild(cl); |
| 1161 | row.appendChild(end); |
| 1162 | } |
| 1163 | |
| 1164 | // NOT OFFERED TO THE CREATOR, because `leave` refuses them and would be |
| 1165 | // right to: see its own note. This is the same argument the drop controls |
| 1166 | // above are absent under -- a control that cannot do what it says explains |
| 1167 | // less than its absence -- except that this one is worse than one that |
| 1168 | // always refuses, since it would appear to work until the next roster. |
| 1169 | if (!rec.iAmCreator) { |
| 1170 | var mineActs = elt('div', 'post-acts'); |
| 1171 | var lv = elt('button', 'post-btn', tOr('group.leave', 'Leave this group')); |
| 1172 | lv.type = 'button'; |
| 1173 | lv.dataset.act = 'group-leave'; |
| 1174 | mineActs.appendChild(lv); |
| 1175 | row.appendChild(mineActs); |
| 1176 | } |
| 1177 | return row; |
| 1178 | } |
| 1179 | |
| 1180 | /// A group this device is no longer in. It stays on the list, with its |
| 1181 | /// messages, because that is the whole of the second sentence: leaving and |
| 1182 | /// being taken out both retract exactly nothing. |
| 1183 | function drawLeft(rec) { |
| 1184 | var row = elt('article', 'post-msg'); |
| 1185 | row.dataset.gid = rec.gid; |
| 1186 | var who = elt('div', 'post-who'); |
| 1187 | who.appendChild(elt('span', 'post-name', title(rec))); |
| 1188 | row.appendChild(who); |
| 1189 | row.appendChild(elt('p', 'post-body', tOr('group.gone', |
| 1190 | 'You are no longer in this group. Nothing has been taken away: every ' |
| 1191 | + 'message already here stays, and nothing new will arrive.'))); |
| 1192 | return row; |
| 1193 | } |
| 1194 | |
| 1195 | /// A group that has been closed. It keeps its place, with its messages, and it |
| 1196 | /// carries NO CONTROLS AT ALL -- not for the creator either. |
| 1197 | /// |
| 1198 | /// COULD IT BE TAKEN OFF THE LIST? Not by this act, and the reason is a fact |
| 1199 | /// about another file rather than a preference: `post.js` `drawMsg` puts the |
| 1200 | /// group's name over every message of it, read out of this record. Delete the |
| 1201 | /// record and a transcript the whole feature exists to preserve is left under |
| 1202 | /// "A group · 3f2a91c4", which is deletion wearing the words of a tidy-up. |
| 1203 | /// |
| 1204 | /// So it would be a SECOND act, local to one device, in the register of |
| 1205 | /// `post.js`'s Ignore -- hide the row, keep the record -- and it is not built |
| 1206 | /// here. Closing and hiding answer to different people: closing is the |
| 1207 | /// creator's and reaches everybody, hiding is each reader's own and reaches |
| 1208 | /// nobody. Putting them on one button would let one press mean either. |
| 1209 | function drawClosed(rec) { |
| 1210 | var row = elt('article', 'post-msg'); |
| 1211 | row.dataset.gid = rec.gid; |
| 1212 | row.dataset.closed = '1'; |
| 1213 | var who = elt('div', 'post-who'); |
| 1214 | who.appendChild(elt('span', 'post-name', title(rec))); |
| 1215 | row.appendChild(who); |
| 1216 | row.appendChild(elt('p', 'post-body', tOr('group.closed', |
| 1217 | 'This group has been closed by the person who made it. Nothing has been ' |
| 1218 | + 'taken away: every message already here stays, and nobody can write to ' |
| 1219 | + 'it again.'))); |
| 1220 | return row; |
| 1221 | } |
| 1222 | |
| 1223 | /// The box that makes one. A name and a set of people this device holds cards |
| 1224 | /// for, because a member with no card is a member nothing can be sealed to. |
| 1225 | function drawMake() { |
| 1226 | var box = elt('form', 'post-write'); |
| 1227 | box.id = 'group-make'; |
| 1228 | var who = []; |
| 1229 | try { who = (window.DaimondPost && DaimondPost.people) ? DaimondPost.people() : []; } |
| 1230 | catch (e) { who = []; } |
| 1231 | if (!who.length) { |
| 1232 | box.appendChild(elt('p', 'post-nobody', tOr('group.nobody', |
| 1233 | 'There is nobody to put in a group yet. Exchange codes with somebody ' |
| 1234 | + 'in People, and they will be here.'))); |
| 1235 | return box; |
| 1236 | } |
| 1237 | var name = elt('input', 'post-to'); |
| 1238 | name.id = 'group-name'; |
| 1239 | name.type = 'text'; |
| 1240 | name.maxLength = NAME_MAX; |
| 1241 | name.placeholder = tOr('group.name_ph', 'What to call this group'); |
| 1242 | name.setAttribute('aria-label', tOr('group.name_label', 'The group\'s name')); |
| 1243 | box.appendChild(name); |
| 1244 | |
| 1245 | var pick = elt('select', 'post-to'); |
| 1246 | pick.id = 'group-members'; |
| 1247 | pick.multiple = true; |
| 1248 | pick.size = Math.min(6, who.length); |
| 1249 | pick.setAttribute('aria-label', tOr('group.members_label', 'Who is in this group')); |
| 1250 | who.forEach(function (p) { |
| 1251 | var o = elt('option', null, p.label || p.keyHex.slice(0, 8)); |
| 1252 | o.value = p.keyHex; |
| 1253 | pick.appendChild(o); |
| 1254 | }); |
| 1255 | box.appendChild(pick); |
| 1256 | |
| 1257 | box.appendChild(elt('p', 'post-audience', joiningSentence())); |
| 1258 | var mk = elt('button', 'post-btn post-send', tOr('group.make', 'Make this group')); |
| 1259 | mk.type = 'submit'; |
| 1260 | mk.dataset.act = 'group-make'; |
| 1261 | box.appendChild(mk); |
| 1262 | var note = elt('p', 'post-note'); |
| 1263 | note.id = 'group-note'; |
| 1264 | box.appendChild(note); |
| 1265 | return box; |
| 1266 | } |
| 1267 | |
| 1268 | function say(text) { |
| 1269 | var n = document.getElementById('group-note'); |
| 1270 | if (n) n.textContent = String(text || ''); |
| 1271 | } |
| 1272 | |
| 1273 | /// Everything a roster did not do, borrowed from post.js so that there is one |
| 1274 | /// voice for it. |
| 1275 | /// |
| 1276 | /// A ROSTER'S REFUSALS ARE PEOPLE WHO DO NOT KNOW THE GROUP EXISTS, which is |
| 1277 | /// why they cannot be counted and dropped: "Made, and 5 people have been told" |
| 1278 | /// over a fan-out that reached one is a sentence about four people who will |
| 1279 | /// never see a message sent to them. It is the same fault post.js had one |
| 1280 | /// function along -- an `ok:true` beside a count of what failed, and no caller |
| 1281 | /// reading the count -- so it takes the same remedy rather than a second one. |
| 1282 | function shortfallWords(r) { |
| 1283 | try { |
| 1284 | if (window.DaimondPost && DaimondPost.shortfall) return DaimondPost.shortfall(r); |
| 1285 | } catch (e) { /* no post module */ } |
| 1286 | return ''; |
| 1287 | } |
| 1288 | |
| 1289 | /// Ask, once, before closing a group. Answers whether they said yes. |
| 1290 | /// |
| 1291 | /// THE APP'S OWN DIALOGUE FRAME, `DaimondCore.confirm`, which is |
| 1292 | /// `daimond.js`'s one modal: `share.js` asks about a stranger's code through |
| 1293 | /// it and `trust.js` borrows `pairing.js`'s overlay rather than writing a |
| 1294 | /// second. A third frame for a third question is how an app ends up with three |
| 1295 | /// ways of asking the same thing and only two of them trapping focus. |
| 1296 | /// |
| 1297 | /// NO FRAME, NO CLOSE. The answer to a missing dialogue is `false`, not "go |
| 1298 | /// ahead": the one act in this file that cannot be undone must not happen |
| 1299 | /// because a script did not load. That is the opposite of `share.js`'s `tell`, |
| 1300 | /// which may fail quietly -- it only says something, and nothing turns on it. |
| 1301 | async function askClose(rec) { |
| 1302 | var body = closingSentence() + '\n\n' + tOr('group.close_ask', |
| 1303 | 'This closes “{name}” for everybody in it.', |
| 1304 | { name: rec.name || tOr('group.unnamed', 'A group') }); |
| 1305 | try { |
| 1306 | if (window.DaimondCore && typeof DaimondCore.confirm === 'function') { |
| 1307 | return !!await DaimondCore.confirm(body, |
| 1308 | tOr('group.close_ok', 'Close it for everybody'), { |
| 1309 | title: tOr('group.close_title', 'Close this group for everybody?'), |
| 1310 | danger: true, |
| 1311 | }); |
| 1312 | } |
| 1313 | } catch (e) { log('the confirmation dialogue would not open', e); } |
| 1314 | log('no dialogue frame, so the group was not closed'); |
| 1315 | return false; |
| 1316 | } |
| 1317 | |
| 1318 | /// Take the region post.js gives this file, and draw into it. |
| 1319 | function mount(host) { |
| 1320 | _host = host || null; |
| 1321 | return draw(); |
| 1322 | } |
| 1323 | |
| 1324 | // ── Wiring ───────────────────────────────────────────────── |
| 1325 | |
| 1326 | document.addEventListener('click', function (e) { |
| 1327 | var b = e.target && e.target.closest ? e.target.closest('[data-act]') : null; |
| 1328 | if (!b || String(b.dataset.act).slice(0, 6) !== 'group-') return; |
| 1329 | if (!_host || !_host.contains(b)) return; |
| 1330 | var row = b.closest('[data-gid]'); |
| 1331 | var gid = row ? row.dataset.gid : ''; |
| 1332 | var act = b.dataset.act; |
| 1333 | e.preventDefault(); |
| 1334 | |
| 1335 | if (act === 'group-join') { join(gid); return; } |
| 1336 | if (act === 'group-decline') { leave(gid); return; } |
| 1337 | if (act === 'group-leave') { leave(gid); return; } |
| 1338 | if (act === 'group-drop') { |
| 1339 | get(gid).then(function (rec) { |
| 1340 | if (!rec) return; |
| 1341 | var keep = rec.members.filter(function (m) { |
| 1342 | return m.k !== rec.creator && m.k !== b.dataset.key; |
| 1343 | }).map(function (m) { return m.k; }); |
| 1344 | say(tOr('group.sending_roster', 'Telling everybody…')); |
| 1345 | return setMembers(gid, rec.name, keep).then(function (r) { |
| 1346 | say((r.ok ? tOr('group.roster_sent', 'Done.') : r.why) |
| 1347 | + shortfallWords(r)); |
| 1348 | }); |
| 1349 | }); |
| 1350 | return; |
| 1351 | } |
| 1352 | if (act === 'group-close') { |
| 1353 | // ONE DIALOGUE, AND THE ACT IS INSIDE ITS ANSWER. Not asked and then done |
| 1354 | // anyway: `askClose` resolving false is the whole of the refusal, so a |
| 1355 | // dismissed dialogue leaves the group exactly as it was. |
| 1356 | get(gid).then(function (rec) { |
| 1357 | if (!rec) return; |
| 1358 | return askClose(rec).then(function (yes) { |
| 1359 | if (!yes) return; |
| 1360 | say(tOr('group.sending_roster', 'Telling everybody…')); |
| 1361 | return close(gid).then(function (r) { |
| 1362 | say((r.ok |
| 1363 | ? tOr('group.closed_said', |
| 1364 | 'Closed, and {n} people have been told.', { n: r.sent | 0 }) |
| 1365 | : r.why) + shortfallWords(r)); |
| 1366 | }); |
| 1367 | }); |
| 1368 | }); |
| 1369 | return; |
| 1370 | } |
| 1371 | if (act === 'group-make') { |
| 1372 | var name = document.getElementById('group-name'); |
| 1373 | var pick = document.getElementById('group-members'); |
| 1374 | var keys = pick ? [].slice.call(pick.selectedOptions || []) |
| 1375 | .map(function (o) { return o.value; }) : []; |
| 1376 | if (!keys.length) { |
| 1377 | say(tOr('group.err_pick', 'Choose at least one person.')); |
| 1378 | return; |
| 1379 | } |
| 1380 | say(tOr('group.making', 'Making the group…')); |
| 1381 | create(name ? name.value : '', keys).then(function (r) { |
| 1382 | say((r.ok |
| 1383 | ? tOr('group.made', 'Made, and {n} people have been told.', { n: r.sent | 0 }) |
| 1384 | : r.why) + shortfallWords(r)); |
| 1385 | }); |
| 1386 | return; |
| 1387 | } |
| 1388 | }); |
| 1389 | |
| 1390 | // ── Public surface ───────────────────────────────────────── |
| 1391 | window.DaimondGroup = { |
| 1392 | /// The marker post.js refuses to let a person send. |
| 1393 | MARK: MARK, |
| 1394 | STATE: STATE, |
| 1395 | /// The identity of a group, and the whole of its authorisation model. |
| 1396 | deriveId: deriveId, |
| 1397 | /// What post.js asks about an artefact whose `to` is not this key. |
| 1398 | accepts: accepts, |
| 1399 | consume: consume, |
| 1400 | looksLikeOp: looksLikeOp, |
| 1401 | /// Who a message to a group is sealed to and delivered to, and who was |
| 1402 | /// left out. `skipped` is drawn by the caller, never swallowed. |
| 1403 | sealTo: sealTo, |
| 1404 | /// The acts. |
| 1405 | create: create, |
| 1406 | setMembers: setMembers, |
| 1407 | join: join, |
| 1408 | leave: leave, |
| 1409 | /// The one that cannot be undone. Its confirmation is on the control, not |
| 1410 | /// in here: a caller reaching this has already asked or is a verifier. |
| 1411 | close: close, |
| 1412 | /// Whether a group's roster names nobody, which is what closed IS. |
| 1413 | isClosed: isClosed, |
| 1414 | /// What is held, for a panel and for a verifier. |
| 1415 | list: async function () { |
| 1416 | var g = await all(); |
| 1417 | if (!g) return []; |
| 1418 | return Object.keys(g).map(function (k) { return g[k]; }); |
| 1419 | }, |
| 1420 | get: get, |
| 1421 | /// The panel, hosted inside post.js's own region. |
| 1422 | mount: mount, |
| 1423 | draw: draw, |
| 1424 | /// The three sentences, published so a verifier asserts the WORDS that are |
| 1425 | /// drawn rather than a class name that could be drawn empty. |
| 1426 | joiningSentence: joiningSentence, |
| 1427 | removingSentence: removingSentence, |
| 1428 | closingSentence: closingSentence, |
| 1429 | }; |
| 1430 | })(); |