@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.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- export { A as AnalyticsProperties, a as AuthChangeEvent, b as AuthState, c as AuthSuccess, d as AuthUser, C as Call, e as CallChangeCallback, f as CallParticipant, g as CallState, h as Chat, i as ChatBackend, j as ChatDraft, k as ChatKind, l as ChatMember, m as ChatMessage, n as ChatMessageKind, o as ChatRole, p as ChatState, q as CommsPrefs, F as FlagsView, M as MagicLinkResult, r as MessageDirection, O as OAuthExchangeResult, P as PalbeAnalytics, s as PalbeAuth, t as PalbeCalls, u as PalbeConfig, v as PalbeFlags, w as PalbeMessaging, x as PalbeOAuthConfig, y as PalbeRealtime, z as PresenceState, R as RealtimeChannel, B as RealtimeConnectionState, D as RealtimeHandler, E as RealtimePayload, G as RealtimeStatus, H as RealtimeStatusSnapshot, I as RealtimeSubscription, J as ResolvedReply, S as SentReceipt, U as Unsubscribe } from './analytics-facade-B8UATgS4.cjs';
1
+ export { A as AnalyticsProperties, a as AuthChangeEvent, b as AuthState, c as AuthSuccess, d as AuthUser, C as Call, e as CallChangeCallback, f as CallParticipant, g as CallState, h as Chat, i as ChatBackend, j as ChatDraft, k as ChatKind, l as ChatMember, m as ChatMessage, n as ChatMessageKind, o as ChatRole, p as ChatState, q as CommsPrefs, F as FlagsView, M as MagicLinkResult, r as MessageDirection, O as OAuthExchangeResult, P as PalbeAnalytics, s as PalbeAuth, t as PalbeCalls, u as PalbeConfig, v as PalbeFlags, w as PalbeMessaging, x as PalbeOAuthConfig, y as PalbeRealtime, z as PresenceState, R as RealtimeChannel, B as RealtimeConnectionState, D as RealtimeHandler, E as RealtimePayload, G as RealtimeStatus, H as RealtimeStatusSnapshot, I as RealtimeSubscription, J as ResolvedReply, S as SentReceipt, U as Unsubscribe } from './analytics-facade-DA9TGKLF.cjs';
2
2
  export { B as BackendError, a as BackendErrorKind, F as FieldError, i as isBackendError } from './errors-fDoNdTrJ.cjs';
3
- export { C as CallOptions, P as PB, U as UploadOptions, a as UploadProgress, p as pb } from './pb-DivIiUGR.cjs';
3
+ export { C as CallOptions, P as PB, U as UploadOptions, a as UploadProgress, p as pb } from './pb-SVs7vTOp.cjs';
4
4
  export { P as PersistedSession, S as SessionStorageAdapter, l as localStorageSessionStorage, m as memorySessionStorage } from './storage-BPaeSG8K.cjs';
5
5
  export { F as FlagValue } from './pooled-flags-Bwq4usn0.js';
6
6
  import 'livekit-client';
