@palbase/web 1.2.1 → 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.
@@ -2628,6 +2628,151 @@ 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
+
2694
+ // src/messaging/edit-fold.ts
2695
+ function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2696
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
2697
+ return aSeq < bSeq;
2698
+ }
2699
+ function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
2700
+ return aEpoch === bEpoch && aSeq === bSeq;
2701
+ }
2702
+ var EditFold = class {
2703
+ // target → winning edit state
2704
+ states = /* @__PURE__ */ new Map();
2705
+ // dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
2706
+ seenEvents = /* @__PURE__ */ new Set();
2707
+ // events parked because target/author or sender was unresolved at ingest time
2708
+ held = [];
2709
+ // targets that have had ≥1 valid edit applied (write-once)
2710
+ editedTargets = /* @__PURE__ */ new Set();
2711
+ /**
2712
+ * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2713
+ * (null = target unknown/dangling → HOLD).
2714
+ */
2715
+ ingest(e, authorOfTarget) {
2716
+ const author = authorOfTarget(e.targetClientMsgId);
2717
+ if (author === null) {
2718
+ this.holdIfNew(e);
2719
+ return;
2720
+ }
2721
+ if (e.editorUserId === null) {
2722
+ this.holdIfNew(e);
2723
+ return;
2724
+ }
2725
+ if (e.editorUserId !== author) return;
2726
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
2727
+ this.seenEvents.add(e.eventClientMsgId);
2728
+ const prev = this.states.get(e.targetClientMsgId);
2729
+ if (prev !== void 0) {
2730
+ if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
2731
+ if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
2732
+ return;
2733
+ }
2734
+ }
2735
+ this.states.set(e.targetClientMsgId, {
2736
+ orderEpoch: e.epoch,
2737
+ orderSeq: e.serverSeq,
2738
+ lastEventId: e.eventClientMsgId,
2739
+ text: e.newText
2740
+ });
2741
+ this.editedTargets.add(e.targetClientMsgId);
2742
+ }
2743
+ /**
2744
+ * Park an event for later re-attempt, deduping held re-deliveries by
2745
+ * eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
2746
+ * once (and never double-applies when it finally resolves on reevaluate).
2747
+ */
2748
+ holdIfNew(e) {
2749
+ if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
2750
+ this.held.push(e);
2751
+ }
2752
+ /** The winning edit text for a target, or null if no valid edit has applied. */
2753
+ text(targetClientMsgId) {
2754
+ return this.states.get(targetClientMsgId)?.text ?? null;
2755
+ }
2756
+ /** Write-once: true once any valid edit applied to the target. */
2757
+ isEdited(targetClientMsgId) {
2758
+ return this.editedTargets.has(targetClientMsgId);
2759
+ }
2760
+ /**
2761
+ * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2762
+ * change and when a target message arrives). Clears `held` and re-ingests each
2763
+ * event with the fresh `authorOfTarget` — events that still don't resolve are
2764
+ * simply re-held; events that now resolve fold via the normal LWW path.
2765
+ * Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
2766
+ * `holdIfNew` (still-held events), so reevaluating repeatedly can neither
2767
+ * double-apply nor lose an edit.
2768
+ */
2769
+ reevaluateHeld(authorOfTarget) {
2770
+ const pending = this.held;
2771
+ this.held = [];
2772
+ for (const e of pending) this.ingest(e, authorOfTarget);
2773
+ }
2774
+ };
2775
+
2631
2776
  // src/messaging/util.ts
