@palbase/web 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1607,6 +1607,69 @@ var PalbeFlags = class {
1607
1607
  }
1608
1608
  };
1609
1609
 
1610
+ // src/messaging/delete-fold.ts
1611
+ var DeleteFold = class {
1612
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
1613
+ tombstoned = /* @__PURE__ */ new Set();
1614
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
1615
+ pending = /* @__PURE__ */ new Map();
1616
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
1617
+ seen = /* @__PURE__ */ new Set();
1618
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
1619
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
1620
+ held = [];
1621
+ /**
1622
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
1623
+ * userId (null = target absent locally → defer).
1624
+ */
1625
+ ingest(e, authorOfTarget) {
1626
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
1627
+ if (this.seen.has(e.eventClientMsgId)) return;
1628
+ if (this.heldContains(e.eventClientMsgId)) return;
1629
+ const author = authorOfTarget(e.targetClientMsgId);
1630
+ if (author !== null) {
1631
+ this.seen.add(e.eventClientMsgId);
1632
+ if (e.actorUserId === null || e.actorUserId !== author) return;
1633
+ this.tombstoned.add(e.targetClientMsgId);
1634
+ } else if (e.actorUserId !== null) {
1635
+ this.seen.add(e.eventClientMsgId);
1636
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
1637
+ } else {
1638
+ this.held.push(e);
1639
+ }
1640
+ }
1641
+ /** True once a valid tombstone has absorbed this target. */
1642
+ isTombstoned(targetClientMsgId) {
1643
+ return this.tombstoned.has(targetClientMsgId);
1644
+ }
1645
+ /**
1646
+ * When a target message newly arrives with a resolved `author`, re-check any
1647
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
1648
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
1649
+ * the in-order path.
1650
+ */
1651
+ reevaluatePending(target, author) {
1652
+ const actor = this.pending.get(target);
1653
+ if (actor !== void 0) {
1654
+ if (author !== null && actor === author) {
1655
+ this.tombstoned.add(target);
1656
+ this.pending.delete(target);
1657
+ } else if (author !== null) {
1658
+ this.pending.delete(target);
1659
+ }
1660
+ }
1661
+ if (this.held.length === 0) return;
1662
+ const pendingHeld = this.held;
1663
+ this.held = [];
1664
+ for (const e of pendingHeld) {
1665
+ this.ingest(e, (t) => t === target ? author : null);
1666
+ }
1667
+ }
1668
+ heldContains(eventClientMsgId) {
1669
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
1670
+ }
1671
+ };
1672
+
1610
1673
  // src/messaging/edit-fold.ts
1611
1674
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
1612
1675
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -1828,6 +1891,17 @@ async function listDevices(rt, userId) {
1828
1891
  }
1829
1892
 
1830
1893
  // src/messaging/group-messaging.ts