@@ -207,6 +207,6 @@ interface MlsGlue {
207
207
  */
208
208
  declare function initMls(): Promise<MlsGlue>;
209
209
 
210
- declare const VERSION = "1.3.0";
210
+ declare const VERSION = "1.4.0";
211
211
 
212
212
  export { VERSION, initMls };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { A as AnalyticsProperties, a as AuthChangeEvent, b as AuthState, c as AuthSuccess, d as AuthUser, C as Call, e as CallChangeCallback, f as CallParticipant, g as CallState, h as Chat, i as ChatBackend, j as ChatDraft, k as ChatKind, l as ChatMember, m as ChatMessage, n as ChatMessageKind, o as ChatRole, p as ChatState, q as CommsPrefs, F as FlagsView, M as MagicLinkResult, r as MessageDirection, O as OAuthExchangeResult, P as PalbeAnalytics, s as PalbeAuth, t as PalbeCalls, u as PalbeConfig, v as PalbeFlags, w as PalbeMessaging, x as PalbeOAuthConfig, y as PalbeRealtime, z as PresenceState, R as RealtimeChannel, B as RealtimeConnectionState, D as RealtimeHandler, E as RealtimePayload, G as RealtimeStatus, H as RealtimeStatusSnapshot, I as RealtimeSubscription, J as ResolvedReply, S as SentReceipt, U as Unsubscribe } from './analytics-facade-Cq7R-VH_.js';
1
+ export { A as AnalyticsProperties, a as AuthChangeEvent, b as AuthState, c as AuthSuccess, d as AuthUser, C as Call, e as CallChangeCallback, f as CallParticipant, g as CallState, h as Chat, i as ChatBackend, j as ChatDraft, k as ChatKind, l as ChatMember, m as ChatMessage, n as ChatMessageKind, o as ChatRole, p as ChatState, q as CommsPrefs, F as FlagsView, M as MagicLinkResult, r as MessageDirection, O as OAuthExchangeResult, P as PalbeAnalytics, s as PalbeAuth, t as PalbeCalls, u as PalbeConfig, v as PalbeFlags, w as PalbeMessaging, x as PalbeOAuthConfig, y as PalbeRealtime, z as PresenceState, R as RealtimeChannel, B as RealtimeConnectionState, D as RealtimeHandler, E as RealtimePayload, G as RealtimeStatus, H as RealtimeStatusSnapshot, I as RealtimeSubscription, J as ResolvedReply, S as SentReceipt, U as Unsubscribe } from './analytics-facade-CbXprYrS.js';
2
2
  export { B as BackendError, a as BackendErrorKind, F as FieldError, i as isBackendError } from './errors-fDoNdTrJ.js';
3
- export { C as CallOptions, P as PB, U as UploadOptions, a as UploadProgress, p as pb } from './pb-DSJiH1uV.js';
3
+ export { C as CallOptions, P as PB, U as UploadOptions, a as UploadProgress, p as pb } from './pb-CLfTLQzH.js';
4
4
  export { P as PersistedSession, S as SessionStorageAdapter, l as localStorageSessionStorage, m as memorySessionStorage } from './storage-BPaeSG8K.js';
5
5
  export { F as FlagValue } from './pooled-flags-Bwq4usn0.js';
6
6
  import 'livekit-client';
@@ -207,6 +207,6 @@ interface MlsGlue {
207
207
  */
208
208
  declare function initMls(): Promise<MlsGlue>;
209
209
 
210
- declare const VERSION = "1.3.0";
210
+ declare const VERSION = "1.4.0";
211
211
 
212
212
  export { VERSION, initMls };
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  localStorageSessionStorage,
14
14
  memorySessionStorage,
15
15
  pb
16
- } from "./chunk-UX43AB4W.js";
16
+ } from "./chunk-3EVGYJ5F.js";
17
17
  export {
18
18
  BackendError,
19
19
  PalbeAnalytics,
package/dist/internal.cjs CHANGED
@@ -2612,6 +2612,69 @@ var PalbeFlags = class {
2612
2612
  }
2613
2613
  };
2614
2614
 
