@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.
package/dist/internal.cjs CHANGED
@@ -2612,6 +2612,151 @@ 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
+
2678
+ // src/messaging/edit-fold.ts
2679
+ function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2680
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
2681
+ return aSeq < bSeq;
2682
+ }
2683
+ function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
2684
+ return aEpoch === bEpoch && aSeq === bSeq;
2685
+ }
2686
+ var EditFold = class {
2687
+ // target → winning edit state
2688
+ states = /* @__PURE__ */ new Map();
2689
+ // dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
2690
+ seenEvents = /* @__PURE__ */ new Set();
2691
+ // events parked because target/author or sender was unresolved at ingest time
2692
+ held = [];
2693
+ // targets that have had ≥1 valid edit applied (write-once)
2694
+ editedTargets = /* @__PURE__ */ new Set();
2695
+ /**
2696
+ * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2697
+ * (null = target unknown/dangling → HOLD).
2698
+ */
2699
+ ingest(e, authorOfTarget) {
2700
+ const author = authorOfTarget(e.targetClientMsgId);
2701
+ if (author === null) {
2702
+ this.holdIfNew(e);
2703
+ return;
2704
+ }
2705
+ if (e.editorUserId === null) {
2706
+ this.holdIfNew(e);
2707
+ return;
2708
+ }
2709
+ if (e.editorUserId !== author) return;
2710
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
2711
+ this.seenEvents.add(e.eventClientMsgId);
2712
+ const prev = this.states.get(e.targetClientMsgId);
2713
+ if (prev !== void 0) {
2714
+ if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
2715
+ if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
2716
+ return;
2717
+ }
2718
+ }
2719
+ this.states.set(e.targetClientMsgId, {
2720
+ orderEpoch: e.epoch,
2721
+ orderSeq: e.serverSeq,
2722
+ lastEventId: e.eventClientMsgId,
2723
+ text: e.newText
2724
+ });
2725
+ this.editedTargets.add(e.targetClientMsgId);
2726
+ }
2727
+ /**
2728
+ * Park an event for later re-attempt, deduping held re-deliveries by
2729
+ * eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
2730
+ * once (and never double-applies when it finally resolves on reevaluate).
2731
+ */
2732
+ holdIfNew(e) {
2733
+ if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
2734
+ this.held.push(e);
2735
+ }
2736
+ /** The winning edit text for a target, or null if no valid edit has applied. */
2737
+ text(targetClientMsgId) {
2738
+ return this.states.get(targetClientMsgId)?.text ?? null;
2739
+ }
2740
+ /** Write-once: true once any valid edit applied to the target. */
2741
+ isEdited(targetClientMsgId) {
2742
+ return this.editedTargets.has(targetClientMsgId);
2743
+ }
2744
+ /**
2745
+ * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2746
+ * change and when a target message arrives). Clears `held` and re-ingests each
2747
+ * event with the fresh `authorOfTarget` — events that still don't resolve are
2748
+ * simply re-held; events that now resolve fold via the normal LWW path.
2749
+ * Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
2750
+ * `holdIfNew` (still-held events), so reevaluating repeatedly can neither
2751
+ * double-apply nor lose an edit.
2752
+ */
2753
+ reevaluateHeld(authorOfTarget) {
2754
+ const pending = this.held;
2755
+ this.held = [];
2756
+ for (const e of pending) this.ingest(e, authorOfTarget);
2757
+ }
2758
+ };
2759
+
2615
2760
  // src/messaging/util.ts