1894
+ function encodeDelete(args) {
1895
+ return encodeUtf8(
1896
+ JSON.stringify({
1897
+ v: 1,
1898
+ type: "delete",
1899
+ client_msg_id: args.clientMsgId,
1900
+ target_client_msg_id: args.targetClientMsgId,
1901
+ scope: "everyone"
1902
+ })
1903
+ );
1904
+ }
1831
1905
  function encodeEdit(args) {
1832
1906
  return encodeUtf8(
1833
1907
  JSON.stringify({
@@ -1865,6 +1939,18 @@ function decodeEnvelope(bytes) {
1865
1939
  const s = decodeUtf8(bytes);
1866
1940
  try {
1867
1941
  const o = JSON.parse(s);
1942
+ if (typeof o === "object" && o !== null && o.type === "delete") {
1943
+ return {
1944
+ type: "delete",
1945
+ text: null,
1946
+ clientMsgId: o.client_msg_id ?? "",
1947
+ replyTo: null,
1948
+ delete: {
1949
+ targetClientMsgId: o.target_client_msg_id ?? "",
1950
+ scope: o.scope ?? "everyone"
1951
+ }
1952
+ };
1953
+ }
1868
1954
  if (typeof o === "object" && o !== null && o.type === "reaction") {
1869
1955
  return {
1870
1956
  type: "reaction",
@@ -2252,6 +2338,55 @@ var GroupMessaging = class {
2252
2338
  clientMsgId: args.clientMsgId
2253
2339
  };
2254
2340
  }
2341
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
2342
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
2343
+ * application path as `sendText` (the server stays blind — a delete is just
2344
+ * another opaque application message; the original ciphertext row is NOT
2345
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
2346
+ * target after a reload (the own-send half of the reload parity — the iOS-review
2347
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
2348
+ * rebases (epoch-bound like any application message). */
2349
+ async sendDelete(group, args) {
2350
+ const plaintext = encodeDelete({
2351
+ clientMsgId: args.clientMsgId,
2352
+ targetClientMsgId: args.targetClientMsgId
2353
+ });
2354
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2355
+ const body = {
2356
+ ciphertext_b64: toBase64(ct),
2357
+ client_idem_key: randomId()
2358
+ };
2359
+ const wire = await palbeRequest(
2360
+ this.rt,
2361
+ "POST",
2362
+ MessagingPaths.groupMessages(group.displayId),
2363
+ { body }
2364
+ );
2365
+ const stored = {
2366
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
2367
+ direction: "outgoing",
2368
+ text: null,
2369
+ senderDeviceId: this.selfDeviceId,
2370
+ epoch: wire.epoch,
2371
+ serverSeq: wire.server_seq,
2372
+ at: Date.now(),
2373
+ clientMsgId: args.clientMsgId,
2374
+ replyTo: null,
2375
+ envelopeType: "delete",
2376
+ delete: {
2377
+ targetClientMsgId: args.targetClientMsgId,
2378
+ scope: "everyone"
2379
+ }
2380
+ };
2381
+ try {
2382
+ await this.messageStore.append(group.rfcGroupId, stored);
2383
+ } catch {
2384
+ }
2385
+ return {
2386
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
2387
+ clientMsgId: args.clientMsgId
2388
+ };
2389
+ }
2255
2390
  // ── The rebase loop ──
2256
2391
  async commitWithRebase(rfcGroupId, build) {
2257
2392
  const gidBytes = fromBase64(rfcGroupId);
@@ -2369,6 +2504,7 @@ var ReactionFold = class {
2369
2504
  };
2370
2505
 
2371
2506
  // src/messaging/chat.ts
2507
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
2372
2508
  var Chat = class {
2373
2509
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
2374
2510
  id;
@@ -2390,6 +2526,15 @@ var Chat = class {
2390
2526
  reactionFold = new ReactionFold();
2391
2527
  /** The single authoritative edit fold for this chat (live + own-send + history). */
2392
2528
  editFold = new EditFold();
2529
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
2530
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
2531
+ deleteFold = new DeleteFold();
2532
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
2533
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
2534
+ suppressed = /* @__PURE__ */ new Set();
2535
+ /** True once the persisted suppression set has been loaded (so the omit applies
2536
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
2537
+ suppressedLoaded = false;
2393
2538
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
2394
2539
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
2395
2540
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -2438,7 +2583,7 @@ var Chat = class {
2438
2583
  return this.kind === "direct";
2439
2584
  }
2440
2585
  get messages() {
2441
- return this.messageList;
2586
+ return this.surfaced();
2442
2587
  }
2443
2588
  get members() {
2444
2589
  return this.memberCache;
@@ -2447,13 +2592,49 @@ var Chat = class {
2447
2592
  return this.typingList;
2448
2593
  }
2449
2594
  get lastMessage() {
2450
- return this.messageList.at(-1) ?? null;
2595
+ return this.surfaced().at(-1) ?? null;
2451
2596
  }
2452
2597
  get unreadCount() {
2453
- return this.messageList.filter(
2454
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
2598
+ return this.surfaced().filter(
2599
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
2455
2600
  ).length;
2456
2601
  }
2602
+ /**
2603
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
2604
+ * through it identically). Over the raw `messageList` (which already carries the
2605
+ * folded edit text + reactions + reply):
2606
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
2607
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
2608
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
2609
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
2610
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
2611
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
2612
+ */
2613
+ surfaced() {
2614
+ const out = [];
2615
+ for (const m of this.messageList) {
2616
+ const key = this.suppressionKey(m);
2617
+ if (this.suppressed.has(key)) continue;
2618
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
2619
+ if (tombstoned) {
2620
+ out.push({
2621
+ ...m,
2622
+ text: DELETED_DESCRIPTOR,
2623
+ reactions: {},
2624
+ replyTo: null,
2625
+ edited: false,
2626
+ isDeleted: true
2627
+ });
2628
+ continue;
2629
+ }
2630
+ out.push(m);
2631
+ }
2632
+ return out;
2633
+ }
2634
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
2635
+ suppressionKey(m) {
2636
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
2637
+ }
2457
2638
  get title() {
2458
2639
  if (this.titleOverride) return this.titleOverride;
2459
2640
  if (this._group?.name) return this._group.name;
@@ -2477,9 +2658,28 @@ var Chat = class {
2477
2658
  if (this.wired || this._state !== "active" || !this._group) return;
2478
2659
  this.wired = true;
2479
2660
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
2661
+ void this.loadSuppressed();
2480
2662
  void this.hydrateHistory();
2481
2663
  void this.refreshMembers();
2482
2664
  }
2665
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
2666
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
2667
+ async loadSuppressed() {
2668
+ if (this.suppressedLoaded || !this._group) return;
2669
+ this.suppressedLoaded = true;
2670
+ try {
2671
+ const keys = await this.backend.loadSuppressed(this._group);
2672
+ let changed = false;
2673
+ for (const k of keys) {
2674
+ if (!this.suppressed.has(k)) {
2675
+ this.suppressed.add(k);
2676
+ changed = true;
2677
+ }
2678
+ }
2679
+ if (changed) this.emit();
2680
+ } catch {
2681
+ }
2682
+ }
2483
2683
  async hydrateHistory() {
2484
2684
  if (this.historyLoaded || !this._group) return;
2485
2685
  this.historyLoaded = true;
@@ -2490,13 +2690,13 @@ var Chat = class {
2490
2690
  let changed = false;
2491
2691
  for (const m of incoming) {
2492
2692
  if (m.serverSeq <= 0) continue;
2493
- if (m.clientMsgId && m.text !== null) {
2693
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
2494
2694
  this.byClientMsgId.set(m.clientMsgId, {
2495
2695
  text: m.text,
2496
2696
  senderUserId: m.senderUserId ?? ""
2497
2697
  });
2498
2698
  }
2499
- if (m.clientMsgId) {
2699
+ if (m.clientMsgId && !m.isDeleted) {
2500
2700
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
2501
2701
  }
2502
2702
  }
@@ -2506,6 +2706,9 @@ var Chat = class {
2506
2706
  const key = this.internalKey(m.serverSeq);
2507
2707
  if (this.seenKeys.has(key)) continue;
2508
2708
  this.seenKeys.add(key);
2709
+ if (m.clientMsgId && !m.isDeleted) {
2710
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
2711
+ }
2509
2712
  this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
2510
2713
  changed = true;
2511
2714
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
@@ -2563,6 +2766,21 @@ var Chat = class {
2563
2766
  this.recomputeEdit(incoming.edit.targetClientMsgId);
2564
2767
  return;
2565
2768
  }
2769
+ if (incoming.envelopeType === "delete" && incoming.delete) {
2770
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
2771
+ this.deleteFold.ingest(
2772
+ {
2773
+ targetClientMsgId: incoming.delete.targetClientMsgId,
2774
+ actorUserId,
2775
+ epoch: incoming.epoch,
2776
+ serverSeq: incoming.serverSeq,
2777
+ eventClientMsgId: incoming.clientMsgId
2778
+ },
2779
+ this.authorOfTarget
2780
+ );
2781
+ this.emit();
2782
+ return;
2783
+ }
2566
2784
  const incomingClientMsgId = incoming.clientMsgId;
2567
2785
  const incomingReplyRef = incoming.replyRef;
2568
2786
  let resolvedReplyTo = null;
@@ -2583,7 +2801,9 @@ var Chat = class {
2583
2801
  // BEFORE its target — the dangling case — renders the moment the target lands).
2584
2802
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
2585
2803
  // Default false; applyEditOverlay below folds any edit that arrived first.
2586
- edited: false
2804
+ edited: false,
2805
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
2806
+ isDeleted: false
2587
2807
  };
2588
2808
  if (incomingClientMsgId && incoming.text !== null) {
2589
2809
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -2594,6 +2814,7 @@ var Chat = class {
2594
2814
  if (incomingClientMsgId) {
2595
2815
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
2596
2816
  this.editFold.reevaluateHeld(this.authorOfTarget);
2817
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
2597
2818
  }
2598
2819
  this.messageList.push(this.applyEditOverlay(msg));
2599
2820
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -2835,7 +3056,9 @@ var Chat = class {
2835
3056
  // the dangling-target invariant uniform across every append path).
2836
3057
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
2837
3058
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
2838
- edited: false
3059
+ edited: false,
3060
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
3061
+ isDeleted: false
2839
3062
  });
2840
3063
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
2841
3064
  this.emit();
@@ -2947,6 +3170,52 @@ var Chat = class {
2947
3170
  );
2948
3171
  this.recomputeEdit(message.clientMsgId);
2949
3172
  }
3173
+ // ── Delete ──
3174
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
3175
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
3176
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
3177
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
3178
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
3179
+ * server stays blind), folds the own delete locally so the target scrubs in
3180
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
3181
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
3182
+ async deleteForEveryone(message) {
3183
+ if (!message.clientMsgId) return;
3184
+ const group = await this.materializeIfNeeded();
3185
+ const clientMsgId = mintClientMsgId();
3186
+ const { receipt } = await this.backend.sendDelete(group, {
3187
+ clientMsgId,
3188
+ targetClientMsgId: message.clientMsgId
3189
+ });
3190
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3191
+ this.deleteFold.ingest(
3192
+ {
3193
+ targetClientMsgId: message.clientMsgId,
3194
+ actorUserId: this.backend.selfUserId,
3195
+ epoch: receipt.epoch,
3196
+ serverSeq: receipt.serverSeq,
3197
+ eventClientMsgId: clientMsgId
3198
+ },
3199
+ this.authorOfTarget
3200
+ );
3201
+ this.emit();
3202
+ }
3203
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
3204
+ * attribution, no server contact: the message is OMITTED from THIS view and the
3205
+ * suppression key persists per chat (survives reload). The key is the message's
3206
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
3207
+ async deleteForMe(message) {
3208
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3209
+ if (this.suppressed.has(key)) return;
3210
+ this.suppressed.add(key);
3211
+ this.emit();
3212
+ if (this._group) {
3213
+ try {
3214
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
3215
+ } catch {
3216
+ }
3217
+ }
3218
+ }
2950
3219
  };
2951
3220
  function sameReactions(a, b) {
2952
3221
  const ak = Object.keys(a);
@@ -3127,6 +3396,7 @@ var MessageDeliverySource = class {
3127
3396
  const { text, clientMsgId, replyTo } = decoded;
3128
3397
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
3129
3398
  const isEdit = decoded.type === "edit" && decoded.edit != null;
3399
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
3130
3400
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3131
3401
  const stored = {
3132
3402
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3165,6 +3435,18 @@ var MessageDeliverySource = class {
3165
3435
  targetClientMsgId: decoded.edit.targetClientMsgId,
3166
3436
  newText: decoded.edit.newText
3167
3437
  }
3438
+ } : {},
3439
+ // Thread the delete discriminator + target through the persisted row so a
3440
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
3441
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
3442
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
3443
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
3444
+ ...isDelete && decoded.delete ? {
3445
+ envelopeType: "delete",
3446
+ delete: {
3447
+ targetClientMsgId: decoded.delete.targetClientMsgId,
3448
+ scope: decoded.delete.scope
3449
+ }
3168
3450
  } : {}
3169
3451
  };
3170
3452
  try {
@@ -3184,7 +3466,8 @@ var MessageDeliverySource = class {
3184
3466
  replyRef: replyTo,
3185
3467
  envelopeType: decoded.type ?? "text",
3186
3468
  reaction: isReaction ? decoded.reaction : null,
3187
- edit: isEdit ? decoded.edit : null
3469
+ edit: isEdit ? decoded.edit : null,
3470
+ delete: isDelete ? decoded.delete : null
3188
3471
  });
3189
3472
  return true;
3190
3473
  }
@@ -5006,6 +5289,36 @@ var SignatureKeyStore = class {
5006
5289
  }
5007
5290
  };
5008
5291
 
5292
+ // src/messaging/suppression.ts
5293
+ var SuppressionStore = class {
5294
+ constructor(kv) {
5295
+ this.kv = kv;
5296
+ }
5297
+ kv;
5298
+ key(rfcGroupId) {
5299
+ return `supp:${rfcGroupId}`;
5300
+ }
5301
+ /** Load the persisted suppression keys for a chat (empty array if none). */
5302
+ async load(rfcGroupId) {
5303
+ const raw = await this.kv.get(this.key(rfcGroupId));
5304
+ if (!raw) return [];
5305
+ try {
5306
+ const parsed = JSON.parse(decodeUtf8(raw));
5307
+ return Array.isArray(parsed) ? parsed : [];
5308
+ } catch {
5309
+ return [];
5310
+ }
5311
+ }
5312
+ /** Persist the full suppression key set for a chat (deterministic order). */
5313
+ async save(rfcGroupId, keys) {
5314
+ const sorted = [...new Set(keys)].sort();
5315
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
5316
+ }
5317
+ async wipe() {
5318
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
5319
+ }
5320
+ };
5321
+
5009
5322
  // src/messaging/coordinator.ts
5010
5323
  var MessagingCoordinator = class {
5011
5324
  constructor(rt) {
@@ -5015,6 +5328,7 @@ var MessagingCoordinator = class {
5015
5328
  this.sigStore = new SignatureKeyStore(this.kv);
5016
5329
  this.groupStore = new GroupStateStorage(this.kv);
5017
5330
  this.kpStore = new KeyPackageStorage(this.kv);
5331
+ this.suppressionStore = new SuppressionStore(this.kv);
5018
5332
  this.registry.attachChatList(
5019
5333
  (chats) => {
5020
5334
  this.chatList = chats;
@@ -5029,6 +5343,7 @@ var MessagingCoordinator = class {
5029
5343
  sigStore;
5030
5344
  groupStore;
5031
5345
  kpStore;
5346
+ suppressionStore;
5032
5347
  registry = new GroupRegistry();
5033
5348
  resolved = null;
5034
5349
  resolvePromise = null;
@@ -5196,6 +5511,18 @@ var MessagingCoordinator = class {
5196
5511
  const r = await this.resolve();
5197
5512
  return r.groups.sendEdit(group, args);
5198
5513
  }
5514
+ async sendDelete(group, args) {
5515
+ const r = await this.resolve();
5516
+ return r.groups.sendDelete(group, args);
5517
+ }
5518
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
5519
+ loadSuppressed(group) {
5520
+ return this.suppressionStore.load(group.rfcGroupId);
5521
+ }
5522
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
5523
+ saveSuppressed(group, keys) {
5524
+ return this.suppressionStore.save(group.rfcGroupId, keys);
5525
+ }
5199
5526
  async history(group, limit, before) {
5200
5527
  const r = await this.resolve();
5201
5528
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -5293,9 +5620,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5293
5620
  });
5294
5621
  }
5295
5622
  const editFold = new EditFold();
5623
+ const deleteFold = new DeleteFold();
5296
5624
  const authorByClientMsgId = /* @__PURE__ */ new Map();
5297
5625
  for (const s of rows) {
5298
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
5626
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5627
+ continue;
5299
5628
  const cid = s.clientMsgId ?? "";
5300
5629
  if (!cid) continue;
5301
5630
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -5318,9 +5647,25 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5318
5647
  );
5319
5648
  }
5320
5649
  editFold.reevaluateHeld(authorOfTarget);
5650
+ for (const s of rows) {
5651
+ if (s.envelopeType !== "delete" || !s.delete) continue;
5652
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5653
+ deleteFold.ingest(
5654
+ {
5655
+ targetClientMsgId: s.delete.targetClientMsgId,
5656
+ actorUserId: actor,
5657
+ epoch: s.epoch,
5658
+ serverSeq: s.serverSeq,
5659
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
5660
+ },
5661
+ authorOfTarget
5662
+ );
5663
+ }
5664
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
5321
5665
  const lookup = /* @__PURE__ */ new Map();
5322
5666
  for (const s of rows) {
5323
- if (s.envelopeType === "reaction") continue;
5667
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5668
+ continue;
5324
5669
  const cid = s.clientMsgId ?? "";
5325
5670
  if (cid && s.text !== null) {
5326
5671
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -5329,8 +5674,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5329
5674
  }
5330
5675
  const out = [];
5331
5676
  for (const s of rows) {
5332
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
5677
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5678
+ continue;
5333
5679
  const clientMsgId = s.clientMsgId ?? "";
5680
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
5681
+ if (isDeleted) {
5682
+ out.push({
5683
+ id: `${displayId}#${s.serverSeq}`,
5684
+ kind: "text",
5685
+ direction: s.direction,
5686
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
5687
+ text: DELETED_DESCRIPTOR,
5688
+ serverSeq: s.serverSeq,
5689
+ sentAt: new Date(s.at),
5690
+ clientMsgId,
5691
+ replyTo: null,
5692
+ reactions: {},
5693
+ edited: false,
5694
+ isDeleted: true
5695
+ });
5696
+ continue;
5697
+ }
5334
5698
  let replyTo = null;
5335
5699
  if (s.replyTo) {
5336
5700
  const ref = {
@@ -5358,7 +5722,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5358
5722
  clientMsgId,
5359
5723
  replyTo,
5360
5724
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
5361
- edited
5725
+ edited,
5726
+ isDeleted: false
5362
5727
  });
5363
5728
  }
5364
5729
  return out;
@@ -6215,7 +6580,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
6215
6580
  }
6216
6581
 
6217
6582
  // src/version.ts
6218
- var VERSION = "1.3.0";
6583
+ var VERSION = "1.4.0";
6219
6584
 
6220
6585
  // src/internal.ts
6221
6586
  function getRuntime() {