2615
+ // src/messaging/delete-fold.ts
2616
+ var DeleteFold = class {
2617
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2618
+ tombstoned = /* @__PURE__ */ new Set();
2619
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2620
+ pending = /* @__PURE__ */ new Map();
2621
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2622
+ seen = /* @__PURE__ */ new Set();
2623
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2624
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2625
+ held = [];
2626
+ /**
2627
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2628
+ * userId (null = target absent locally → defer).
2629
+ */
2630
+ ingest(e, authorOfTarget) {
2631
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2632
+ if (this.seen.has(e.eventClientMsgId)) return;
2633
+ if (this.heldContains(e.eventClientMsgId)) return;
2634
+ const author = authorOfTarget(e.targetClientMsgId);
2635
+ if (author !== null) {
2636
+ this.seen.add(e.eventClientMsgId);
2637
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2638
+ this.tombstoned.add(e.targetClientMsgId);
2639
+ } else if (e.actorUserId !== null) {
2640
+ this.seen.add(e.eventClientMsgId);
2641
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2642
+ } else {
2643
+ this.held.push(e);
2644
+ }
2645
+ }
2646
+ /** True once a valid tombstone has absorbed this target. */
2647
+ isTombstoned(targetClientMsgId) {
2648
+ return this.tombstoned.has(targetClientMsgId);
2649
+ }
2650
+ /**
2651
+ * When a target message newly arrives with a resolved `author`, re-check any
2652
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2653
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2654
+ * the in-order path.
2655
+ */
2656
+ reevaluatePending(target, author) {
2657
+ const actor = this.pending.get(target);
2658
+ if (actor !== void 0) {
2659
+ if (author !== null && actor === author) {
2660
+ this.tombstoned.add(target);
2661
+ this.pending.delete(target);
2662
+ } else if (author !== null) {
2663
+ this.pending.delete(target);
2664
+ }
2665
+ }
2666
+ if (this.held.length === 0) return;
2667
+ const pendingHeld = this.held;
2668
+ this.held = [];
2669
+ for (const e of pendingHeld) {
2670
+ this.ingest(e, (t) => t === target ? author : null);
2671
+ }
2672
+ }
2673
+ heldContains(eventClientMsgId) {
2674
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2675
+ }
2676
+ };
2677
+
2615
2678
  // src/messaging/edit-fold.ts
2616
2679
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2617
2680
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2833,6 +2896,17 @@ async function listDevices(rt, userId) {
2833
2896
  }
2834
2897
 
2835
2898
  // src/messaging/group-messaging.ts
