@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.
@@ -7,7 +7,7 @@ import {
7
7
  __configure,
8
8
  endpointRefFromApiKey,
9
9
  getRuntime
10
- } from "../chunk-UX43AB4W.js";
10
+ } from "../chunk-3EVGYJ5F.js";
11
11
 
12
12
  // src/next/client.ts
13
13
  var SESSION_MAX_AGE_S = 2592e3;
@@ -2628,6 +2628,69 @@ var PalbeFlags = class {
2628
2628
  }
2629
2629
  };
2630
2630
 
2631
+ // src/messaging/delete-fold.ts
2632
+ var DeleteFold = class {
2633
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2634
+ tombstoned = /* @__PURE__ */ new Set();
2635
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2636
+ pending = /* @__PURE__ */ new Map();
2637
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2638
+ seen = /* @__PURE__ */ new Set();
2639
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2640
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2641
+ held = [];
2642
+ /**
2643
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2644
+ * userId (null = target absent locally → defer).
2645
+ */
2646
+ ingest(e, authorOfTarget) {
2647
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2648
+ if (this.seen.has(e.eventClientMsgId)) return;
2649
+ if (this.heldContains(e.eventClientMsgId)) return;
2650
+ const author = authorOfTarget(e.targetClientMsgId);
2651
+ if (author !== null) {
2652
+ this.seen.add(e.eventClientMsgId);
2653
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2654
+ this.tombstoned.add(e.targetClientMsgId);
2655
+ } else if (e.actorUserId !== null) {
2656
+ this.seen.add(e.eventClientMsgId);
2657
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2658
+ } else {
2659
+ this.held.push(e);
2660
+ }
2661
+ }
2662
+ /** True once a valid tombstone has absorbed this target. */
2663
+ isTombstoned(targetClientMsgId) {
2664
+ return this.tombstoned.has(targetClientMsgId);
2665
+ }
2666
+ /**
2667
+ * When a target message newly arrives with a resolved `author`, re-check any
2668
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2669
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2670
+ * the in-order path.
2671
+ */
2672
+ reevaluatePending(target, author) {
2673
+ const actor = this.pending.get(target);
2674
+ if (actor !== void 0) {
2675
+ if (author !== null && actor === author) {
2676
+ this.tombstoned.add(target);
2677
+ this.pending.delete(target);
2678
+ } else if (author !== null) {
2679
+ this.pending.delete(target);
2680
+ }
2681
+ }
2682
+ if (this.held.length === 0) return;
2683
+ const pendingHeld = this.held;
2684
+ this.held = [];
2685
+ for (const e of pendingHeld) {
2686
+ this.ingest(e, (t) => t === target ? author : null);
2687
+ }
2688
+ }
2689
+ heldContains(eventClientMsgId) {
2690
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2691
+ }
2692
+ };
2693
+
2631
2694
  // src/messaging/edit-fold.ts
2632
2695
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2633
2696
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2849,6 +2912,17 @@ async function listDevices(rt, userId) {
2849
2912
  }
2850
2913
 
2851
2914
  // src/messaging/group-messaging.ts
