@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.
@@ -2591,6 +2591,151 @@ var PalbeFlags = class {
2591
2591
  }
2592
2592
  };
2593
2593
 
2594
+ // src/messaging/delete-fold.ts
2595
+ var DeleteFold = class {
2596
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2597
+ tombstoned = /* @__PURE__ */ new Set();
2598
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2599
+ pending = /* @__PURE__ */ new Map();
2600
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2601
+ seen = /* @__PURE__ */ new Set();
2602
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2603
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2604
+ held = [];
2605
+ /**
2606
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2607
+ * userId (null = target absent locally → defer).
2608
+ */
2609
+ ingest(e, authorOfTarget) {
2610
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2611
+ if (this.seen.has(e.eventClientMsgId)) return;
2612
+ if (this.heldContains(e.eventClientMsgId)) return;
2613
+ const author = authorOfTarget(e.targetClientMsgId);
2614
+ if (author !== null) {
2615
+ this.seen.add(e.eventClientMsgId);
2616
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2617
+ this.tombstoned.add(e.targetClientMsgId);
2618
+ } else if (e.actorUserId !== null) {
2619
+ this.seen.add(e.eventClientMsgId);
2620
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2621
+ } else {
2622
+ this.held.push(e);
2623
+ }
2624
+ }
2625
+ /** True once a valid tombstone has absorbed this target. */
2626
+ isTombstoned(targetClientMsgId) {
2627
+ return this.tombstoned.has(targetClientMsgId);
2628
+ }
2629
+ /**
2630
+ * When a target message newly arrives with a resolved `author`, re-check any
2631
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2632
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2633
+ * the in-order path.
2634
+ */
2635
+ reevaluatePending(target, author) {
2636
+ const actor = this.pending.get(target);
2637
+ if (actor !== void 0) {
2638
+ if (author !== null && actor === author) {
2639
+ this.tombstoned.add(target);
2640
+ this.pending.delete(target);
2641
+ } else if (author !== null) {
2642
+ this.pending.delete(target);
2643
+ }
2644
+ }
2645
+ if (this.held.length === 0) return;
2646
+ const pendingHeld = this.held;
2647
+ this.held = [];
2648
+ for (const e of pendingHeld) {
2649
+ this.ingest(e, (t) => t === target ? author : null);
2650
+ }
2651
+ }
2652
+ heldContains(eventClientMsgId) {
2653
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2654
+ }
2655
+ };
2656
+
2657
+ // src/messaging/edit-fold.ts
2658
+ function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2659
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
2660
+ return aSeq < bSeq;
2661
+ }
2662
+ function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
2663
+ return aEpoch === bEpoch && aSeq === bSeq;
2664
+ }
2665
+ var EditFold = class {
2666
+ // target → winning edit state
2667
+ states = /* @__PURE__ */ new Map();
2668
+ // dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
2669
+ seenEvents = /* @__PURE__ */ new Set();
2670
+ // events parked because target/author or sender was unresolved at ingest time
2671
+ held = [];
2672
+ // targets that have had ≥1 valid edit applied (write-once)
2673
+ editedTargets = /* @__PURE__ */ new Set();
2674
+ /**
2675
+ * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2676
+ * (null = target unknown/dangling → HOLD).
2677
+ */
2678
+ ingest(e, authorOfTarget) {
2679
+ const author = authorOfTarget(e.targetClientMsgId);
2680
+ if (author === null) {
2681
+ this.holdIfNew(e);
2682
+ return;
2683
+ }
2684
+ if (e.editorUserId === null) {
2685
+ this.holdIfNew(e);
2686
+ return;
2687
+ }
2688
+ if (e.editorUserId !== author) return;
2689
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
2690
+ this.seenEvents.add(e.eventClientMsgId);
2691
+ const prev = this.states.get(e.targetClientMsgId);
2692
+ if (prev !== void 0) {
2693
+ if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
2694
+ if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
2695
+ return;
2696
+ }
2697
+ }
2698
+ this.states.set(e.targetClientMsgId, {
2699
+ orderEpoch: e.epoch,
2700
+ orderSeq: e.serverSeq,
2701
+ lastEventId: e.eventClientMsgId,
2702
+ text: e.newText
2703
+ });
2704
+ this.editedTargets.add(e.targetClientMsgId);
2705
+ }
2706
+ /**
2707
+ * Park an event for later re-attempt, deduping held re-deliveries by
2708
+ * eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
2709
+ * once (and never double-applies when it finally resolves on reevaluate).
2710
+ */
2711
+ holdIfNew(e) {
2712
+ if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
2713
+ this.held.push(e);
2714
+ }
2715
+ /** The winning edit text for a target, or null if no valid edit has applied. */
2716
+ text(targetClientMsgId) {
2717
+ return this.states.get(targetClientMsgId)?.text ?? null;
2718
+ }
2719
+ /** Write-once: true once any valid edit applied to the target. */
2720
+ isEdited(targetClientMsgId) {
2721
+ return this.editedTargets.has(targetClientMsgId);
2722
+ }
2723
+ /**
2724
+ * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2725
+ * change and when a target message arrives). Clears `held` and re-ingests each
2726
+ * event with the fresh `authorOfTarget` — events that still don't resolve are
2727
+ * simply re-held; events that now resolve fold via the normal LWW path.
2728
+ * Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
2729
+ * `holdIfNew` (still-held events), so reevaluating repeatedly can neither
2730
+ * double-apply nor lose an edit.
2731
+ */
2732
+ reevaluateHeld(authorOfTarget) {
2733
+ const pending = this.held;
2734
+ this.held = [];
2735
+ for (const e of pending) this.ingest(e, authorOfTarget);
2736
+ }
2737
+ };
2738
+
2594
2739
  // src/messaging/util.ts