2899
+ function encodeDelete(args) {
2900
+ return encodeUtf8(
2901
+ JSON.stringify({
2902
+ v: 1,
2903
+ type: "delete",
2904
+ client_msg_id: args.clientMsgId,
2905
+ target_client_msg_id: args.targetClientMsgId,
2906
+ scope: "everyone"
2907
+ })
2908
+ );
2909
+ }
2836
2910
  function encodeEdit(args) {
2837
2911
  return encodeUtf8(
2838
2912
  JSON.stringify({
@@ -2870,6 +2944,18 @@ function decodeEnvelope(bytes) {
2870
2944
  const s = decodeUtf8(bytes);
2871
2945
  try {
2872
2946
  const o = JSON.parse(s);
2947
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2948
+ return {
2949
+ type: "delete",
2950
+ text: null,
2951
+ clientMsgId: o.client_msg_id ?? "",
2952
+ replyTo: null,
2953
+ delete: {
2954
+ targetClientMsgId: o.target_client_msg_id ?? "",
2955
+ scope: o.scope ?? "everyone"
2956
+ }
2957
+ };
2958
+ }
2873
2959
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2874
2960
  return {
2875
2961
  type: "reaction",
@@ -3257,6 +3343,55 @@ var GroupMessaging = class {
3257
3343
  clientMsgId: args.clientMsgId
3258
3344
  };
3259
3345
  }
3346
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3347
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3348
+ * application path as `sendText` (the server stays blind — a delete is just
3349
+ * another opaque application message; the original ciphertext row is NOT
3350
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3351
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3352
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3353
+ * rebases (epoch-bound like any application message). */
3354
+ async sendDelete(group, args) {
3355
+ const plaintext = encodeDelete({
3356
+ clientMsgId: args.clientMsgId,
3357
+ targetClientMsgId: args.targetClientMsgId
3358
+ });
3359
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3360
+ const body = {
3361
+ ciphertext_b64: toBase64(ct),
3362
+ client_idem_key: randomId()
3363
+ };
3364
+ const wire = await palbeRequest(
3365
+ this.rt,
3366
+ "POST",
3367
+ MessagingPaths.groupMessages(group.displayId),
3368
+ { body }
3369
+ );
3370
+ const stored = {
3371
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3372
+ direction: "outgoing",
3373
+ text: null,
3374
+ senderDeviceId: this.selfDeviceId,
3375
+ epoch: wire.epoch,
3376
+ serverSeq: wire.server_seq,
3377
+ at: Date.now(),
3378
+ clientMsgId: args.clientMsgId,
3379
+ replyTo: null,
3380
+ envelopeType: "delete",
3381
+ delete: {
3382
+ targetClientMsgId: args.targetClientMsgId,
3383
+ scope: "everyone"
3384
+ }
3385
+ };
3386
+ try {
3387
+ await this.messageStore.append(group.rfcGroupId, stored);
3388
+ } catch {
3389
+ }
3390
+ return {
3391
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3392
+ clientMsgId: args.clientMsgId
3393
+ };
3394
+ }
3260
3395
  // ── The rebase loop ──
3261
3396
  async commitWithRebase(rfcGroupId, build) {
3262
3397
  const gidBytes = fromBase64(rfcGroupId);
@@ -3374,6 +3509,7 @@ var ReactionFold = class {
3374
3509
  };
3375
3510
 
3376
3511
  // src/messaging/chat.ts
3512
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3377
3513
  var Chat = class {
3378
3514
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3379
3515
  id;
@@ -3395,6 +3531,15 @@ var Chat = class {
3395
3531
  reactionFold = new ReactionFold();
3396
3532
  /** The single authoritative edit fold for this chat (live + own-send + history). */
3397
3533
  editFold = new EditFold();
3534
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3535
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3536
+ deleteFold = new DeleteFold();
3537
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3538
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3539
+ suppressed = /* @__PURE__ */ new Set();
3540
+ /** True once the persisted suppression set has been loaded (so the omit applies
3541
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3542
+ suppressedLoaded = false;
3398
3543
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3399
3544
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3400
3545
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3443,7 +3588,7 @@ var Chat = class {
3443
3588
  return this.kind === "direct";
3444
3589
  }
3445
3590
  get messages() {
3446
- return this.messageList;
3591
+ return this.surfaced();
3447
3592
  }
3448
3593
  get members() {
3449
3594
  return this.memberCache;
@@ -3452,13 +3597,49 @@ var Chat = class {
3452
3597
  return this.typingList;
3453
3598
  }
3454
3599
  get lastMessage() {
3455
- return this.messageList.at(-1) ?? null;
3600
+ return this.surfaced().at(-1) ?? null;
3456
3601
  }
3457
3602
  get unreadCount() {
3458
- return this.messageList.filter(
3459
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3603
+ return this.surfaced().filter(
3604
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3460
3605
  ).length;
3461
3606
  }
3607
+ /**
3608
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3609
+ * through it identically). Over the raw `messageList` (which already carries the
3610
+ * folded edit text + reactions + reply):
3611
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3612
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3613
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3614
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3615
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3616
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3617
+ */
3618
+ surfaced() {
3619
+ const out = [];
3620
+ for (const m of this.messageList) {
3621
+ const key = this.suppressionKey(m);
3622
+ if (this.suppressed.has(key)) continue;
3623
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3624
+ if (tombstoned) {
3625
+ out.push({
3626
+ ...m,
3627
+ text: DELETED_DESCRIPTOR,
3628
+ reactions: {},
3629
+ replyTo: null,
3630
+ edited: false,
3631
+ isDeleted: true
3632
+ });
3633
+ continue;
3634
+ }
3635
+ out.push(m);
3636
+ }
3637
+ return out;
3638
+ }
3639
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3640
+ suppressionKey(m) {
3641
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3642
+ }
3462
3643
  get title() {
3463
3644
  if (this.titleOverride) return this.titleOverride;
3464
3645
  if (this._group?.name) return this._group.name;
@@ -3482,9 +3663,28 @@ var Chat = class {
3482
3663
  if (this.wired || this._state !== "active" || !this._group) return;
3483
3664
  this.wired = true;
3484
3665
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3666
+ void this.loadSuppressed();
3485
3667
  void this.hydrateHistory();
3486
3668
  void this.refreshMembers();
3487
3669
  }
3670
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3671
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3672
+ async loadSuppressed() {
3673
+ if (this.suppressedLoaded || !this._group) return;
3674
+ this.suppressedLoaded = true;
3675
+ try {
3676
+ const keys = await this.backend.loadSuppressed(this._group);
3677
+ let changed = false;
3678
+ for (const k of keys) {
3679
+ if (!this.suppressed.has(k)) {
3680
+ this.suppressed.add(k);
3681
+ changed = true;
3682
+ }
3683
+ }
3684
+ if (changed) this.emit();
3685
+ } catch {
3686
+ }
3687
+ }
3488
3688
  async hydrateHistory() {
3489
3689
  if (this.historyLoaded || !this._group) return;
3490
3690
  this.historyLoaded = true;
@@ -3495,13 +3695,13 @@ var Chat = class {
3495
3695
  let changed = false;
3496
3696
  for (const m of incoming) {
3497
3697
  if (m.serverSeq <= 0) continue;
3498
- if (m.clientMsgId && m.text !== null) {
3698
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3499
3699
  this.byClientMsgId.set(m.clientMsgId, {
3500
3700
  text: m.text,
3501
3701
  senderUserId: m.senderUserId ?? ""
3502
3702
  });
3503
3703
  }
3504
- if (m.clientMsgId) {
3704
+ if (m.clientMsgId && !m.isDeleted) {
3505
3705
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3506
3706
  }
3507
3707
  }
@@ -3511,6 +3711,9 @@ var Chat = class {
3511
3711
  const key = this.internalKey(m.serverSeq);
3512
3712
  if (this.seenKeys.has(key)) continue;
3513
3713
  this.seenKeys.add(key);
3714
+ if (m.clientMsgId && !m.isDeleted) {
3715
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3716
+ }
3514
3717
  this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3515
3718
  changed = true;
3516
3719
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
@@ -3568,6 +3771,21 @@ var Chat = class {
3568
3771
  this.recomputeEdit(incoming.edit.targetClientMsgId);
3569
3772
  return;
3570
3773
  }
3774
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3775
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3776
+ this.deleteFold.ingest(
3777
+ {
3778
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3779
+ actorUserId,
3780
+ epoch: incoming.epoch,
3781
+ serverSeq: incoming.serverSeq,
3782
+ eventClientMsgId: incoming.clientMsgId
3783
+ },
3784
+ this.authorOfTarget
3785
+ );
3786
+ this.emit();
3787
+ return;
3788
+ }
3571
3789
  const incomingClientMsgId = incoming.clientMsgId;
3572
3790
  const incomingReplyRef = incoming.replyRef;
3573
3791
  let resolvedReplyTo = null;
@@ -3588,7 +3806,9 @@ var Chat = class {
3588
3806
  // BEFORE its target — the dangling case — renders the moment the target lands).
3589
3807
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3590
3808
  // Default false; applyEditOverlay below folds any edit that arrived first.
3591
- edited: false
3809
+ edited: false,
3810
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3811
+ isDeleted: false
3592
3812
  };
3593
3813
  if (incomingClientMsgId && incoming.text !== null) {
3594
3814
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -3599,6 +3819,7 @@ var Chat = class {
3599
3819
  if (incomingClientMsgId) {
3600
3820
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3601
3821
  this.editFold.reevaluateHeld(this.authorOfTarget);
3822
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3602
3823
  }
3603
3824
  this.messageList.push(this.applyEditOverlay(msg));
3604
3825
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3840,7 +4061,9 @@ var Chat = class {
3840
4061
  // the dangling-target invariant uniform across every append path).
3841
4062
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3842
4063
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3843
- edited: false
4064
+ edited: false,
4065
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4066
+ isDeleted: false
3844
4067
  });
3845
4068
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3846
4069
  this.emit();
@@ -3952,6 +4175,52 @@ var Chat = class {
3952
4175
  );
3953
4176
  this.recomputeEdit(message.clientMsgId);
3954
4177
  }
4178
+ // ── Delete ──
4179
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
4180
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
4181
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
4182
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
4183
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
4184
+ * server stays blind), folds the own delete locally so the target scrubs in
4185
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
4186
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
4187
+ async deleteForEveryone(message) {
4188
+ if (!message.clientMsgId) return;
4189
+ const group = await this.materializeIfNeeded();
4190
+ const clientMsgId = mintClientMsgId();
4191
+ const { receipt } = await this.backend.sendDelete(group, {
4192
+ clientMsgId,
4193
+ targetClientMsgId: message.clientMsgId
4194
+ });
4195
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4196
+ this.deleteFold.ingest(
4197
+ {
4198
+ targetClientMsgId: message.clientMsgId,
4199
+ actorUserId: this.backend.selfUserId,
4200
+ epoch: receipt.epoch,
4201
+ serverSeq: receipt.serverSeq,
4202
+ eventClientMsgId: clientMsgId
4203
+ },
4204
+ this.authorOfTarget
4205
+ );
4206
+ this.emit();
4207
+ }
4208
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4209
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4210
+ * suppression key persists per chat (survives reload). The key is the message's
4211
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4212
+ async deleteForMe(message) {
4213
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4214
+ if (this.suppressed.has(key)) return;
4215
+ this.suppressed.add(key);
4216
+ this.emit();
4217
+ if (this._group) {
4218
+ try {
4219
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4220
+ } catch {
4221
+ }
4222
+ }
4223
+ }
3955
4224
  };
3956
4225
  function sameReactions(a, b) {
3957
4226
  const ak = Object.keys(a);
@@ -4132,6 +4401,7 @@ var MessageDeliverySource = class {
4132
4401
  const { text, clientMsgId, replyTo } = decoded;
4133
4402
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4134
4403
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4404
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
4135
4405
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4136
4406
  const stored = {
4137
4407
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4170,6 +4440,18 @@ var MessageDeliverySource = class {
4170
4440
  targetClientMsgId: decoded.edit.targetClientMsgId,
4171
4441
  newText: decoded.edit.newText
4172
4442
  }
4443
+ } : {},
4444
+ // Thread the delete discriminator + target through the persisted row so a
4445
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4446
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4447
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4448
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4449
+ ...isDelete && decoded.delete ? {
4450
+ envelopeType: "delete",
4451
+ delete: {
4452
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4453
+ scope: decoded.delete.scope
4454
+ }
4173
4455
  } : {}
4174
4456
  };
4175
4457
  try {
@@ -4189,7 +4471,8 @@ var MessageDeliverySource = class {
4189
4471
  replyRef: replyTo,
4190
4472
  envelopeType: decoded.type ?? "text",
4191
4473
  reaction: isReaction ? decoded.reaction : null,
4192
- edit: isEdit ? decoded.edit : null
4474
+ edit: isEdit ? decoded.edit : null,
4475
+ delete: isDelete ? decoded.delete : null
4193
4476
  });
4194
4477
  return true;
4195
4478
  }
@@ -6011,6 +6294,36 @@ var SignatureKeyStore = class {
6011
6294
  }
6012
6295
  };
6013
6296
 
6297
+ // src/messaging/suppression.ts
6298
+ var SuppressionStore = class {
6299
+ constructor(kv) {
6300
+ this.kv = kv;
6301
+ }
6302
+ kv;
6303
+ key(rfcGroupId) {
6304
+ return `supp:${rfcGroupId}`;
6305
+ }
6306
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6307
+ async load(rfcGroupId) {
6308
+ const raw = await this.kv.get(this.key(rfcGroupId));
6309
+ if (!raw) return [];
6310
+ try {
6311
+ const parsed = JSON.parse(decodeUtf8(raw));
6312
+ return Array.isArray(parsed) ? parsed : [];
6313
+ } catch {
6314
+ return [];
6315
+ }
6316
+ }
6317
+ /** Persist the full suppression key set for a chat (deterministic order). */
6318
+ async save(rfcGroupId, keys) {
6319
+ const sorted = [...new Set(keys)].sort();
6320
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6321
+ }
6322
+ async wipe() {
6323
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6324
+ }
6325
+ };
6326
+
6014
6327
  // src/messaging/coordinator.ts
6015
6328
  var MessagingCoordinator = class {
6016
6329
  constructor(rt) {
@@ -6020,6 +6333,7 @@ var MessagingCoordinator = class {
6020
6333
  this.sigStore = new SignatureKeyStore(this.kv);
6021
6334
  this.groupStore = new GroupStateStorage(this.kv);
6022
6335
  this.kpStore = new KeyPackageStorage(this.kv);
6336
+ this.suppressionStore = new SuppressionStore(this.kv);
6023
6337
  this.registry.attachChatList(
6024
6338
  (chats) => {
6025
6339
  this.chatList = chats;
@@ -6034,6 +6348,7 @@ var MessagingCoordinator = class {
6034
6348
  sigStore;
6035
6349
  groupStore;
6036
6350
  kpStore;
6351
+ suppressionStore;
6037
6352
  registry = new GroupRegistry();
6038
6353
  resolved = null;
6039
6354
  resolvePromise = null;
@@ -6201,6 +6516,18 @@ var MessagingCoordinator = class {
6201
6516
  const r = await this.resolve();
6202
6517
  return r.groups.sendEdit(group, args);
6203
6518
  }
6519
+ async sendDelete(group, args) {
6520
+ const r = await this.resolve();
6521
+ return r.groups.sendDelete(group, args);
6522
+ }
6523
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6524
+ loadSuppressed(group) {
6525
+ return this.suppressionStore.load(group.rfcGroupId);
6526
+ }
6527
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6528
+ saveSuppressed(group, keys) {
6529
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6530
+ }
6204
6531
  async history(group, limit, before) {
6205
6532
  const r = await this.resolve();
6206
6533
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6298,9 +6625,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6298
6625
  });
6299
6626
  }
6300
6627
  const editFold = new EditFold();
6628
+ const deleteFold = new DeleteFold();
6301
6629
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6302
6630
  for (const s of rows) {
6303
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6631
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6632
+ continue;
6304
6633
  const cid = s.clientMsgId ?? "";
6305
6634
  if (!cid) continue;
6306
6635
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -6323,9 +6652,25 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6323
6652
  );
6324
6653
  }
6325
6654
  editFold.reevaluateHeld(authorOfTarget);
6655
+ for (const s of rows) {
6656
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6657
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6658
+ deleteFold.ingest(
6659
+ {
6660
+ targetClientMsgId: s.delete.targetClientMsgId,
6661
+ actorUserId: actor,
6662
+ epoch: s.epoch,
6663
+ serverSeq: s.serverSeq,
6664
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6665
+ },
6666
+ authorOfTarget
6667
+ );
6668
+ }
6669
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
6326
6670
  const lookup = /* @__PURE__ */ new Map();
6327
6671
  for (const s of rows) {
6328
- if (s.envelopeType === "reaction") continue;
6672
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6673
+ continue;
6329
6674
  const cid = s.clientMsgId ?? "";
6330
6675
  if (cid && s.text !== null) {
6331
6676
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6334,8 +6679,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6334
6679
  }
6335
6680
  const out = [];
6336
6681
  for (const s of rows) {
6337
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6682
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6683
+ continue;
6338
6684
  const clientMsgId = s.clientMsgId ?? "";
6685
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6686
+ if (isDeleted) {
6687
+ out.push({
6688
+ id: `${displayId}#${s.serverSeq}`,
6689
+ kind: "text",
6690
+ direction: s.direction,
6691
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6692
+ text: DELETED_DESCRIPTOR,
6693
+ serverSeq: s.serverSeq,
6694
+ sentAt: new Date(s.at),
6695
+ clientMsgId,
6696
+ replyTo: null,
6697
+ reactions: {},
6698
+ edited: false,
6699
+ isDeleted: true
6700
+ });
6701
+ continue;
6702
+ }
6339
6703
  let replyTo = null;
6340
6704
  if (s.replyTo) {
6341
6705
  const ref = {
@@ -6363,7 +6727,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6363
6727
  clientMsgId,
6364
6728
  replyTo,
6365
6729
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6366
- edited
6730
+ edited,
6731
+ isDeleted: false
6367
6732
  });
6368
6733
  }
6369
6734
  return out;
@@ -7101,7 +7466,7 @@ function defaultSessionStorage(key) {
7101
7466
  }
7102
7467
 
7103
7468
  // src/version.ts
7104
- var VERSION = "1.3.0";
7469
+ var VERSION = "1.4.0";
7105
7470
 
7106
7471
  // src/runtime.ts
7107
7472
  function buildRuntime(config) {