2915
+ function encodeDelete(args) {
2916
+ return encodeUtf8(
2917
+ JSON.stringify({
2918
+ v: 1,
2919
+ type: "delete",
2920
+ client_msg_id: args.clientMsgId,
2921
+ target_client_msg_id: args.targetClientMsgId,
2922
+ scope: "everyone"
2923
+ })
2924
+ );
2925
+ }
2852
2926
  function encodeEdit(args) {
2853
2927
  return encodeUtf8(
2854
2928
  JSON.stringify({
@@ -2886,6 +2960,18 @@ function decodeEnvelope(bytes) {
2886
2960
  const s = decodeUtf8(bytes);
2887
2961
  try {
2888
2962
  const o = JSON.parse(s);
2963
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2964
+ return {
2965
+ type: "delete",
2966
+ text: null,
2967
+ clientMsgId: o.client_msg_id ?? "",
2968
+ replyTo: null,
2969
+ delete: {
2970
+ targetClientMsgId: o.target_client_msg_id ?? "",
2971
+ scope: o.scope ?? "everyone"
2972
+ }
2973
+ };
2974
+ }
2889
2975
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2890
2976
  return {
2891
2977
  type: "reaction",
@@ -3273,6 +3359,55 @@ var GroupMessaging = class {
3273
3359
  clientMsgId: args.clientMsgId
3274
3360
  };
3275
3361
  }
3362
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3363
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3364
+ * application path as `sendText` (the server stays blind — a delete is just
3365
+ * another opaque application message; the original ciphertext row is NOT
3366
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3367
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3368
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3369
+ * rebases (epoch-bound like any application message). */
3370
+ async sendDelete(group, args) {
3371
+ const plaintext = encodeDelete({
3372
+ clientMsgId: args.clientMsgId,
3373
+ targetClientMsgId: args.targetClientMsgId
3374
+ });
3375
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3376
+ const body = {
3377
+ ciphertext_b64: toBase64(ct),
3378
+ client_idem_key: randomId()
3379
+ };
3380
+ const wire = await palbeRequest(
3381
+ this.rt,
3382
+ "POST",
3383
+ MessagingPaths.groupMessages(group.displayId),
3384
+ { body }
3385
+ );
3386
+ const stored = {
3387
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3388
+ direction: "outgoing",
3389
+ text: null,
3390
+ senderDeviceId: this.selfDeviceId,
3391
+ epoch: wire.epoch,
3392
+ serverSeq: wire.server_seq,
3393
+ at: Date.now(),
3394
+ clientMsgId: args.clientMsgId,
3395
+ replyTo: null,
3396
+ envelopeType: "delete",
3397
+ delete: {
3398
+ targetClientMsgId: args.targetClientMsgId,
3399
+ scope: "everyone"
3400
+ }
3401
+ };
3402
+ try {
3403
+ await this.messageStore.append(group.rfcGroupId, stored);
3404
+ } catch {
3405
+ }
3406
+ return {
3407
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3408
+ clientMsgId: args.clientMsgId
3409
+ };
3410
+ }
3276
3411
  // ── The rebase loop ──
3277
3412
  async commitWithRebase(rfcGroupId, build) {
3278
3413
  const gidBytes = fromBase64(rfcGroupId);
@@ -3390,6 +3525,7 @@ var ReactionFold = class {
3390
3525
  };
3391
3526
 
3392
3527
  // src/messaging/chat.ts
3528
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3393
3529
  var Chat = class {
3394
3530
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3395
3531
  id;
@@ -3411,6 +3547,15 @@ var Chat = class {
3411
3547
  reactionFold = new ReactionFold();
3412
3548
  /** The single authoritative edit fold for this chat (live + own-send + history). */
3413
3549
  editFold = new EditFold();
3550
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3551
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3552
+ deleteFold = new DeleteFold();
3553
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3554
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3555
+ suppressed = /* @__PURE__ */ new Set();
3556
+ /** True once the persisted suppression set has been loaded (so the omit applies
3557
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3558
+ suppressedLoaded = false;
3414
3559
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3415
3560
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3416
3561
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3459,7 +3604,7 @@ var Chat = class {
3459
3604
  return this.kind === "direct";
3460
3605
  }
3461
3606
  get messages() {
3462
- return this.messageList;
3607
+ return this.surfaced();
3463
3608
  }
3464
3609
  get members() {
3465
3610
  return this.memberCache;
@@ -3468,13 +3613,49 @@ var Chat = class {
3468
3613
  return this.typingList;
3469
3614
  }
3470
3615
  get lastMessage() {
3471
- return this.messageList.at(-1) ?? null;
3616
+ return this.surfaced().at(-1) ?? null;
3472
3617
  }
3473
3618
  get unreadCount() {
3474
- return this.messageList.filter(
3475
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3619
+ return this.surfaced().filter(
3620
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3476
3621
  ).length;
3477
3622
  }
3623
+ /**
3624
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3625
+ * through it identically). Over the raw `messageList` (which already carries the
3626
+ * folded edit text + reactions + reply):
3627
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3628
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3629
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3630
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3631
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3632
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3633
+ */
3634
+ surfaced() {
3635
+ const out = [];
3636
+ for (const m of this.messageList) {
3637
+ const key = this.suppressionKey(m);
3638
+ if (this.suppressed.has(key)) continue;
3639
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3640
+ if (tombstoned) {
3641
+ out.push({
3642
+ ...m,
3643
+ text: DELETED_DESCRIPTOR,
3644
+ reactions: {},
3645
+ replyTo: null,
3646
+ edited: false,
3647
+ isDeleted: true
3648
+ });
3649
+ continue;
3650
+ }
3651
+ out.push(m);
3652
+ }
3653
+ return out;
3654
+ }
3655
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3656
+ suppressionKey(m) {
3657
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3658
+ }
3478
3659
  get title() {
3479
3660
  if (this.titleOverride) return this.titleOverride;
3480
3661
  if (this._group?.name) return this._group.name;
@@ -3498,9 +3679,28 @@ var Chat = class {
3498
3679
  if (this.wired || this._state !== "active" || !this._group) return;
3499
3680
  this.wired = true;
3500
3681
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3682
+ void this.loadSuppressed();
3501
3683
  void this.hydrateHistory();
3502
3684
  void this.refreshMembers();
3503
3685
  }
3686
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3687
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3688
+ async loadSuppressed() {
3689
+ if (this.suppressedLoaded || !this._group) return;
3690
+ this.suppressedLoaded = true;
3691
+ try {
3692
+ const keys = await this.backend.loadSuppressed(this._group);
3693
+ let changed = false;
3694
+ for (const k of keys) {
3695
+ if (!this.suppressed.has(k)) {
3696
+ this.suppressed.add(k);
3697
+ changed = true;
3698
+ }
3699
+ }
3700
+ if (changed) this.emit();
3701
+ } catch {
3702
+ }
3703
+ }
3504
3704
  async hydrateHistory() {
3505
3705
  if (this.historyLoaded || !this._group) return;
3506
3706
  this.historyLoaded = true;
@@ -3511,13 +3711,13 @@ var Chat = class {
3511
3711
  let changed = false;
3512
3712
  for (const m of incoming) {
3513
3713
  if (m.serverSeq <= 0) continue;
3514
- if (m.clientMsgId && m.text !== null) {
3714
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3515
3715
  this.byClientMsgId.set(m.clientMsgId, {
3516
3716
  text: m.text,
3517
3717
  senderUserId: m.senderUserId ?? ""
3518
3718
  });
3519
3719
  }
3520
- if (m.clientMsgId) {
3720
+ if (m.clientMsgId && !m.isDeleted) {
3521
3721
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3522
3722
  }
3523
3723
  }
@@ -3527,6 +3727,9 @@ var Chat = class {
3527
3727
  const key = this.internalKey(m.serverSeq);
3528
3728
  if (this.seenKeys.has(key)) continue;
3529
3729
  this.seenKeys.add(key);
3730
+ if (m.clientMsgId && !m.isDeleted) {
3731
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3732
+ }
3530
3733
  this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3531
3734
  changed = true;
3532
3735
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
@@ -3584,6 +3787,21 @@ var Chat = class {
3584
3787
  this.recomputeEdit(incoming.edit.targetClientMsgId);
3585
3788
  return;
3586
3789
  }
3790
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3791
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3792
+ this.deleteFold.ingest(
3793
+ {
3794
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3795
+ actorUserId,
3796
+ epoch: incoming.epoch,
3797
+ serverSeq: incoming.serverSeq,
3798
+ eventClientMsgId: incoming.clientMsgId
3799
+ },
3800
+ this.authorOfTarget
3801
+ );
3802
+ this.emit();
3803
+ return;
3804
+ }
3587
3805
  const incomingClientMsgId = incoming.clientMsgId;
3588
3806
  const incomingReplyRef = incoming.replyRef;
3589
3807
  let resolvedReplyTo = null;
@@ -3604,7 +3822,9 @@ var Chat = class {
3604
3822
  // BEFORE its target — the dangling case — renders the moment the target lands).
3605
3823
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3606
3824
  // Default false; applyEditOverlay below folds any edit that arrived first.
3607
- edited: false
3825
+ edited: false,
3826
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3827
+ isDeleted: false
3608
3828
  };
3609
3829
  if (incomingClientMsgId && incoming.text !== null) {
3610
3830
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -3615,6 +3835,7 @@ var Chat = class {
3615
3835
  if (incomingClientMsgId) {
3616
3836
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3617
3837
  this.editFold.reevaluateHeld(this.authorOfTarget);
3838
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3618
3839
  }
3619
3840
  this.messageList.push(this.applyEditOverlay(msg));
3620
3841
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3856,7 +4077,9 @@ var Chat = class {
3856
4077
  // the dangling-target invariant uniform across every append path).
3857
4078
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3858
4079
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3859
- edited: false
4080
+ edited: false,
4081
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4082
+ isDeleted: false
3860
4083
  });
3861
4084
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3862
4085
  this.emit();
@@ -3968,6 +4191,52 @@ var Chat = class {
3968
4191
  );
3969
4192
  this.recomputeEdit(message.clientMsgId);
3970
4193
  }
4194
+ // ── Delete ──
4195
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
4196
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
4197
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
4198
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
4199
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
4200
+ * server stays blind), folds the own delete locally so the target scrubs in
4201
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
4202
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
4203
+ async deleteForEveryone(message) {
4204
+ if (!message.clientMsgId) return;
4205
+ const group = await this.materializeIfNeeded();
4206
+ const clientMsgId = mintClientMsgId();
4207
+ const { receipt } = await this.backend.sendDelete(group, {
4208
+ clientMsgId,
4209
+ targetClientMsgId: message.clientMsgId
4210
+ });
4211
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4212
+ this.deleteFold.ingest(
4213
+ {
4214
+ targetClientMsgId: message.clientMsgId,
4215
+ actorUserId: this.backend.selfUserId,
4216
+ epoch: receipt.epoch,
4217
+ serverSeq: receipt.serverSeq,
4218
+ eventClientMsgId: clientMsgId
4219
+ },
4220
+ this.authorOfTarget
4221
+ );
4222
+ this.emit();
4223
+ }
4224
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4225
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4226
+ * suppression key persists per chat (survives reload). The key is the message's
4227
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4228
+ async deleteForMe(message) {
4229
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4230
+ if (this.suppressed.has(key)) return;
4231
+ this.suppressed.add(key);
4232
+ this.emit();
4233
+ if (this._group) {
4234
+ try {
4235
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4236
+ } catch {
4237
+ }
4238
+ }
4239
+ }
3971
4240
  };
3972
4241
  function sameReactions(a, b) {
3973
4242
  const ak = Object.keys(a);
@@ -4148,6 +4417,7 @@ var MessageDeliverySource = class {
4148
4417
  const { text, clientMsgId, replyTo } = decoded;
4149
4418
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4150
4419
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4420
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
4151
4421
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4152
4422
  const stored = {
4153
4423
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4186,6 +4456,18 @@ var MessageDeliverySource = class {
4186
4456
  targetClientMsgId: decoded.edit.targetClientMsgId,
4187
4457
  newText: decoded.edit.newText
4188
4458
  }
4459
+ } : {},
4460
+ // Thread the delete discriminator + target through the persisted row so a
4461
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4462
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4463
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4464
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4465
+ ...isDelete && decoded.delete ? {
4466
+ envelopeType: "delete",
4467
+ delete: {
4468
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4469
+ scope: decoded.delete.scope
4470
+ }
4189
4471
  } : {}
4190
4472
  };
4191
4473
  try {
@@ -4205,7 +4487,8 @@ var MessageDeliverySource = class {
4205
4487
  replyRef: replyTo,
4206
4488
  envelopeType: decoded.type ?? "text",
4207
4489
  reaction: isReaction ? decoded.reaction : null,
4208
- edit: isEdit ? decoded.edit : null
4490
+ edit: isEdit ? decoded.edit : null,
4491
+ delete: isDelete ? decoded.delete : null
4209
4492
  });
4210
4493
  return true;
4211
4494
  }
@@ -6027,6 +6310,36 @@ var SignatureKeyStore = class {
6027
6310
  }
6028
6311
  };
6029
6312
 
6313
+ // src/messaging/suppression.ts
6314
+ var SuppressionStore = class {
6315
+ constructor(kv) {
6316
+ this.kv = kv;
6317
+ }
6318
+ kv;
6319
+ key(rfcGroupId) {
6320
+ return `supp:${rfcGroupId}`;
6321
+ }
6322
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6323
+ async load(rfcGroupId) {
6324
+ const raw = await this.kv.get(this.key(rfcGroupId));
6325
+ if (!raw) return [];
6326
+ try {
6327
+ const parsed = JSON.parse(decodeUtf8(raw));
6328
+ return Array.isArray(parsed) ? parsed : [];
6329
+ } catch {
6330
+ return [];
6331
+ }
6332
+ }
6333
+ /** Persist the full suppression key set for a chat (deterministic order). */
6334
+ async save(rfcGroupId, keys) {
6335
+ const sorted = [...new Set(keys)].sort();
6336
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6337
+ }
6338
+ async wipe() {
6339
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6340
+ }
6341
+ };
6342
+
6030
6343
  // src/messaging/coordinator.ts
6031
6344
  var MessagingCoordinator = class {
6032
6345
  constructor(rt) {
@@ -6036,6 +6349,7 @@ var MessagingCoordinator = class {
6036
6349
  this.sigStore = new SignatureKeyStore(this.kv);
6037
6350
  this.groupStore = new GroupStateStorage(this.kv);
6038
6351
  this.kpStore = new KeyPackageStorage(this.kv);
6352
+ this.suppressionStore = new SuppressionStore(this.kv);
6039
6353
  this.registry.attachChatList(
6040
6354
  (chats) => {
6041
6355
  this.chatList = chats;
@@ -6050,6 +6364,7 @@ var MessagingCoordinator = class {
6050
6364
  sigStore;
6051
6365
  groupStore;
6052
6366
  kpStore;
6367
+ suppressionStore;
6053
6368
  registry = new GroupRegistry();
6054
6369
  resolved = null;
6055
6370
  resolvePromise = null;
@@ -6217,6 +6532,18 @@ var MessagingCoordinator = class {
6217
6532
  const r = await this.resolve();
6218
6533
  return r.groups.sendEdit(group, args);
6219
6534
  }
6535
+ async sendDelete(group, args) {
6536
+ const r = await this.resolve();
6537
+ return r.groups.sendDelete(group, args);
6538
+ }
6539
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6540
+ loadSuppressed(group) {
6541
+ return this.suppressionStore.load(group.rfcGroupId);
6542
+ }
6543
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6544
+ saveSuppressed(group, keys) {
6545
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6546
+ }
6220
6547
  async history(group, limit, before) {
6221
6548
  const r = await this.resolve();
6222
6549
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6314,9 +6641,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6314
6641
  });
6315
6642
  }
6316
6643
  const editFold = new EditFold();
6644
+ const deleteFold = new DeleteFold();
6317
6645
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6318
6646
  for (const s of rows) {
6319
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6647
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6648
+ continue;
6320
6649
  const cid = s.clientMsgId ?? "";
6321
6650
  if (!cid) continue;
6322
6651
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -6339,9 +6668,25 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6339
6668
  );
6340
6669
  }
6341
6670
  editFold.reevaluateHeld(authorOfTarget);
6671
+ for (const s of rows) {
6672
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6673
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6674
+ deleteFold.ingest(
6675
+ {
6676
+ targetClientMsgId: s.delete.targetClientMsgId,
6677
+ actorUserId: actor,
6678
+ epoch: s.epoch,
6679
+ serverSeq: s.serverSeq,
6680
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6681
+ },
6682
+ authorOfTarget
6683
+ );
6684
+ }
6685
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
6342
6686
  const lookup = /* @__PURE__ */ new Map();
6343
6687
  for (const s of rows) {
6344
- if (s.envelopeType === "reaction") continue;
6688
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6689
+ continue;
6345
6690
  const cid = s.clientMsgId ?? "";
6346
6691
  if (cid && s.text !== null) {
6347
6692
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6350,8 +6695,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6350
6695
  }
6351
6696
  const out = [];
6352
6697
  for (const s of rows) {
6353
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6698
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6699
+ continue;
6354
6700
  const clientMsgId = s.clientMsgId ?? "";
6701
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6702
+ if (isDeleted) {
6703
+ out.push({
6704
+ id: `${displayId}#${s.serverSeq}`,
6705
+ kind: "text",
6706
+ direction: s.direction,
6707
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6708
+ text: DELETED_DESCRIPTOR,
6709
+ serverSeq: s.serverSeq,
6710
+ sentAt: new Date(s.at),
6711
+ clientMsgId,
6712
+ replyTo: null,
6713
+ reactions: {},
6714
+ edited: false,
6715
+ isDeleted: true
6716
+ });
6717
+ continue;
6718
+ }
6355
6719
  let replyTo = null;
6356
6720
  if (s.replyTo) {
6357
6721
  const ref = {
@@ -6379,7 +6743,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6379
6743
  clientMsgId,
6380
6744
  replyTo,
6381
6745
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6382
- edited
6746
+ edited,
6747
+ isDeleted: false
6383
6748
  });
6384
6749
  }
6385
6750
  return out;
@@ -7117,7 +7482,7 @@ function defaultSessionStorage(key) {
7117
7482
  }
7118
7483
 
7119
7484
  // src/version.ts
7120
- var VERSION = "1.3.0";
7485
+ var VERSION = "1.4.0";
7121
7486
 
7122
7487
  // src/runtime.ts
7123
7488
  function buildRuntime(config) {