2632
2777
  function toBase64(bytes) {
2633
2778
  if (typeof Buffer !== "undefined") {
@@ -2767,6 +2912,28 @@ async function listDevices(rt, userId) {
2767
2912
  }
2768
2913
 
2769
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
+ }
2926
+ function encodeEdit(args) {
2927
+ return encodeUtf8(
2928
+ JSON.stringify({
2929
+ v: 1,
2930
+ type: "edit",
2931
+ client_msg_id: args.clientMsgId,
2932
+ target_client_msg_id: args.targetClientMsgId,
2933
+ new_text: args.newText
2934
+ })
2935
+ );
2936
+ }
2770
2937
  function encodeReaction(args) {
2771
2938
  return encodeUtf8(
2772
2939
  JSON.stringify({
@@ -2793,6 +2960,18 @@ function decodeEnvelope(bytes) {
2793
2960
  const s = decodeUtf8(bytes);
2794
2961
  try {
2795
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
+ }
2796
2975
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2797
2976
  return {
2798
2977
  type: "reaction",
@@ -2806,6 +2985,18 @@ function decodeEnvelope(bytes) {
2806
2985
  }
2807
2986
  };
2808
2987
  }
2988
+ if (typeof o === "object" && o !== null && o.type === "edit") {
2989
+ return {
2990
+ type: "edit",
2991
+ text: null,
2992
+ clientMsgId: o.client_msg_id ?? "",
2993
+ replyTo: null,
2994
+ edit: {
2995
+ targetClientMsgId: o.target_client_msg_id ?? "",
2996
+ newText: o.new_text ?? ""
2997
+ }
2998
+ };
2999
+ }
2809
3000
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2810
3001
  return {
2811
3002
  type: "text",
@@ -3120,6 +3311,103 @@ var GroupMessaging = class {
3120
3311
  clientMsgId: args.clientMsgId
3121
3312
  };
3122
3313
  }
3314
+ /** Send an edit (edit-by-supersession on a target message). Encrypts a
3315
+ * `type:'edit'` envelope at the current epoch and sends through the SAME MLS
3316
+ * application path as `sendText` (the server stays blind — an edit is just
3317
+ * another application message). Persists the outgoing edit row so it re-folds
3318
+ * onto its target's text after a reload (the own-send half of the reload
3319
+ * parity). NEVER rebases (epoch-bound like any application message). */
3320
+ async sendEdit(group, args) {
3321
+ const plaintext = encodeEdit({
3322
+ clientMsgId: args.clientMsgId,
3323
+ targetClientMsgId: args.targetClientMsgId,
3324
+ newText: args.newText
3325
+ });
3326
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3327
+ const body = {
3328
+ ciphertext_b64: toBase64(ct),
3329
+ client_idem_key: randomId()
3330
+ };
3331
+ const wire = await palbeRequest(
3332
+ this.rt,
3333
+ "POST",
3334
+ MessagingPaths.groupMessages(group.displayId),
3335
+ { body }
3336
+ );
3337
+ const stored = {
3338
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3339
+ direction: "outgoing",
3340
+ text: null,
3341
+ senderDeviceId: this.selfDeviceId,
3342
+ epoch: wire.epoch,
3343
+ serverSeq: wire.server_seq,
3344
+ at: Date.now(),
3345
+ clientMsgId: args.clientMsgId,
3346
+ replyTo: null,
3347
+ envelopeType: "edit",
3348
+ edit: {
3349
+ targetClientMsgId: args.targetClientMsgId,
3350
+ newText: args.newText
3351
+ }
3352
+ };
3353
+ try {
3354
+ await this.messageStore.append(group.rfcGroupId, stored);
3355
+ } catch {
3356
+ }
3357
+ return {
3358
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3359
+ clientMsgId: args.clientMsgId
3360
+ };
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
+ }
3123
3411
  // ── The rebase loop ──
3124
3412
  async commitWithRebase(rfcGroupId, build) {
3125
3413
  const gidBytes = fromBase64(rfcGroupId);
@@ -3237,6 +3525,7 @@ var ReactionFold = class {
3237
3525
  };
3238
3526
 
3239
3527
  // src/messaging/chat.ts
3528
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3240
3529
  var Chat = class {
3241
3530
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3242
3531
  id;
@@ -3256,6 +3545,23 @@ var Chat = class {
3256
3545
  byClientMsgId = /* @__PURE__ */ new Map();
3257
3546
  /** The single authoritative reaction fold for this chat (live + own-send + history). */
3258
3547
  reactionFold = new ReactionFold();
3548
+ /** The single authoritative edit fold for this chat (live + own-send + history). */
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;
3559
+ /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3560
+ * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3561
+ originalTextByClientMsgId = /* @__PURE__ */ new Map();
3562
+ /** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
3563
+ * projection time from senderUserId; '' = resolved-but-unknown peer). */
3564
+ authorByClientMsgId = /* @__PURE__ */ new Map();
3259
3565
  loadedEarliestSeq = null;
3260
3566
  historyLoaded = false;
3261
3567
  wired = false;
@@ -3298,7 +3604,7 @@ var Chat = class {
3298
3604
  return this.kind === "direct";
3299
3605
  }
3300
3606
  get messages() {
3301
- return this.messageList;
3607
+ return this.surfaced();
3302
3608
  }
3303
3609
  get members() {
3304
3610
  return this.memberCache;
@@ -3307,13 +3613,49 @@ var Chat = class {
3307
3613
  return this.typingList;
3308
3614
  }
3309
3615
  get lastMessage() {
3310
- return this.messageList.at(-1) ?? null;
3616
+ return this.surfaced().at(-1) ?? null;
3311
3617
  }
3312
3618
  get unreadCount() {
3313
- return this.messageList.filter(
3314
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3619
+ return this.surfaced().filter(
3620
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3315
3621
  ).length;
3316
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
+ }
3317
3659
  get title() {
3318
3660
  if (this.titleOverride) return this.titleOverride;
3319
3661
  if (this._group?.name) return this._group.name;
@@ -3337,9 +3679,28 @@ var Chat = class {
3337
3679
  if (this.wired || this._state !== "active" || !this._group) return;
3338
3680
  this.wired = true;
3339
3681
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3682
+ void this.loadSuppressed();
3340
3683
  void this.hydrateHistory();
3341
3684
  void this.refreshMembers();
3342
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
+ }
3343
3704
  async hydrateHistory() {
3344
3705
  if (this.historyLoaded || !this._group) return;
3345
3706
  this.historyLoaded = true;
@@ -3350,19 +3711,26 @@ var Chat = class {
3350
3711
  let changed = false;
3351
3712
  for (const m of incoming) {
3352
3713
  if (m.serverSeq <= 0) continue;
3353
- if (m.clientMsgId && m.text !== null) {
3714
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3354
3715
  this.byClientMsgId.set(m.clientMsgId, {
3355
3716
  text: m.text,
3356
3717
  senderUserId: m.senderUserId ?? ""
3357
3718
  });
3358
3719
  }
3720
+ if (m.clientMsgId && !m.isDeleted) {
3721
+ this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3722
+ }
3359
3723
  }
3724
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3360
3725
  for (const m of incoming) {
3361
3726
  if (m.serverSeq <= 0) continue;
3362
3727
  const key = this.internalKey(m.serverSeq);
3363
3728
  if (this.seenKeys.has(key)) continue;
3364
3729
  this.seenKeys.add(key);
3365
- this.messageList.push(this.applyReactionTally(m));
3730
+ if (m.clientMsgId && !m.isDeleted) {
3731
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3732
+ }
3733
+ this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3366
3734
  changed = true;
3367
3735
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3368
3736
  }
@@ -3403,6 +3771,37 @@ var Chat = class {
3403
3771
  }
3404
3772
  return;
3405
3773
  }
3774
+ if (incoming.envelopeType === "edit" && incoming.edit) {
3775
+ const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3776
+ this.editFold.ingest(
3777
+ {
3778
+ targetClientMsgId: incoming.edit.targetClientMsgId,
3779
+ editorUserId,
3780
+ newText: incoming.edit.newText,
3781
+ epoch: incoming.epoch,
3782
+ serverSeq: incoming.serverSeq,
3783
+ eventClientMsgId: incoming.clientMsgId
3784
+ },
3785
+ this.authorOfTarget
3786
+ );
3787
+ this.recomputeEdit(incoming.edit.targetClientMsgId);
3788
+ return;
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
+ }
3406
3805
  const incomingClientMsgId = incoming.clientMsgId;
3407
3806
  const incomingReplyRef = incoming.replyRef;
3408
3807
  let resolvedReplyTo = null;
@@ -3421,7 +3820,11 @@ var Chat = class {
3421
3820
  replyTo: resolvedReplyTo,
3422
3821
  // Attach any tally already folded for this message (a reaction that arrived
3423
3822
  // BEFORE its target — the dangling case — renders the moment the target lands).
3424
- reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
3823
+ reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3824
+ // Default false; applyEditOverlay below folds any edit that arrived first.
3825
+ edited: false,
3826
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3827
+ isDeleted: false
3425
3828
  };
3426
3829
  if (incomingClientMsgId && incoming.text !== null) {
3427
3830
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -3429,7 +3832,12 @@ var Chat = class {
3429
3832
  senderUserId: senderUser ?? ""
3430
3833
  });
3431
3834
  }
3432
- this.messageList.push(msg);
3835
+ if (incomingClientMsgId) {
3836
+ this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3837
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3838
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3839
+ }
3840
+ this.messageList.push(this.applyEditOverlay(msg));
3433
3841
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3434
3842
  this.loadedEarliestSeq = Math.min(
3435
3843
  this.loadedEarliestSeq ?? incoming.serverSeq,
@@ -3437,6 +3845,19 @@ var Chat = class {
3437
3845
  );
3438
3846
  this.emit();
3439
3847
  }
3848
+ /** The EditFold author-gate input: the target message's resolved author userId
3849
+ * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3850
+ * so it can be passed to the pure EditFold. */
3851
+ authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3852
+ /** Seed the per-target base text + author for the edit fold. Base is write-once
3853
+ * (a later own/peer edit must not overwrite the original we render against). The
3854
+ * author is (re)recorded whenever a non-empty resolution is available. */
3855
+ seedEditBase(clientMsgId, text, author) {
3856
+ if (!this.originalTextByClientMsgId.has(clientMsgId)) {
3857
+ this.originalTextByClientMsgId.set(clientMsgId, text);
3858
+ }
3859
+ if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
3860
+ }
3440
3861
  /**
3441
3862
  * Rebuild the target message's `reactions` from the authoritative fold and
3442
3863
  * re-emit. No-op when the target isn't present yet (its tally is attached the
@@ -3466,6 +3887,46 @@ var Chat = class {
3466
3887
  if (sameReactions(m.reactions, tally)) return m;
3467
3888
  return { ...m, reactions: tally };
3468
3889
  }
3890
+ /**
3891
+ * Rebuild the target message's rendered `text` + `edited` flag from the
3892
+ * authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
3893
+ * (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
3894
+ * base`; base is the seeded original so a forged/ignored edit leaves it intact.
3895
+ * No-op when the target isn't present yet (the fold already recorded it; the
3896
+ * overlay applies the moment the target lands) or when unchanged.
3897
+ */
3898
+ recomputeEdit(targetClientMsgId) {
3899
+ if (!targetClientMsgId) return;
3900
+ const editText = this.editFold.text(targetClientMsgId);
3901
+ const foldEdited = this.editFold.isEdited(targetClientMsgId);
3902
+ let changed = false;
3903
+ this.messageList = this.messageList.map((m) => {
3904
+ if (m.clientMsgId !== targetClientMsgId) return m;
3905
+ const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3906
+ const text = editText ?? base;
3907
+ const edited = foldEdited || m.edited;
3908
+ if (m.text === text && m.edited === edited) return m;
3909
+ changed = true;
3910
+ return { ...m, text, edited };
3911
+ });
3912
+ if (changed) this.emit();
3913
+ }
3914
+ /**
3915
+ * Overlay the authoritative edit fold's winning text + flag onto a message as it
3916
+ * is appended/merged. The fold WINS when it has an edit for this target;
3917
+ * otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
3918
+ * history fold) is preserved. PRESERVES reactions + replyTo.
3919
+ */
3920
+ applyEditOverlay(m) {
3921
+ if (!m.clientMsgId) return m;
3922
+ const editText = this.editFold.text(m.clientMsgId);
3923
+ const foldEdited = this.editFold.isEdited(m.clientMsgId);
3924
+ if (editText === null && !foldEdited) return m;
3925
+ const text = editText ?? m.text;
3926
+ const edited = foldEdited || m.edited;
3927
+ if (m.text === text && m.edited === edited) return m;
3928
+ return { ...m, text, edited };
3929
+ }
3469
3930
  /** @internal — called by the backend's conv subscription. */
3470
3931
  applyConv(event, payload) {
3471
3932
  const userId = typeof payload.user_id === "string" ? payload.user_id : null;
@@ -3520,6 +3981,8 @@ var Chat = class {
3520
3981
  this.memberCache = m;
3521
3982
  this.emit();
3522
3983
  }
3984
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3985
+ for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3523
3986
  }
3524
3987
  seedMembersFromGroup(group) {
3525
3988
  const seed = [
@@ -3598,6 +4061,7 @@ var Chat = class {
3598
4061
  this.seenKeys.add(key);
3599
4062
  if (clientMsgId) {
3600
4063
  this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
4064
+ this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
3601
4065
  }
3602
4066
  this.messageList.push({
3603
4067
  id: this.publicId(receipt.serverSeq),
@@ -3611,7 +4075,11 @@ var Chat = class {
3611
4075
  replyTo: resolvedReplyTo,
3612
4076
  // Attach any tally already folded for this own-sent message (rare, but keeps
3613
4077
  // the dangling-target invariant uniform across every append path).
3614
- reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
4078
+ reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
4079
+ // Own-sent edits fold via edit() after the fact; new sends start unedited.
4080
+ edited: false,
4081
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4082
+ isDeleted: false
3615
4083
  });
3616
4084
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3617
4085
  this.emit();
@@ -3692,6 +4160,83 @@ var Chat = class {
3692
4160
  });
3693
4161
  this.recomputeReactions(message.clientMsgId);
3694
4162
  }
4163
+ // ── Edit ──
4164
+ /** Edit an own text message (edit-by-supersession). No-op if the message isn't
4165
+ * editable (empty clientMsgId, or not a `text` kind). The edit folds locally
4166
+ * with the server receipt's `(epoch, serverSeq)` so the target's text updates
4167
+ * instantly; the durable echo on the next pump is a fold no-op (dedup on the
4168
+ * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
4169
+ * reactions + reply context. Only the original author's edits count — for an own
4170
+ * message self IS the author, so the author-gate passes. */
4171
+ async edit(message, newText) {
4172
+ if (!message.clientMsgId || message.kind !== "text") return;
4173
+ const group = await this.materializeIfNeeded();
4174
+ const clientMsgId = mintClientMsgId();
4175
+ const { receipt } = await this.backend.sendEdit(group, {
4176
+ clientMsgId,
4177
+ targetClientMsgId: message.clientMsgId,
4178
+ newText
4179
+ });
4180
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4181
+ this.editFold.ingest(
4182
+ {
4183
+ targetClientMsgId: message.clientMsgId,
4184
+ editorUserId: this.backend.selfUserId,
4185
+ newText,
4186
+ epoch: receipt.epoch,
4187
+ serverSeq: receipt.serverSeq,
4188
+ eventClientMsgId: clientMsgId
4189
+ },
4190
+ this.authorOfTarget
4191
+ );
4192
+ this.recomputeEdit(message.clientMsgId);
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
+ }
3695
4240
  };
3696
4241
  function sameReactions(a, b) {
3697
4242
  const ak = Object.keys(a);
@@ -3871,6 +4416,8 @@ var MessageDeliverySource = class {
3871
4416
  const decoded = decodeEnvelope(received.data);
3872
4417
  const { text, clientMsgId, replyTo } = decoded;
3873
4418
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4419
+ const isEdit = decoded.type === "edit" && decoded.edit != null;
4420
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
3874
4421
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3875
4422
  const stored = {
3876
4423
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3898,6 +4445,29 @@ var MessageDeliverySource = class {
3898
4445
  emoji: decoded.reaction.emoji,
3899
4446
  op: decoded.reaction.op
3900
4447
  }
4448
+ } : {},
4449
+ // Thread the edit discriminator + new text through the persisted row so an
4450
+ // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4451
+ // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4452
+ // `'text'`/no-edit (backward-compat).
4453
+ ...isEdit && decoded.edit ? {
4454
+ envelopeType: "edit",
4455
+ edit: {
4456
+ targetClientMsgId: decoded.edit.targetClientMsgId,
4457
+ newText: decoded.edit.newText
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
+ }
3901
4471
  } : {}
3902
4472
  };
3903
4473
  try {
@@ -3916,7 +4486,9 @@ var MessageDeliverySource = class {
3916
4486
  clientMsgId,
3917
4487
  replyRef: replyTo,
3918
4488
  envelopeType: decoded.type ?? "text",
3919
- reaction: isReaction ? decoded.reaction : null
4489
+ reaction: isReaction ? decoded.reaction : null,
4490
+ edit: isEdit ? decoded.edit : null,
4491
+ delete: isDelete ? decoded.delete : null
3920
4492
  });
3921
4493
  return true;
3922
4494
  }
@@ -5738,6 +6310,36 @@ var SignatureKeyStore = class {
5738
6310
  }
5739
6311
  };
5740
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
+
5741
6343
  // src/messaging/coordinator.ts
5742
6344
  var MessagingCoordinator = class {
5743
6345
  constructor(rt) {
@@ -5747,6 +6349,7 @@ var MessagingCoordinator = class {
5747
6349
  this.sigStore = new SignatureKeyStore(this.kv);
5748
6350
  this.groupStore = new GroupStateStorage(this.kv);
5749
6351
  this.kpStore = new KeyPackageStorage(this.kv);
6352
+ this.suppressionStore = new SuppressionStore(this.kv);
5750
6353
  this.registry.attachChatList(
5751
6354
  (chats) => {
5752
6355
  this.chatList = chats;
@@ -5761,6 +6364,7 @@ var MessagingCoordinator = class {
5761
6364
  sigStore;
5762
6365
  groupStore;
5763
6366
  kpStore;
6367
+ suppressionStore;
5764
6368
  registry = new GroupRegistry();
5765
6369
  resolved = null;
5766
6370
  resolvePromise = null;
@@ -5924,6 +6528,22 @@ var MessagingCoordinator = class {
5924
6528
  const r = await this.resolve();
5925
6529
  return r.groups.sendReaction(group, args);
5926
6530
  }
6531
+ async sendEdit(group, args) {
6532
+ const r = await this.resolve();
6533
+ return r.groups.sendEdit(group, args);
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
+ }
5927
6547
  async history(group, limit, before) {
5928
6548
  const r = await this.resolve();
5929
6549
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6020,9 +6640,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6020
6640
  eventClientMsgId: s.clientMsgId ?? `${s.id}`
6021
6641
  });
6022
6642
  }
6643
+ const editFold = new EditFold();
6644
+ const deleteFold = new DeleteFold();
6645
+ const authorByClientMsgId = /* @__PURE__ */ new Map();
6646
+ for (const s of rows) {
6647
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6648
+ continue;
6649
+ const cid = s.clientMsgId ?? "";
6650
+ if (!cid) continue;
6651
+ const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6652
+ if (author != null) authorByClientMsgId.set(cid, author);
6653
+ }
6654
+ const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6655
+ for (const s of rows) {
6656
+ if (s.envelopeType !== "edit" || !s.edit) continue;
6657
+ const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6658
+ editFold.ingest(
6659
+ {
6660
+ targetClientMsgId: s.edit.targetClientMsgId,
6661
+ editorUserId: editor,
6662
+ newText: s.edit.newText,
6663
+ epoch: s.epoch,
6664
+ serverSeq: s.serverSeq,
6665
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6666
+ },
6667
+ authorOfTarget
6668
+ );
6669
+ }
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);
6023
6686
  const lookup = /* @__PURE__ */ new Map();
6024
6687
  for (const s of rows) {
6025
- if (s.envelopeType === "reaction") continue;
6688
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6689
+ continue;
6026
6690
  const cid = s.clientMsgId ?? "";
6027
6691
  if (cid && s.text !== null) {
6028
6692
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6031,8 +6695,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6031
6695
  }
6032
6696
  const out = [];
6033
6697
  for (const s of rows) {
6034
- if (s.envelopeType === "reaction") continue;
6698
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6699
+ continue;
6035
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
+ }
6036
6719
  let replyTo = null;
6037
6720
  if (s.replyTo) {
6038
6721
  const ref = {
@@ -6047,17 +6730,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6047
6730
  };
6048
6731
  replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
6049
6732
  }
6733
+ const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6734
+ const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6050
6735
  out.push({
6051
6736
  id: `${displayId}#${s.serverSeq}`,
6052
6737
  kind: s.text != null ? "text" : "system",
6053
6738
  direction: s.direction,
6054
6739
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6055
- text: s.text,
6740
+ text: editText ?? s.text,
6056
6741
  serverSeq: s.serverSeq,
6057
6742
  sentAt: new Date(s.at),
6058
6743
  clientMsgId,
6059
6744
  replyTo,
6060
- reactions: clientMsgId ? fold.tally(clientMsgId) : {}
6745
+ reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6746
+ edited,
6747
+ isDeleted: false
6061
6748
  });
6062
6749
  }
6063
6750
  return out;
@@ -6795,7 +7482,7 @@ function defaultSessionStorage(key) {
6795
7482
  }
6796
7483
 
6797
7484
  // src/version.ts
6798
- var VERSION = "1.2.1";
7485
+ var VERSION = "1.4.0";
6799
7486
 
6800
7487
  // src/runtime.ts
6801
7488
  function buildRuntime(config) {