2616
2761
  function toBase64(bytes) {
2617
2762
  if (typeof Buffer !== "undefined") {
@@ -2751,6 +2896,28 @@ async function listDevices(rt, userId) {
2751
2896
  }
2752
2897
 
2753
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
+ }
2910
+ function encodeEdit(args) {
2911
+ return encodeUtf8(
2912
+ JSON.stringify({
2913
+ v: 1,
2914
+ type: "edit",
2915
+ client_msg_id: args.clientMsgId,
2916
+ target_client_msg_id: args.targetClientMsgId,
2917
+ new_text: args.newText
2918
+ })
2919
+ );
2920
+ }
2754
2921
  function encodeReaction(args) {
2755
2922
  return encodeUtf8(
2756
2923
  JSON.stringify({
@@ -2777,6 +2944,18 @@ function decodeEnvelope(bytes) {
2777
2944
  const s = decodeUtf8(bytes);
2778
2945
  try {
2779
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
+ }
2780
2959
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2781
2960
  return {
2782
2961
  type: "reaction",
@@ -2790,6 +2969,18 @@ function decodeEnvelope(bytes) {
2790
2969
  }
2791
2970
  };
2792
2971
  }
2972
+ if (typeof o === "object" && o !== null && o.type === "edit") {
2973
+ return {
2974
+ type: "edit",
2975
+ text: null,
2976
+ clientMsgId: o.client_msg_id ?? "",
2977
+ replyTo: null,
2978
+ edit: {
2979
+ targetClientMsgId: o.target_client_msg_id ?? "",
2980
+ newText: o.new_text ?? ""
2981
+ }
2982
+ };
2983
+ }
2793
2984
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2794
2985
  return {
2795
2986
  type: "text",
@@ -3104,6 +3295,103 @@ var GroupMessaging = class {
3104
3295
  clientMsgId: args.clientMsgId
3105
3296
  };
3106
3297
  }
3298
+ /** Send an edit (edit-by-supersession on a target message). Encrypts a
3299
+ * `type:'edit'` envelope at the current epoch and sends through the SAME MLS
3300
+ * application path as `sendText` (the server stays blind — an edit is just
3301
+ * another application message). Persists the outgoing edit row so it re-folds
3302
+ * onto its target's text after a reload (the own-send half of the reload
3303
+ * parity). NEVER rebases (epoch-bound like any application message). */
3304
+ async sendEdit(group, args) {
3305
+ const plaintext = encodeEdit({
3306
+ clientMsgId: args.clientMsgId,
3307
+ targetClientMsgId: args.targetClientMsgId,
3308
+ newText: args.newText
3309
+ });
3310
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3311
+ const body = {
3312
+ ciphertext_b64: toBase64(ct),
3313
+ client_idem_key: randomId()
3314
+ };
3315
+ const wire = await palbeRequest(
3316
+ this.rt,
3317
+ "POST",
3318
+ MessagingPaths.groupMessages(group.displayId),
3319
+ { body }
3320
+ );
3321
+ const stored = {
3322
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3323
+ direction: "outgoing",
3324
+ text: null,
3325
+ senderDeviceId: this.selfDeviceId,
3326
+ epoch: wire.epoch,
3327
+ serverSeq: wire.server_seq,
3328
+ at: Date.now(),
3329
+ clientMsgId: args.clientMsgId,
3330
+ replyTo: null,
3331
+ envelopeType: "edit",
3332
+ edit: {
3333
+ targetClientMsgId: args.targetClientMsgId,
3334
+ newText: args.newText
3335
+ }
3336
+ };
3337
+ try {
3338
+ await this.messageStore.append(group.rfcGroupId, stored);
3339
+ } catch {
3340
+ }
3341
+ return {
3342
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3343
+ clientMsgId: args.clientMsgId
3344
+ };
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
+ }
3107
3395
  // ── The rebase loop ──
3108
3396
  async commitWithRebase(rfcGroupId, build) {
3109
3397
  const gidBytes = fromBase64(rfcGroupId);
@@ -3221,6 +3509,7 @@ var ReactionFold = class {
3221
3509
  };
3222
3510
 
3223
3511
  // src/messaging/chat.ts
3512
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3224
3513
  var Chat = class {
3225
3514
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3226
3515
  id;
@@ -3240,6 +3529,23 @@ var Chat = class {
3240
3529
  byClientMsgId = /* @__PURE__ */ new Map();
3241
3530
  /** The single authoritative reaction fold for this chat (live + own-send + history). */
3242
3531
  reactionFold = new ReactionFold();
3532
+ /** The single authoritative edit fold for this chat (live + own-send + history). */
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;
3543
+ /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3544
+ * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3545
+ originalTextByClientMsgId = /* @__PURE__ */ new Map();
3546
+ /** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
3547
+ * projection time from senderUserId; '' = resolved-but-unknown peer). */
3548
+ authorByClientMsgId = /* @__PURE__ */ new Map();
3243
3549
  loadedEarliestSeq = null;
3244
3550
  historyLoaded = false;
3245
3551
  wired = false;
@@ -3282,7 +3588,7 @@ var Chat = class {
3282
3588
  return this.kind === "direct";
3283
3589
  }
3284
3590
  get messages() {
3285
- return this.messageList;
3591
+ return this.surfaced();
3286
3592
  }
3287
3593
  get members() {
3288
3594
  return this.memberCache;
@@ -3291,13 +3597,49 @@ var Chat = class {
3291
3597
  return this.typingList;
3292
3598
  }
3293
3599
  get lastMessage() {
3294
- return this.messageList.at(-1) ?? null;
3600
+ return this.surfaced().at(-1) ?? null;
3295
3601
  }
3296
3602
  get unreadCount() {
3297
- return this.messageList.filter(
3298
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3603
+ return this.surfaced().filter(
3604
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3299
3605
  ).length;
3300
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
+ }
3301
3643
  get title() {
3302
3644
  if (this.titleOverride) return this.titleOverride;
3303
3645
  if (this._group?.name) return this._group.name;
@@ -3321,9 +3663,28 @@ var Chat = class {
3321
3663
  if (this.wired || this._state !== "active" || !this._group) return;
3322
3664
  this.wired = true;
3323
3665
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3666
+ void this.loadSuppressed();
3324
3667
  void this.hydrateHistory();
3325
3668
  void this.refreshMembers();
3326
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
+ }
3327
3688
  async hydrateHistory() {
3328
3689
  if (this.historyLoaded || !this._group) return;
3329
3690
  this.historyLoaded = true;
@@ -3334,19 +3695,26 @@ var Chat = class {
3334
3695
  let changed = false;
3335
3696
  for (const m of incoming) {
3336
3697
  if (m.serverSeq <= 0) continue;
3337
- if (m.clientMsgId && m.text !== null) {
3698
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3338
3699
  this.byClientMsgId.set(m.clientMsgId, {
3339
3700
  text: m.text,
3340
3701
  senderUserId: m.senderUserId ?? ""
3341
3702
  });
3342
3703
  }
3704
+ if (m.clientMsgId && !m.isDeleted) {
3705
+ this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3706
+ }
3343
3707
  }
3708
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3344
3709
  for (const m of incoming) {
3345
3710
  if (m.serverSeq <= 0) continue;
3346
3711
  const key = this.internalKey(m.serverSeq);
3347
3712
  if (this.seenKeys.has(key)) continue;
3348
3713
  this.seenKeys.add(key);
3349
- this.messageList.push(this.applyReactionTally(m));
3714
+ if (m.clientMsgId && !m.isDeleted) {
3715
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3716
+ }
3717
+ this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3350
3718
  changed = true;
3351
3719
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3352
3720
  }
@@ -3387,6 +3755,37 @@ var Chat = class {
3387
3755
  }
3388
3756
  return;
3389
3757
  }
3758
+ if (incoming.envelopeType === "edit" && incoming.edit) {
3759
+ const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3760
+ this.editFold.ingest(
3761
+ {
3762
+ targetClientMsgId: incoming.edit.targetClientMsgId,
3763
+ editorUserId,
3764
+ newText: incoming.edit.newText,
3765
+ epoch: incoming.epoch,
3766
+ serverSeq: incoming.serverSeq,
3767
+ eventClientMsgId: incoming.clientMsgId
3768
+ },
3769
+ this.authorOfTarget
3770
+ );
3771
+ this.recomputeEdit(incoming.edit.targetClientMsgId);
3772
+ return;
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
+ }
3390
3789
  const incomingClientMsgId = incoming.clientMsgId;
3391
3790
  const incomingReplyRef = incoming.replyRef;
3392
3791
  let resolvedReplyTo = null;
@@ -3405,7 +3804,11 @@ var Chat = class {
3405
3804
  replyTo: resolvedReplyTo,
3406
3805
  // Attach any tally already folded for this message (a reaction that arrived
3407
3806
  // BEFORE its target — the dangling case — renders the moment the target lands).
3408
- reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
3807
+ reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3808
+ // Default false; applyEditOverlay below folds any edit that arrived first.
3809
+ edited: false,
3810
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3811
+ isDeleted: false
3409
3812
  };
3410
3813
  if (incomingClientMsgId && incoming.text !== null) {
3411
3814
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -3413,7 +3816,12 @@ var Chat = class {
3413
3816
  senderUserId: senderUser ?? ""
3414
3817
  });
3415
3818
  }
3416
- this.messageList.push(msg);
3819
+ if (incomingClientMsgId) {
3820
+ this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3821
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3822
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3823
+ }
3824
+ this.messageList.push(this.applyEditOverlay(msg));
3417
3825
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3418
3826
  this.loadedEarliestSeq = Math.min(
3419
3827
  this.loadedEarliestSeq ?? incoming.serverSeq,
@@ -3421,6 +3829,19 @@ var Chat = class {
3421
3829
  );
3422
3830
  this.emit();
3423
3831
  }
3832
+ /** The EditFold author-gate input: the target message's resolved author userId
3833
+ * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3834
+ * so it can be passed to the pure EditFold. */
3835
+ authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3836
+ /** Seed the per-target base text + author for the edit fold. Base is write-once
3837
+ * (a later own/peer edit must not overwrite the original we render against). The
3838
+ * author is (re)recorded whenever a non-empty resolution is available. */
3839
+ seedEditBase(clientMsgId, text, author) {
3840
+ if (!this.originalTextByClientMsgId.has(clientMsgId)) {
3841
+ this.originalTextByClientMsgId.set(clientMsgId, text);
3842
+ }
3843
+ if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
3844
+ }
3424
3845
  /**
3425
3846
  * Rebuild the target message's `reactions` from the authoritative fold and
3426
3847
  * re-emit. No-op when the target isn't present yet (its tally is attached the
@@ -3450,6 +3871,46 @@ var Chat = class {
3450
3871
  if (sameReactions(m.reactions, tally)) return m;
3451
3872
  return { ...m, reactions: tally };
3452
3873
  }
3874
+ /**
3875
+ * Rebuild the target message's rendered `text` + `edited` flag from the
3876
+ * authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
3877
+ * (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
3878
+ * base`; base is the seeded original so a forged/ignored edit leaves it intact.
3879
+ * No-op when the target isn't present yet (the fold already recorded it; the
3880
+ * overlay applies the moment the target lands) or when unchanged.
3881
+ */
3882
+ recomputeEdit(targetClientMsgId) {
3883
+ if (!targetClientMsgId) return;
3884
+ const editText = this.editFold.text(targetClientMsgId);
3885
+ const foldEdited = this.editFold.isEdited(targetClientMsgId);
3886
+ let changed = false;
3887
+ this.messageList = this.messageList.map((m) => {
3888
+ if (m.clientMsgId !== targetClientMsgId) return m;
3889
+ const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3890
+ const text = editText ?? base;
3891
+ const edited = foldEdited || m.edited;
3892
+ if (m.text === text && m.edited === edited) return m;
3893
+ changed = true;
3894
+ return { ...m, text, edited };
3895
+ });
3896
+ if (changed) this.emit();
3897
+ }
3898
+ /**
3899
+ * Overlay the authoritative edit fold's winning text + flag onto a message as it
3900
+ * is appended/merged. The fold WINS when it has an edit for this target;
3901
+ * otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
3902
+ * history fold) is preserved. PRESERVES reactions + replyTo.
3903
+ */
3904
+ applyEditOverlay(m) {
3905
+ if (!m.clientMsgId) return m;
3906
+ const editText = this.editFold.text(m.clientMsgId);
3907
+ const foldEdited = this.editFold.isEdited(m.clientMsgId);
3908
+ if (editText === null && !foldEdited) return m;
3909
+ const text = editText ?? m.text;
3910
+ const edited = foldEdited || m.edited;
3911
+ if (m.text === text && m.edited === edited) return m;
3912
+ return { ...m, text, edited };
3913
+ }
3453
3914
  /** @internal — called by the backend's conv subscription. */
3454
3915
  applyConv(event, payload) {
3455
3916
  const userId = typeof payload.user_id === "string" ? payload.user_id : null;
@@ -3504,6 +3965,8 @@ var Chat = class {
3504
3965
  this.memberCache = m;
3505
3966
  this.emit();
3506
3967
  }
3968
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3969
+ for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3507
3970
  }
3508
3971
  seedMembersFromGroup(group) {
3509
3972
  const seed = [
@@ -3582,6 +4045,7 @@ var Chat = class {
3582
4045
  this.seenKeys.add(key);
3583
4046
  if (clientMsgId) {
3584
4047
  this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
4048
+ this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
3585
4049
  }
3586
4050
  this.messageList.push({
3587
4051
  id: this.publicId(receipt.serverSeq),
@@ -3595,7 +4059,11 @@ var Chat = class {
3595
4059
  replyTo: resolvedReplyTo,
3596
4060
  // Attach any tally already folded for this own-sent message (rare, but keeps
3597
4061
  // the dangling-target invariant uniform across every append path).
3598
- reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
4062
+ reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
4063
+ // Own-sent edits fold via edit() after the fact; new sends start unedited.
4064
+ edited: false,
4065
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4066
+ isDeleted: false
3599
4067
  });
3600
4068
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3601
4069
  this.emit();
@@ -3676,6 +4144,83 @@ var Chat = class {
3676
4144
  });
3677
4145
  this.recomputeReactions(message.clientMsgId);
3678
4146
  }
4147
+ // ── Edit ──
4148
+ /** Edit an own text message (edit-by-supersession). No-op if the message isn't
4149
+ * editable (empty clientMsgId, or not a `text` kind). The edit folds locally
4150
+ * with the server receipt's `(epoch, serverSeq)` so the target's text updates
4151
+ * instantly; the durable echo on the next pump is a fold no-op (dedup on the
4152
+ * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
4153
+ * reactions + reply context. Only the original author's edits count — for an own
4154
+ * message self IS the author, so the author-gate passes. */
4155
+ async edit(message, newText) {
4156
+ if (!message.clientMsgId || message.kind !== "text") return;
4157
+ const group = await this.materializeIfNeeded();
4158
+ const clientMsgId = mintClientMsgId();
4159
+ const { receipt } = await this.backend.sendEdit(group, {
4160
+ clientMsgId,
4161
+ targetClientMsgId: message.clientMsgId,
4162
+ newText
4163
+ });
4164
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4165
+ this.editFold.ingest(
4166
+ {
4167
+ targetClientMsgId: message.clientMsgId,
4168
+ editorUserId: this.backend.selfUserId,
4169
+ newText,
4170
+ epoch: receipt.epoch,
4171
+ serverSeq: receipt.serverSeq,
4172
+ eventClientMsgId: clientMsgId
4173
+ },
4174
+ this.authorOfTarget
4175
+ );
4176
+ this.recomputeEdit(message.clientMsgId);
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
+ }
3679
4224
  };
3680
4225
  function sameReactions(a, b) {
3681
4226
  const ak = Object.keys(a);
@@ -3855,6 +4400,8 @@ var MessageDeliverySource = class {
3855
4400
  const decoded = decodeEnvelope(received.data);
3856
4401
  const { text, clientMsgId, replyTo } = decoded;
3857
4402
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4403
+ const isEdit = decoded.type === "edit" && decoded.edit != null;
4404
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
3858
4405
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3859
4406
  const stored = {
3860
4407
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3882,6 +4429,29 @@ var MessageDeliverySource = class {
3882
4429
  emoji: decoded.reaction.emoji,
3883
4430
  op: decoded.reaction.op
3884
4431
  }
4432
+ } : {},
4433
+ // Thread the edit discriminator + new text through the persisted row so an
4434
+ // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4435
+ // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4436
+ // `'text'`/no-edit (backward-compat).
4437
+ ...isEdit && decoded.edit ? {
4438
+ envelopeType: "edit",
4439
+ edit: {
4440
+ targetClientMsgId: decoded.edit.targetClientMsgId,
4441
+ newText: decoded.edit.newText
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
+ }
3885
4455
  } : {}
3886
4456
  };
3887
4457
  try {
@@ -3900,7 +4470,9 @@ var MessageDeliverySource = class {
3900
4470
  clientMsgId,
3901
4471
  replyRef: replyTo,
3902
4472
  envelopeType: decoded.type ?? "text",
3903
- reaction: isReaction ? decoded.reaction : null
4473
+ reaction: isReaction ? decoded.reaction : null,
4474
+ edit: isEdit ? decoded.edit : null,
4475
+ delete: isDelete ? decoded.delete : null
3904
4476
  });
3905
4477
  return true;
3906
4478
  }
@@ -5722,6 +6294,36 @@ var SignatureKeyStore = class {
5722
6294
  }
5723
6295
  };
5724
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
+
5725
6327
  // src/messaging/coordinator.ts
5726
6328
  var MessagingCoordinator = class {
5727
6329
  constructor(rt) {
@@ -5731,6 +6333,7 @@ var MessagingCoordinator = class {
5731
6333
  this.sigStore = new SignatureKeyStore(this.kv);
5732
6334
  this.groupStore = new GroupStateStorage(this.kv);
5733
6335
  this.kpStore = new KeyPackageStorage(this.kv);
6336
+ this.suppressionStore = new SuppressionStore(this.kv);
5734
6337
  this.registry.attachChatList(
5735
6338
  (chats) => {
5736
6339
  this.chatList = chats;
@@ -5745,6 +6348,7 @@ var MessagingCoordinator = class {
5745
6348
  sigStore;
5746
6349
  groupStore;
5747
6350
  kpStore;
6351
+ suppressionStore;
5748
6352
  registry = new GroupRegistry();
5749
6353
  resolved = null;
5750
6354
  resolvePromise = null;
@@ -5908,6 +6512,22 @@ var MessagingCoordinator = class {
5908
6512
  const r = await this.resolve();
5909
6513
  return r.groups.sendReaction(group, args);
5910
6514
  }
6515
+ async sendEdit(group, args) {
6516
+ const r = await this.resolve();
6517
+ return r.groups.sendEdit(group, args);
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
+ }
5911
6531
  async history(group, limit, before) {
5912
6532
  const r = await this.resolve();
5913
6533
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6004,9 +6624,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6004
6624
  eventClientMsgId: s.clientMsgId ?? `${s.id}`
6005
6625
  });
6006
6626
  }
6627
+ const editFold = new EditFold();
6628
+ const deleteFold = new DeleteFold();
6629
+ const authorByClientMsgId = /* @__PURE__ */ new Map();
6630
+ for (const s of rows) {
6631
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6632
+ continue;
6633
+ const cid = s.clientMsgId ?? "";
6634
+ if (!cid) continue;
6635
+ const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6636
+ if (author != null) authorByClientMsgId.set(cid, author);
6637
+ }
6638
+ const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6639
+ for (const s of rows) {
6640
+ if (s.envelopeType !== "edit" || !s.edit) continue;
6641
+ const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6642
+ editFold.ingest(
6643
+ {
6644
+ targetClientMsgId: s.edit.targetClientMsgId,
6645
+ editorUserId: editor,
6646
+ newText: s.edit.newText,
6647
+ epoch: s.epoch,
6648
+ serverSeq: s.serverSeq,
6649
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6650
+ },
6651
+ authorOfTarget
6652
+ );
6653
+ }
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);
6007
6670
  const lookup = /* @__PURE__ */ new Map();
6008
6671
  for (const s of rows) {
6009
- if (s.envelopeType === "reaction") continue;
6672
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6673
+ continue;
6010
6674
  const cid = s.clientMsgId ?? "";
6011
6675
  if (cid && s.text !== null) {
6012
6676
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6015,8 +6679,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6015
6679
  }
6016
6680
  const out = [];
6017
6681
  for (const s of rows) {
6018
- if (s.envelopeType === "reaction") continue;
6682
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6683
+ continue;
6019
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
+ }
6020
6703
  let replyTo = null;
6021
6704
  if (s.replyTo) {
6022
6705
  const ref = {
@@ -6031,17 +6714,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6031
6714
  };
6032
6715
  replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
6033
6716
  }
6717
+ const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6718
+ const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6034
6719
  out.push({
6035
6720
  id: `${displayId}#${s.serverSeq}`,
6036
6721
  kind: s.text != null ? "text" : "system",
6037
6722
  direction: s.direction,
6038
6723
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6039
- text: s.text,
6724
+ text: editText ?? s.text,
6040
6725
  serverSeq: s.serverSeq,
6041
6726
  sentAt: new Date(s.at),
6042
6727
  clientMsgId,
6043
6728
  replyTo,
6044
- reactions: clientMsgId ? fold.tally(clientMsgId) : {}
6729
+ reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6730
+ edited,
6731
+ isDeleted: false
6045
6732
  });
6046
6733
  }
6047
6734
  return out;
@@ -6779,7 +7466,7 @@ function defaultSessionStorage(key) {
6779
7466
  }
6780
7467
 
6781
7468
  // src/version.ts
6782
- var VERSION = "1.2.1";
7469
+ var VERSION = "1.4.0";
6783
7470
 
6784
7471
  // src/runtime.ts
6785
7472
  function buildRuntime(config) {