2595
2740
  function toBase64(bytes) {
2596
2741
  if (typeof Buffer !== "undefined") {
@@ -2730,6 +2875,28 @@ async function listDevices(rt, userId) {
2730
2875
  }
2731
2876
 
2732
2877
  // src/messaging/group-messaging.ts
2878
+ function encodeDelete(args) {
2879
+ return encodeUtf8(
2880
+ JSON.stringify({
2881
+ v: 1,
2882
+ type: "delete",
2883
+ client_msg_id: args.clientMsgId,
2884
+ target_client_msg_id: args.targetClientMsgId,
2885
+ scope: "everyone"
2886
+ })
2887
+ );
2888
+ }
2889
+ function encodeEdit(args) {
2890
+ return encodeUtf8(
2891
+ JSON.stringify({
2892
+ v: 1,
2893
+ type: "edit",
2894
+ client_msg_id: args.clientMsgId,
2895
+ target_client_msg_id: args.targetClientMsgId,
2896
+ new_text: args.newText
2897
+ })
2898
+ );
2899
+ }
2733
2900
  function encodeReaction(args) {
2734
2901
  return encodeUtf8(
2735
2902
  JSON.stringify({
@@ -2756,6 +2923,18 @@ function decodeEnvelope(bytes) {
2756
2923
  const s = decodeUtf8(bytes);
2757
2924
  try {
2758
2925
  const o = JSON.parse(s);
2926
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2927
+ return {
2928
+ type: "delete",
2929
+ text: null,
2930
+ clientMsgId: o.client_msg_id ?? "",
2931
+ replyTo: null,
2932
+ delete: {
2933
+ targetClientMsgId: o.target_client_msg_id ?? "",
2934
+ scope: o.scope ?? "everyone"
2935
+ }
2936
+ };
2937
+ }
2759
2938
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2760
2939
  return {
2761
2940
  type: "reaction",
@@ -2769,6 +2948,18 @@ function decodeEnvelope(bytes) {
2769
2948
  }
2770
2949
  };
2771
2950
  }
2951
+ if (typeof o === "object" && o !== null && o.type === "edit") {
2952
+ return {
2953
+ type: "edit",
2954
+ text: null,
2955
+ clientMsgId: o.client_msg_id ?? "",
2956
+ replyTo: null,
2957
+ edit: {
2958
+ targetClientMsgId: o.target_client_msg_id ?? "",
2959
+ newText: o.new_text ?? ""
2960
+ }
2961
+ };
2962
+ }
2772
2963
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2773
2964
  return {
2774
2965
  type: "text",
@@ -3083,6 +3274,103 @@ var GroupMessaging = class {
3083
3274
  clientMsgId: args.clientMsgId
3084
3275
  };
3085
3276
  }
3277
+ /** Send an edit (edit-by-supersession on a target message). Encrypts a
3278
+ * `type:'edit'` envelope at the current epoch and sends through the SAME MLS
3279
+ * application path as `sendText` (the server stays blind — an edit is just
3280
+ * another application message). Persists the outgoing edit row so it re-folds
3281
+ * onto its target's text after a reload (the own-send half of the reload
3282
+ * parity). NEVER rebases (epoch-bound like any application message). */
3283
+ async sendEdit(group, args) {
3284
+ const plaintext = encodeEdit({
3285
+ clientMsgId: args.clientMsgId,
3286
+ targetClientMsgId: args.targetClientMsgId,
3287
+ newText: args.newText
3288
+ });
3289
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3290
+ const body = {
3291
+ ciphertext_b64: toBase64(ct),
3292
+ client_idem_key: randomId()
3293
+ };
3294
+ const wire = await palbeRequest(
3295
+ this.rt,
3296
+ "POST",
3297
+ MessagingPaths.groupMessages(group.displayId),
3298
+ { body }
3299
+ );
3300
+ const stored = {
3301
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3302
+ direction: "outgoing",
3303
+ text: null,
3304
+ senderDeviceId: this.selfDeviceId,
3305
+ epoch: wire.epoch,
3306
+ serverSeq: wire.server_seq,
3307
+ at: Date.now(),
3308
+ clientMsgId: args.clientMsgId,
3309
+ replyTo: null,
3310
+ envelopeType: "edit",
3311
+ edit: {
3312
+ targetClientMsgId: args.targetClientMsgId,
3313
+ newText: args.newText
3314
+ }
3315
+ };
3316
+ try {
3317
+ await this.messageStore.append(group.rfcGroupId, stored);
3318
+ } catch {
3319
+ }
3320
+ return {
3321
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3322
+ clientMsgId: args.clientMsgId
3323
+ };
3324
+ }
3325
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3326
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3327
+ * application path as `sendText` (the server stays blind — a delete is just
3328
+ * another opaque application message; the original ciphertext row is NOT
3329
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3330
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3331
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3332
+ * rebases (epoch-bound like any application message). */
3333
+ async sendDelete(group, args) {
3334
+ const plaintext = encodeDelete({
3335
+ clientMsgId: args.clientMsgId,
3336
+ targetClientMsgId: args.targetClientMsgId
3337
+ });
3338
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3339
+ const body = {
3340
+ ciphertext_b64: toBase64(ct),
3341
+ client_idem_key: randomId()
3342
+ };
3343
+ const wire = await palbeRequest(
3344
+ this.rt,
3345
+ "POST",
3346
+ MessagingPaths.groupMessages(group.displayId),
3347
+ { body }
3348
+ );
3349
+ const stored = {
3350
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3351
+ direction: "outgoing",
3352
+ text: null,
3353
+ senderDeviceId: this.selfDeviceId,
3354
+ epoch: wire.epoch,
3355
+ serverSeq: wire.server_seq,
3356
+ at: Date.now(),
3357
+ clientMsgId: args.clientMsgId,
3358
+ replyTo: null,
3359
+ envelopeType: "delete",
3360
+ delete: {
3361
+ targetClientMsgId: args.targetClientMsgId,
3362
+ scope: "everyone"
3363
+ }
3364
+ };
3365
+ try {
3366
+ await this.messageStore.append(group.rfcGroupId, stored);
3367
+ } catch {
3368
+ }
3369
+ return {
3370
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3371
+ clientMsgId: args.clientMsgId
3372
+ };
3373
+ }
3086
3374
  // ── The rebase loop ──
3087
3375
  async commitWithRebase(rfcGroupId, build) {
3088
3376
  const gidBytes = fromBase64(rfcGroupId);
@@ -3200,6 +3488,7 @@ var ReactionFold = class {
3200
3488
  };
3201
3489
 
3202
3490
  // src/messaging/chat.ts
3491
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3203
3492
  var Chat = class {
3204
3493
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3205
3494
  id;
@@ -3219,6 +3508,23 @@ var Chat = class {
3219
3508
  byClientMsgId = /* @__PURE__ */ new Map();
3220
3509
  /** The single authoritative reaction fold for this chat (live + own-send + history). */
3221
3510
  reactionFold = new ReactionFold();
3511
+ /** The single authoritative edit fold for this chat (live + own-send + history). */
3512
+ editFold = new EditFold();
3513
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3514
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3515
+ deleteFold = new DeleteFold();
3516
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3517
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3518
+ suppressed = /* @__PURE__ */ new Set();
3519
+ /** True once the persisted suppression set has been loaded (so the omit applies
3520
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3521
+ suppressedLoaded = false;
3522
+ /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3523
+ * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3524
+ originalTextByClientMsgId = /* @__PURE__ */ new Map();
3525
+ /** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
3526
+ * projection time from senderUserId; '' = resolved-but-unknown peer). */
3527
+ authorByClientMsgId = /* @__PURE__ */ new Map();
3222
3528
  loadedEarliestSeq = null;
3223
3529
  historyLoaded = false;
3224
3530
  wired = false;
@@ -3261,7 +3567,7 @@ var Chat = class {
3261
3567
  return this.kind === "direct";
3262
3568
  }
3263
3569
  get messages() {
3264
- return this.messageList;
3570
+ return this.surfaced();
3265
3571
  }
3266
3572
  get members() {
3267
3573
  return this.memberCache;
@@ -3270,13 +3576,49 @@ var Chat = class {
3270
3576
  return this.typingList;
3271
3577
  }
3272
3578
  get lastMessage() {
3273
- return this.messageList.at(-1) ?? null;
3579
+ return this.surfaced().at(-1) ?? null;
3274
3580
  }
3275
3581
  get unreadCount() {
3276
- return this.messageList.filter(
3277
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3582
+ return this.surfaced().filter(
3583
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3278
3584
  ).length;
3279
3585
  }
3586
+ /**
3587
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3588
+ * through it identically). Over the raw `messageList` (which already carries the
3589
+ * folded edit text + reactions + reply):
3590
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3591
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3592
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3593
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3594
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3595
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3596
+ */
3597
+ surfaced() {
3598
+ const out = [];
3599
+ for (const m of this.messageList) {
3600
+ const key = this.suppressionKey(m);
3601
+ if (this.suppressed.has(key)) continue;
3602
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3603
+ if (tombstoned) {
3604
+ out.push({
3605
+ ...m,
3606
+ text: DELETED_DESCRIPTOR,
3607
+ reactions: {},
3608
+ replyTo: null,
3609
+ edited: false,
3610
+ isDeleted: true
3611
+ });
3612
+ continue;
3613
+ }
3614
+ out.push(m);
3615
+ }
3616
+ return out;
3617
+ }
3618
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3619
+ suppressionKey(m) {
3620
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3621
+ }
3280
3622
  get title() {
3281
3623
  if (this.titleOverride) return this.titleOverride;
3282
3624
  if (this._group?.name) return this._group.name;
@@ -3300,9 +3642,28 @@ var Chat = class {
3300
3642
  if (this.wired || this._state !== "active" || !this._group) return;
3301
3643
  this.wired = true;
3302
3644
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3645
+ void this.loadSuppressed();
3303
3646
  void this.hydrateHistory();
3304
3647
  void this.refreshMembers();
3305
3648
  }
3649
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3650
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3651
+ async loadSuppressed() {
3652
+ if (this.suppressedLoaded || !this._group) return;
3653
+ this.suppressedLoaded = true;
3654
+ try {
3655
+ const keys = await this.backend.loadSuppressed(this._group);
3656
+ let changed = false;
3657
+ for (const k of keys) {
3658
+ if (!this.suppressed.has(k)) {
3659
+ this.suppressed.add(k);
3660
+ changed = true;
3661
+ }
3662
+ }
3663
+ if (changed) this.emit();
3664
+ } catch {
3665
+ }
3666
+ }
3306
3667
  async hydrateHistory() {
3307
3668
  if (this.historyLoaded || !this._group) return;
3308
3669
  this.historyLoaded = true;
@@ -3313,19 +3674,26 @@ var Chat = class {
3313
3674
  let changed = false;
3314
3675
  for (const m of incoming) {
3315
3676
  if (m.serverSeq <= 0) continue;
3316
- if (m.clientMsgId && m.text !== null) {
3677
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3317
3678
  this.byClientMsgId.set(m.clientMsgId, {
3318
3679
  text: m.text,
3319
3680
  senderUserId: m.senderUserId ?? ""
3320
3681
  });
3321
3682
  }
3683
+ if (m.clientMsgId && !m.isDeleted) {
3684
+ this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3685
+ }
3322
3686
  }
3687
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3323
3688
  for (const m of incoming) {
3324
3689
  if (m.serverSeq <= 0) continue;
3325
3690
  const key = this.internalKey(m.serverSeq);
3326
3691
  if (this.seenKeys.has(key)) continue;
3327
3692
  this.seenKeys.add(key);
3328
- this.messageList.push(this.applyReactionTally(m));
3693
+ if (m.clientMsgId && !m.isDeleted) {
3694
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3695
+ }
3696
+ this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3329
3697
  changed = true;
3330
3698
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3331
3699
  }
@@ -3366,6 +3734,37 @@ var Chat = class {
3366
3734
  }
3367
3735
  return;
3368
3736
  }
3737
+ if (incoming.envelopeType === "edit" && incoming.edit) {
3738
+ const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3739
+ this.editFold.ingest(
3740
+ {
3741
+ targetClientMsgId: incoming.edit.targetClientMsgId,
3742
+ editorUserId,
3743
+ newText: incoming.edit.newText,
3744
+ epoch: incoming.epoch,
3745
+ serverSeq: incoming.serverSeq,
3746
+ eventClientMsgId: incoming.clientMsgId
3747
+ },
3748
+ this.authorOfTarget
3749
+ );
3750
+ this.recomputeEdit(incoming.edit.targetClientMsgId);
3751
+ return;
3752
+ }
3753
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3754
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3755
+ this.deleteFold.ingest(
3756
+ {
3757
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3758
+ actorUserId,
3759
+ epoch: incoming.epoch,
3760
+ serverSeq: incoming.serverSeq,
3761
+ eventClientMsgId: incoming.clientMsgId
3762
+ },
3763
+ this.authorOfTarget
3764
+ );
3765
+ this.emit();
3766
+ return;
3767
+ }
3369
3768
  const incomingClientMsgId = incoming.clientMsgId;
3370
3769
  const incomingReplyRef = incoming.replyRef;
3371
3770
  let resolvedReplyTo = null;
@@ -3384,7 +3783,11 @@ var Chat = class {
3384
3783
  replyTo: resolvedReplyTo,
3385
3784
  // Attach any tally already folded for this message (a reaction that arrived
3386
3785
  // BEFORE its target — the dangling case — renders the moment the target lands).
3387
- reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
3786
+ reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3787
+ // Default false; applyEditOverlay below folds any edit that arrived first.
3788
+ edited: false,
3789
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3790
+ isDeleted: false
3388
3791
  };
3389
3792
  if (incomingClientMsgId && incoming.text !== null) {
3390
3793
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -3392,7 +3795,12 @@ var Chat = class {
3392
3795
  senderUserId: senderUser ?? ""
3393
3796
  });
3394
3797
  }
3395
- this.messageList.push(msg);
3798
+ if (incomingClientMsgId) {
3799
+ this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3800
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3801
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3802
+ }
3803
+ this.messageList.push(this.applyEditOverlay(msg));
3396
3804
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3397
3805
  this.loadedEarliestSeq = Math.min(
3398
3806
  this.loadedEarliestSeq ?? incoming.serverSeq,
@@ -3400,6 +3808,19 @@ var Chat = class {
3400
3808
  );
3401
3809
  this.emit();
3402
3810
  }
3811
+ /** The EditFold author-gate input: the target message's resolved author userId
3812
+ * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3813
+ * so it can be passed to the pure EditFold. */
3814
+ authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3815
+ /** Seed the per-target base text + author for the edit fold. Base is write-once
3816
+ * (a later own/peer edit must not overwrite the original we render against). The
3817
+ * author is (re)recorded whenever a non-empty resolution is available. */
3818
+ seedEditBase(clientMsgId, text, author) {
3819
+ if (!this.originalTextByClientMsgId.has(clientMsgId)) {
3820
+ this.originalTextByClientMsgId.set(clientMsgId, text);
3821
+ }
3822
+ if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
3823
+ }
3403
3824
  /**
3404
3825
  * Rebuild the target message's `reactions` from the authoritative fold and
3405
3826
  * re-emit. No-op when the target isn't present yet (its tally is attached the
@@ -3429,6 +3850,46 @@ var Chat = class {
3429
3850
  if (sameReactions(m.reactions, tally)) return m;
3430
3851
  return { ...m, reactions: tally };
3431
3852
  }
3853
+ /**
3854
+ * Rebuild the target message's rendered `text` + `edited` flag from the
3855
+ * authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
3856
+ * (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
3857
+ * base`; base is the seeded original so a forged/ignored edit leaves it intact.
3858
+ * No-op when the target isn't present yet (the fold already recorded it; the
3859
+ * overlay applies the moment the target lands) or when unchanged.
3860
+ */
3861
+ recomputeEdit(targetClientMsgId) {
3862
+ if (!targetClientMsgId) return;
3863
+ const editText = this.editFold.text(targetClientMsgId);
3864
+ const foldEdited = this.editFold.isEdited(targetClientMsgId);
3865
+ let changed = false;
3866
+ this.messageList = this.messageList.map((m) => {
3867
+ if (m.clientMsgId !== targetClientMsgId) return m;
3868
+ const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3869
+ const text = editText ?? base;
3870
+ const edited = foldEdited || m.edited;
3871
+ if (m.text === text && m.edited === edited) return m;
3872
+ changed = true;
3873
+ return { ...m, text, edited };
3874
+ });
3875
+ if (changed) this.emit();
3876
+ }
3877
+ /**
3878
+ * Overlay the authoritative edit fold's winning text + flag onto a message as it
3879
+ * is appended/merged. The fold WINS when it has an edit for this target;
3880
+ * otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
3881
+ * history fold) is preserved. PRESERVES reactions + replyTo.
3882
+ */
3883
+ applyEditOverlay(m) {
3884
+ if (!m.clientMsgId) return m;
3885
+ const editText = this.editFold.text(m.clientMsgId);
3886
+ const foldEdited = this.editFold.isEdited(m.clientMsgId);
3887
+ if (editText === null && !foldEdited) return m;
3888
+ const text = editText ?? m.text;
3889
+ const edited = foldEdited || m.edited;
3890
+ if (m.text === text && m.edited === edited) return m;
3891
+ return { ...m, text, edited };
3892
+ }
3432
3893
  /** @internal — called by the backend's conv subscription. */
3433
3894
  applyConv(event, payload) {
3434
3895
  const userId = typeof payload.user_id === "string" ? payload.user_id : null;
@@ -3483,6 +3944,8 @@ var Chat = class {
3483
3944
  this.memberCache = m;
3484
3945
  this.emit();
3485
3946
  }
3947
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3948
+ for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3486
3949
  }
3487
3950
  seedMembersFromGroup(group) {
3488
3951
  const seed = [
@@ -3561,6 +4024,7 @@ var Chat = class {
3561
4024
  this.seenKeys.add(key);
3562
4025
  if (clientMsgId) {
3563
4026
  this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
4027
+ this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
3564
4028
  }
3565
4029
  this.messageList.push({
3566
4030
  id: this.publicId(receipt.serverSeq),
@@ -3574,7 +4038,11 @@ var Chat = class {
3574
4038
  replyTo: resolvedReplyTo,
3575
4039
  // Attach any tally already folded for this own-sent message (rare, but keeps
3576
4040
  // the dangling-target invariant uniform across every append path).
3577
- reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
4041
+ reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
4042
+ // Own-sent edits fold via edit() after the fact; new sends start unedited.
4043
+ edited: false,
4044
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4045
+ isDeleted: false
3578
4046
  });
3579
4047
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3580
4048
  this.emit();
@@ -3655,6 +4123,83 @@ var Chat = class {
3655
4123
  });
3656
4124
  this.recomputeReactions(message.clientMsgId);
3657
4125
  }
4126
+ // ── Edit ──
4127
+ /** Edit an own text message (edit-by-supersession). No-op if the message isn't
4128
+ * editable (empty clientMsgId, or not a `text` kind). The edit folds locally
4129
+ * with the server receipt's `(epoch, serverSeq)` so the target's text updates
4130
+ * instantly; the durable echo on the next pump is a fold no-op (dedup on the
4131
+ * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
4132
+ * reactions + reply context. Only the original author's edits count — for an own
4133
+ * message self IS the author, so the author-gate passes. */
4134
+ async edit(message, newText) {
4135
+ if (!message.clientMsgId || message.kind !== "text") return;
4136
+ const group = await this.materializeIfNeeded();
4137
+ const clientMsgId = mintClientMsgId();
4138
+ const { receipt } = await this.backend.sendEdit(group, {
4139
+ clientMsgId,
4140
+ targetClientMsgId: message.clientMsgId,
4141
+ newText
4142
+ });
4143
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4144
+ this.editFold.ingest(
4145
+ {
4146
+ targetClientMsgId: message.clientMsgId,
4147
+ editorUserId: this.backend.selfUserId,
4148
+ newText,
4149
+ epoch: receipt.epoch,
4150
+ serverSeq: receipt.serverSeq,
4151
+ eventClientMsgId: clientMsgId
4152
+ },
4153
+ this.authorOfTarget
4154
+ );
4155
+ this.recomputeEdit(message.clientMsgId);
4156
+ }
4157
+ // ── Delete ──
4158
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
4159
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
4160
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
4161
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
4162
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
4163
+ * server stays blind), folds the own delete locally so the target scrubs in
4164
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
4165
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
4166
+ async deleteForEveryone(message) {
4167
+ if (!message.clientMsgId) return;
4168
+ const group = await this.materializeIfNeeded();
4169
+ const clientMsgId = mintClientMsgId();
4170
+ const { receipt } = await this.backend.sendDelete(group, {
4171
+ clientMsgId,
4172
+ targetClientMsgId: message.clientMsgId
4173
+ });
4174
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4175
+ this.deleteFold.ingest(
4176
+ {
4177
+ targetClientMsgId: message.clientMsgId,
4178
+ actorUserId: this.backend.selfUserId,
4179
+ epoch: receipt.epoch,
4180
+ serverSeq: receipt.serverSeq,
4181
+ eventClientMsgId: clientMsgId
4182
+ },
4183
+ this.authorOfTarget
4184
+ );
4185
+ this.emit();
4186
+ }
4187
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4188
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4189
+ * suppression key persists per chat (survives reload). The key is the message's
4190
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4191
+ async deleteForMe(message) {
4192
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4193
+ if (this.suppressed.has(key)) return;
4194
+ this.suppressed.add(key);
4195
+ this.emit();
4196
+ if (this._group) {
4197
+ try {
4198
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4199
+ } catch {
4200
+ }
4201
+ }
4202
+ }
3658
4203
  };
3659
4204
  function sameReactions(a, b) {
3660
4205
  const ak = Object.keys(a);
@@ -3834,6 +4379,8 @@ var MessageDeliverySource = class {
3834
4379
  const decoded = decodeEnvelope(received.data);
3835
4380
  const { text, clientMsgId, replyTo } = decoded;
3836
4381
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4382
+ const isEdit = decoded.type === "edit" && decoded.edit != null;
4383
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
3837
4384
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3838
4385
  const stored = {
3839
4386
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3861,6 +4408,29 @@ var MessageDeliverySource = class {
3861
4408
  emoji: decoded.reaction.emoji,
3862
4409
  op: decoded.reaction.op
3863
4410
  }
4411
+ } : {},
4412
+ // Thread the edit discriminator + new text through the persisted row so an
4413
+ // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4414
+ // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4415
+ // `'text'`/no-edit (backward-compat).
4416
+ ...isEdit && decoded.edit ? {
4417
+ envelopeType: "edit",
4418
+ edit: {
4419
+ targetClientMsgId: decoded.edit.targetClientMsgId,
4420
+ newText: decoded.edit.newText
4421
+ }
4422
+ } : {},
4423
+ // Thread the delete discriminator + target through the persisted row so a
4424
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4425
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4426
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4427
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4428
+ ...isDelete && decoded.delete ? {
4429
+ envelopeType: "delete",
4430
+ delete: {
4431
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4432
+ scope: decoded.delete.scope
4433
+ }
3864
4434
  } : {}
3865
4435
  };
3866
4436
  try {
@@ -3879,7 +4449,9 @@ var MessageDeliverySource = class {
3879
4449
  clientMsgId,
3880
4450
  replyRef: replyTo,
3881
4451
  envelopeType: decoded.type ?? "text",
3882
- reaction: isReaction ? decoded.reaction : null
4452
+ reaction: isReaction ? decoded.reaction : null,
4453
+ edit: isEdit ? decoded.edit : null,
4454
+ delete: isDelete ? decoded.delete : null
3883
4455
  });
3884
4456
  return true;
3885
4457
  }
@@ -5700,6 +6272,36 @@ var SignatureKeyStore = class {
5700
6272
  }
5701
6273
  };
5702
6274
 
6275
+ // src/messaging/suppression.ts
6276
+ var SuppressionStore = class {
6277
+ constructor(kv) {
6278
+ this.kv = kv;
6279
+ }
6280
+ kv;
6281
+ key(rfcGroupId) {
6282
+ return `supp:${rfcGroupId}`;
6283
+ }
6284
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6285
+ async load(rfcGroupId) {
6286
+ const raw = await this.kv.get(this.key(rfcGroupId));
6287
+ if (!raw) return [];
6288
+ try {
6289
+ const parsed = JSON.parse(decodeUtf8(raw));
6290
+ return Array.isArray(parsed) ? parsed : [];
6291
+ } catch {
6292
+ return [];
6293
+ }
6294
+ }
6295
+ /** Persist the full suppression key set for a chat (deterministic order). */
6296
+ async save(rfcGroupId, keys) {
6297
+ const sorted = [...new Set(keys)].sort();
6298
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6299
+ }
6300
+ async wipe() {
6301
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6302
+ }
6303
+ };
6304
+
5703
6305
  // src/messaging/coordinator.ts
5704
6306
  var MessagingCoordinator = class {
5705
6307
  constructor(rt) {
@@ -5709,6 +6311,7 @@ var MessagingCoordinator = class {
5709
6311
  this.sigStore = new SignatureKeyStore(this.kv);
5710
6312
  this.groupStore = new GroupStateStorage(this.kv);
5711
6313
  this.kpStore = new KeyPackageStorage(this.kv);
6314
+ this.suppressionStore = new SuppressionStore(this.kv);
5712
6315
  this.registry.attachChatList(
5713
6316
  (chats) => {
5714
6317
  this.chatList = chats;
@@ -5723,6 +6326,7 @@ var MessagingCoordinator = class {
5723
6326
  sigStore;
5724
6327
  groupStore;
5725
6328
  kpStore;
6329
+ suppressionStore;
5726
6330
  registry = new GroupRegistry();
5727
6331
  resolved = null;
5728
6332
  resolvePromise = null;
@@ -5886,6 +6490,22 @@ var MessagingCoordinator = class {
5886
6490
  const r = await this.resolve();
5887
6491
  return r.groups.sendReaction(group, args);
5888
6492
  }
6493
+ async sendEdit(group, args) {
6494
+ const r = await this.resolve();
6495
+ return r.groups.sendEdit(group, args);
6496
+ }
6497
+ async sendDelete(group, args) {
6498
+ const r = await this.resolve();
6499
+ return r.groups.sendDelete(group, args);
6500
+ }
6501
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6502
+ loadSuppressed(group) {
6503
+ return this.suppressionStore.load(group.rfcGroupId);
6504
+ }
6505
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6506
+ saveSuppressed(group, keys) {
6507
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6508
+ }
5889
6509
  async history(group, limit, before) {
5890
6510
  const r = await this.resolve();
5891
6511
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -5982,9 +6602,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5982
6602
  eventClientMsgId: s.clientMsgId ?? `${s.id}`
5983
6603
  });
5984
6604
  }
6605
+ const editFold = new EditFold();
6606
+ const deleteFold = new DeleteFold();
6607
+ const authorByClientMsgId = /* @__PURE__ */ new Map();
6608
+ for (const s of rows) {
6609
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6610
+ continue;
6611
+ const cid = s.clientMsgId ?? "";
6612
+ if (!cid) continue;
6613
+ const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6614
+ if (author != null) authorByClientMsgId.set(cid, author);
6615
+ }
6616
+ const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6617
+ for (const s of rows) {
6618
+ if (s.envelopeType !== "edit" || !s.edit) continue;
6619
+ const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6620
+ editFold.ingest(
6621
+ {
6622
+ targetClientMsgId: s.edit.targetClientMsgId,
6623
+ editorUserId: editor,
6624
+ newText: s.edit.newText,
6625
+ epoch: s.epoch,
6626
+ serverSeq: s.serverSeq,
6627
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6628
+ },
6629
+ authorOfTarget
6630
+ );
6631
+ }
6632
+ editFold.reevaluateHeld(authorOfTarget);
6633
+ for (const s of rows) {
6634
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6635
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6636
+ deleteFold.ingest(
6637
+ {
6638
+ targetClientMsgId: s.delete.targetClientMsgId,
6639
+ actorUserId: actor,
6640
+ epoch: s.epoch,
6641
+ serverSeq: s.serverSeq,
6642
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6643
+ },
6644
+ authorOfTarget
6645
+ );
6646
+ }
6647
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
5985
6648
  const lookup = /* @__PURE__ */ new Map();
5986
6649
  for (const s of rows) {
5987
- if (s.envelopeType === "reaction") continue;
6650
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6651
+ continue;
5988
6652
  const cid = s.clientMsgId ?? "";
5989
6653
  if (cid && s.text !== null) {
5990
6654
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -5993,8 +6657,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5993
6657
  }
5994
6658
  const out = [];
5995
6659
  for (const s of rows) {
5996
- if (s.envelopeType === "reaction") continue;
6660
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6661
+ continue;
5997
6662
  const clientMsgId = s.clientMsgId ?? "";
6663
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6664
+ if (isDeleted) {
6665
+ out.push({
6666
+ id: `${displayId}#${s.serverSeq}`,
6667
+ kind: "text",
6668
+ direction: s.direction,
6669
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6670
+ text: DELETED_DESCRIPTOR,
6671
+ serverSeq: s.serverSeq,
6672
+ sentAt: new Date(s.at),
6673
+ clientMsgId,
6674
+ replyTo: null,
6675
+ reactions: {},
6676
+ edited: false,
6677
+ isDeleted: true
6678
+ });
6679
+ continue;
6680
+ }
5998
6681
  let replyTo = null;
5999
6682
  if (s.replyTo) {
6000
6683
  const ref = {
@@ -6009,17 +6692,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6009
6692
  };
6010
6693
  replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
6011
6694
  }
6695
+ const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6696
+ const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6012
6697
  out.push({
6013
6698
  id: `${displayId}#${s.serverSeq}`,
6014
6699
  kind: s.text != null ? "text" : "system",
6015
6700
  direction: s.direction,
6016
6701
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6017
- text: s.text,
6702
+ text: editText ?? s.text,
6018
6703
  serverSeq: s.serverSeq,
6019
6704
  sentAt: new Date(s.at),
6020
6705
  clientMsgId,
6021
6706
  replyTo,
6022
- reactions: clientMsgId ? fold.tally(clientMsgId) : {}
6707
+ reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6708
+ edited,
6709
+ isDeleted: false
6023
6710
  });
6024
6711
  }
6025
6712
  return out;
@@ -6757,7 +7444,7 @@ function defaultSessionStorage(key) {
6757
7444
  }
6758
7445
 
6759
7446
  // src/version.ts
6760
- var VERSION = "1.2.1";
7447
+ var VERSION = "1.4.0";
6761
7448
 
6762
7449
  // src/runtime.ts
6763
7450
  function buildRuntime(config) {
@@ -7117,4 +7804,4 @@ export {
7117
7804
  pb,
7118
7805
  createBoundClient
7119
7806
  };
7120
- //# sourceMappingURL=chunk-KJXRY4S3.js.map
7807
+ //# sourceMappingURL=chunk-3EVGYJ5F.js.map