@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.
@@ -2413,6 +2413,151 @@ var PalbeFlags = class {
2413
2413
  }
2414
2414
  };
2415
2415
 
2416
+ // src/messaging/delete-fold.ts
2417
+ var DeleteFold = class {
2418
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2419
+ tombstoned = /* @__PURE__ */ new Set();
2420
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2421
+ pending = /* @__PURE__ */ new Map();
2422
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2423
+ seen = /* @__PURE__ */ new Set();
2424
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2425
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2426
+ held = [];
2427
+ /**
2428
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2429
+ * userId (null = target absent locally → defer).
2430
+ */
2431
+ ingest(e, authorOfTarget) {
2432
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2433
+ if (this.seen.has(e.eventClientMsgId)) return;
2434
+ if (this.heldContains(e.eventClientMsgId)) return;
2435
+ const author = authorOfTarget(e.targetClientMsgId);
2436
+ if (author !== null) {
2437
+ this.seen.add(e.eventClientMsgId);
2438
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2439
+ this.tombstoned.add(e.targetClientMsgId);
2440
+ } else if (e.actorUserId !== null) {
2441
+ this.seen.add(e.eventClientMsgId);
2442
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2443
+ } else {
2444
+ this.held.push(e);
2445
+ }
2446
+ }
2447
+ /** True once a valid tombstone has absorbed this target. */
2448
+ isTombstoned(targetClientMsgId) {
2449
+ return this.tombstoned.has(targetClientMsgId);
2450
+ }
2451
+ /**
2452
+ * When a target message newly arrives with a resolved `author`, re-check any
2453
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2454
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2455
+ * the in-order path.
2456
+ */
2457
+ reevaluatePending(target, author) {
2458
+ const actor = this.pending.get(target);
2459
+ if (actor !== void 0) {
2460
+ if (author !== null && actor === author) {
2461
+ this.tombstoned.add(target);
2462
+ this.pending.delete(target);
2463
+ } else if (author !== null) {
2464
+ this.pending.delete(target);
2465
+ }
2466
+ }
2467
+ if (this.held.length === 0) return;
2468
+ const pendingHeld = this.held;
2469
+ this.held = [];
2470
+ for (const e of pendingHeld) {
2471
+ this.ingest(e, (t) => t === target ? author : null);
2472
+ }
2473
+ }
2474
+ heldContains(eventClientMsgId) {
2475
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2476
+ }
2477
+ };
2478
+
2479
+ // src/messaging/edit-fold.ts
2480
+ function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2481
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
2482
+ return aSeq < bSeq;
2483
+ }
2484
+ function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
2485
+ return aEpoch === bEpoch && aSeq === bSeq;
2486
+ }
2487
+ var EditFold = class {
2488
+ // target → winning edit state
2489
+ states = /* @__PURE__ */ new Map();
2490
+ // dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
2491
+ seenEvents = /* @__PURE__ */ new Set();
2492
+ // events parked because target/author or sender was unresolved at ingest time
2493
+ held = [];
2494
+ // targets that have had ≥1 valid edit applied (write-once)
2495
+ editedTargets = /* @__PURE__ */ new Set();
2496
+ /**
2497
+ * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2498
+ * (null = target unknown/dangling → HOLD).
2499
+ */
2500
+ ingest(e, authorOfTarget) {
2501
+ const author = authorOfTarget(e.targetClientMsgId);
2502
+ if (author === null) {
2503
+ this.holdIfNew(e);
2504
+ return;
2505
+ }
2506
+ if (e.editorUserId === null) {
2507
+ this.holdIfNew(e);
2508
+ return;
2509
+ }
2510
+ if (e.editorUserId !== author) return;
2511
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
2512
+ this.seenEvents.add(e.eventClientMsgId);
2513
+ const prev = this.states.get(e.targetClientMsgId);
2514
+ if (prev !== void 0) {
2515
+ if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
2516
+ if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
2517
+ return;
2518
+ }
2519
+ }
2520
+ this.states.set(e.targetClientMsgId, {
2521
+ orderEpoch: e.epoch,
2522
+ orderSeq: e.serverSeq,
2523
+ lastEventId: e.eventClientMsgId,
2524
+ text: e.newText
2525
+ });
2526
+ this.editedTargets.add(e.targetClientMsgId);
2527
+ }
2528
+ /**
2529
+ * Park an event for later re-attempt, deduping held re-deliveries by
2530
+ * eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
2531
+ * once (and never double-applies when it finally resolves on reevaluate).
2532
+ */
2533
+ holdIfNew(e) {
2534
+ if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
2535
+ this.held.push(e);
2536
+ }
2537
+ /** The winning edit text for a target, or null if no valid edit has applied. */
2538
+ text(targetClientMsgId) {
2539
+ return this.states.get(targetClientMsgId)?.text ?? null;
2540
+ }
2541
+ /** Write-once: true once any valid edit applied to the target. */
2542
+ isEdited(targetClientMsgId) {
2543
+ return this.editedTargets.has(targetClientMsgId);
2544
+ }
2545
+ /**
2546
+ * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2547
+ * change and when a target message arrives). Clears `held` and re-ingests each
2548
+ * event with the fresh `authorOfTarget` — events that still don't resolve are
2549
+ * simply re-held; events that now resolve fold via the normal LWW path.
2550
+ * Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
2551
+ * `holdIfNew` (still-held events), so reevaluating repeatedly can neither
2552
+ * double-apply nor lose an edit.
2553
+ */
2554
+ reevaluateHeld(authorOfTarget) {
2555
+ const pending = this.held;
2556
+ this.held = [];
2557
+ for (const e of pending) this.ingest(e, authorOfTarget);
2558
+ }
2559
+ };
2560
+
2416
2561
  // src/messaging/util.ts
2417
2562
  function toBase64(bytes) {
2418
2563
  if (typeof Buffer !== "undefined") {
@@ -2552,6 +2697,28 @@ async function listDevices(rt, userId) {
2552
2697
  }
2553
2698
 
2554
2699
  // src/messaging/group-messaging.ts
2700
+ function encodeDelete(args) {
2701
+ return encodeUtf8(
2702
+ JSON.stringify({
2703
+ v: 1,
2704
+ type: "delete",
2705
+ client_msg_id: args.clientMsgId,
2706
+ target_client_msg_id: args.targetClientMsgId,
2707
+ scope: "everyone"
2708
+ })
2709
+ );
2710
+ }
2711
+ function encodeEdit(args) {
2712
+ return encodeUtf8(
2713
+ JSON.stringify({
2714
+ v: 1,
2715
+ type: "edit",
2716
+ client_msg_id: args.clientMsgId,
2717
+ target_client_msg_id: args.targetClientMsgId,
2718
+ new_text: args.newText
2719
+ })
2720
+ );
2721
+ }
2555
2722
  function encodeReaction(args) {
2556
2723
  return encodeUtf8(
2557
2724
  JSON.stringify({
@@ -2578,6 +2745,18 @@ function decodeEnvelope(bytes) {
2578
2745
  const s = decodeUtf8(bytes);
2579
2746
  try {
2580
2747
  const o = JSON.parse(s);
2748
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2749
+ return {
2750
+ type: "delete",
2751
+ text: null,
2752
+ clientMsgId: o.client_msg_id ?? "",
2753
+ replyTo: null,
2754
+ delete: {
2755
+ targetClientMsgId: o.target_client_msg_id ?? "",
2756
+ scope: o.scope ?? "everyone"
2757
+ }
2758
+ };
2759
+ }
2581
2760
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2582
2761
  return {
2583
2762
  type: "reaction",
@@ -2591,6 +2770,18 @@ function decodeEnvelope(bytes) {
2591
2770
  }
2592
2771
  };
2593
2772
  }
2773
+ if (typeof o === "object" && o !== null && o.type === "edit") {
2774
+ return {
2775
+ type: "edit",
2776
+ text: null,
2777
+ clientMsgId: o.client_msg_id ?? "",
2778
+ replyTo: null,
2779
+ edit: {
2780
+ targetClientMsgId: o.target_client_msg_id ?? "",
2781
+ newText: o.new_text ?? ""
2782
+ }
2783
+ };
2784
+ }
2594
2785
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2595
2786
  return {
2596
2787
  type: "text",
@@ -2905,6 +3096,103 @@ var GroupMessaging = class {
2905
3096
  clientMsgId: args.clientMsgId
2906
3097
  };
2907
3098
  }
3099
+ /** Send an edit (edit-by-supersession on a target message). Encrypts a
3100
+ * `type:'edit'` envelope at the current epoch and sends through the SAME MLS
3101
+ * application path as `sendText` (the server stays blind — an edit is just
3102
+ * another application message). Persists the outgoing edit row so it re-folds
3103
+ * onto its target's text after a reload (the own-send half of the reload
3104
+ * parity). NEVER rebases (epoch-bound like any application message). */
3105
+ async sendEdit(group, args) {
3106
+ const plaintext = encodeEdit({
3107
+ clientMsgId: args.clientMsgId,
3108
+ targetClientMsgId: args.targetClientMsgId,
3109
+ newText: args.newText
3110
+ });
3111
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3112
+ const body = {
3113
+ ciphertext_b64: toBase64(ct),
3114
+ client_idem_key: randomId()
3115
+ };
3116
+ const wire = await palbeRequest(
3117
+ this.rt,
3118
+ "POST",
3119
+ MessagingPaths.groupMessages(group.displayId),
3120
+ { body }
3121
+ );
3122
+ const stored = {
3123
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3124
+ direction: "outgoing",
3125
+ text: null,
3126
+ senderDeviceId: this.selfDeviceId,
3127
+ epoch: wire.epoch,
3128
+ serverSeq: wire.server_seq,
3129
+ at: Date.now(),
3130
+ clientMsgId: args.clientMsgId,
3131
+ replyTo: null,
3132
+ envelopeType: "edit",
3133
+ edit: {
3134
+ targetClientMsgId: args.targetClientMsgId,
3135
+ newText: args.newText
3136
+ }
3137
+ };
3138
+ try {
3139
+ await this.messageStore.append(group.rfcGroupId, stored);
3140
+ } catch {
3141
+ }
3142
+ return {
3143
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3144
+ clientMsgId: args.clientMsgId
3145
+ };
3146
+ }
3147
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3148
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3149
+ * application path as `sendText` (the server stays blind — a delete is just
3150
+ * another opaque application message; the original ciphertext row is NOT
3151
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3152
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3153
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3154
+ * rebases (epoch-bound like any application message). */
3155
+ async sendDelete(group, args) {
3156
+ const plaintext = encodeDelete({
3157
+ clientMsgId: args.clientMsgId,
3158
+ targetClientMsgId: args.targetClientMsgId
3159
+ });
3160
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3161
+ const body = {
3162
+ ciphertext_b64: toBase64(ct),
3163
+ client_idem_key: randomId()
3164
+ };
3165
+ const wire = await palbeRequest(
3166
+ this.rt,
3167
+ "POST",
3168
+ MessagingPaths.groupMessages(group.displayId),
3169
+ { body }
3170
+ );
3171
+ const stored = {
3172
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3173
+ direction: "outgoing",
3174
+ text: null,
3175
+ senderDeviceId: this.selfDeviceId,
3176
+ epoch: wire.epoch,
3177
+ serverSeq: wire.server_seq,
3178
+ at: Date.now(),
3179
+ clientMsgId: args.clientMsgId,
3180
+ replyTo: null,
3181
+ envelopeType: "delete",
3182
+ delete: {
3183
+ targetClientMsgId: args.targetClientMsgId,
3184
+ scope: "everyone"
3185
+ }
3186
+ };
3187
+ try {
3188
+ await this.messageStore.append(group.rfcGroupId, stored);
3189
+ } catch {
3190
+ }
3191
+ return {
3192
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3193
+ clientMsgId: args.clientMsgId
3194
+ };
3195
+ }
2908
3196
  // ── The rebase loop ──
2909
3197
  async commitWithRebase(rfcGroupId, build) {
2910
3198
  const gidBytes = fromBase64(rfcGroupId);
@@ -3022,6 +3310,7 @@ var ReactionFold = class {
3022
3310
  };
3023
3311
 
3024
3312
  // src/messaging/chat.ts
3313
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3025
3314
  var Chat = class {
3026
3315
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3027
3316
  id;
@@ -3041,6 +3330,23 @@ var Chat = class {
3041
3330
  byClientMsgId = /* @__PURE__ */ new Map();
3042
3331
  /** The single authoritative reaction fold for this chat (live + own-send + history). */
3043
3332
  reactionFold = new ReactionFold();
3333
+ /** The single authoritative edit fold for this chat (live + own-send + history). */
3334
+ editFold = new EditFold();
3335
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3336
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3337
+ deleteFold = new DeleteFold();
3338
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3339
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3340
+ suppressed = /* @__PURE__ */ new Set();
3341
+ /** True once the persisted suppression set has been loaded (so the omit applies
3342
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3343
+ suppressedLoaded = false;
3344
+ /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3345
+ * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3346
+ originalTextByClientMsgId = /* @__PURE__ */ new Map();
3347
+ /** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
3348
+ * projection time from senderUserId; '' = resolved-but-unknown peer). */
3349
+ authorByClientMsgId = /* @__PURE__ */ new Map();
3044
3350
  loadedEarliestSeq = null;
3045
3351
  historyLoaded = false;
3046
3352
  wired = false;
@@ -3083,7 +3389,7 @@ var Chat = class {
3083
3389
  return this.kind === "direct";
3084
3390
  }
3085
3391
  get messages() {
3086
- return this.messageList;
3392
+ return this.surfaced();
3087
3393
  }
3088
3394
  get members() {
3089
3395
  return this.memberCache;
@@ -3092,13 +3398,49 @@ var Chat = class {
3092
3398
  return this.typingList;
3093
3399
  }
3094
3400
  get lastMessage() {
3095
- return this.messageList.at(-1) ?? null;
3401
+ return this.surfaced().at(-1) ?? null;
3096
3402
  }
3097
3403
  get unreadCount() {
3098
- return this.messageList.filter(
3099
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3404
+ return this.surfaced().filter(
3405
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3100
3406
  ).length;
3101
3407
  }
3408
+ /**
3409
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3410
+ * through it identically). Over the raw `messageList` (which already carries the
3411
+ * folded edit text + reactions + reply):
3412
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3413
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3414
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3415
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3416
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3417
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3418
+ */
3419
+ surfaced() {
3420
+ const out = [];
3421
+ for (const m of this.messageList) {
3422
+ const key = this.suppressionKey(m);
3423
+ if (this.suppressed.has(key)) continue;
3424
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3425
+ if (tombstoned) {
3426
+ out.push({
3427
+ ...m,
3428
+ text: DELETED_DESCRIPTOR,
3429
+ reactions: {},
3430
+ replyTo: null,
3431
+ edited: false,
3432
+ isDeleted: true
3433
+ });
3434
+ continue;
3435
+ }
3436
+ out.push(m);
3437
+ }
3438
+ return out;
3439
+ }
3440
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3441
+ suppressionKey(m) {
3442
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3443
+ }
3102
3444
  get title() {
3103
3445
  if (this.titleOverride) return this.titleOverride;
3104
3446
  if (this._group?.name) return this._group.name;
@@ -3122,9 +3464,28 @@ var Chat = class {
3122
3464
  if (this.wired || this._state !== "active" || !this._group) return;
3123
3465
  this.wired = true;
3124
3466
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3467
+ void this.loadSuppressed();
3125
3468
  void this.hydrateHistory();
3126
3469
  void this.refreshMembers();
3127
3470
  }
3471
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3472
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3473
+ async loadSuppressed() {
3474
+ if (this.suppressedLoaded || !this._group) return;
3475
+ this.suppressedLoaded = true;
3476
+ try {
3477
+ const keys = await this.backend.loadSuppressed(this._group);
3478
+ let changed = false;
3479
+ for (const k of keys) {
3480
+ if (!this.suppressed.has(k)) {
3481
+ this.suppressed.add(k);
3482
+ changed = true;
3483
+ }
3484
+ }
3485
+ if (changed) this.emit();
3486
+ } catch {
3487
+ }
3488
+ }
3128
3489
  async hydrateHistory() {
3129
3490
  if (this.historyLoaded || !this._group) return;
3130
3491
  this.historyLoaded = true;
@@ -3135,19 +3496,26 @@ var Chat = class {
3135
3496
  let changed = false;
3136
3497
  for (const m of incoming) {
3137
3498
  if (m.serverSeq <= 0) continue;
3138
- if (m.clientMsgId && m.text !== null) {
3499
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3139
3500
  this.byClientMsgId.set(m.clientMsgId, {
3140
3501
  text: m.text,
3141
3502
  senderUserId: m.senderUserId ?? ""
3142
3503
  });
3143
3504
  }
3505
+ if (m.clientMsgId && !m.isDeleted) {
3506
+ this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3507
+ }
3144
3508
  }
3509
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3145
3510
  for (const m of incoming) {
3146
3511
  if (m.serverSeq <= 0) continue;
3147
3512
  const key = this.internalKey(m.serverSeq);
3148
3513
  if (this.seenKeys.has(key)) continue;
3149
3514
  this.seenKeys.add(key);
3150
- this.messageList.push(this.applyReactionTally(m));
3515
+ if (m.clientMsgId && !m.isDeleted) {
3516
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3517
+ }
3518
+ this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3151
3519
  changed = true;
3152
3520
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3153
3521
  }
@@ -3188,6 +3556,37 @@ var Chat = class {
3188
3556
  }
3189
3557
  return;
3190
3558
  }
3559
+ if (incoming.envelopeType === "edit" && incoming.edit) {
3560
+ const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3561
+ this.editFold.ingest(
3562
+ {
3563
+ targetClientMsgId: incoming.edit.targetClientMsgId,
3564
+ editorUserId,
3565
+ newText: incoming.edit.newText,
3566
+ epoch: incoming.epoch,
3567
+ serverSeq: incoming.serverSeq,
3568
+ eventClientMsgId: incoming.clientMsgId
3569
+ },
3570
+ this.authorOfTarget
3571
+ );
3572
+ this.recomputeEdit(incoming.edit.targetClientMsgId);
3573
+ return;
3574
+ }
3575
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3576
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3577
+ this.deleteFold.ingest(
3578
+ {
3579
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3580
+ actorUserId,
3581
+ epoch: incoming.epoch,
3582
+ serverSeq: incoming.serverSeq,
3583
+ eventClientMsgId: incoming.clientMsgId
3584
+ },
3585
+ this.authorOfTarget
3586
+ );
3587
+ this.emit();
3588
+ return;
3589
+ }
3191
3590
  const incomingClientMsgId = incoming.clientMsgId;
3192
3591
  const incomingReplyRef = incoming.replyRef;
3193
3592
  let resolvedReplyTo = null;
@@ -3206,7 +3605,11 @@ var Chat = class {
3206
3605
  replyTo: resolvedReplyTo,
3207
3606
  // Attach any tally already folded for this message (a reaction that arrived
3208
3607
  // BEFORE its target — the dangling case — renders the moment the target lands).
3209
- reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
3608
+ reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3609
+ // Default false; applyEditOverlay below folds any edit that arrived first.
3610
+ edited: false,
3611
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3612
+ isDeleted: false
3210
3613
  };
3211
3614
  if (incomingClientMsgId && incoming.text !== null) {
3212
3615
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -3214,7 +3617,12 @@ var Chat = class {
3214
3617
  senderUserId: senderUser ?? ""
3215
3618
  });
3216
3619
  }
3217
- this.messageList.push(msg);
3620
+ if (incomingClientMsgId) {
3621
+ this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3622
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3623
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3624
+ }
3625
+ this.messageList.push(this.applyEditOverlay(msg));
3218
3626
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3219
3627
  this.loadedEarliestSeq = Math.min(
3220
3628
  this.loadedEarliestSeq ?? incoming.serverSeq,
@@ -3222,6 +3630,19 @@ var Chat = class {
3222
3630
  );
3223
3631
  this.emit();
3224
3632
  }
3633
+ /** The EditFold author-gate input: the target message's resolved author userId
3634
+ * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3635
+ * so it can be passed to the pure EditFold. */
3636
+ authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3637
+ /** Seed the per-target base text + author for the edit fold. Base is write-once
3638
+ * (a later own/peer edit must not overwrite the original we render against). The
3639
+ * author is (re)recorded whenever a non-empty resolution is available. */
3640
+ seedEditBase(clientMsgId, text, author) {
3641
+ if (!this.originalTextByClientMsgId.has(clientMsgId)) {
3642
+ this.originalTextByClientMsgId.set(clientMsgId, text);
3643
+ }
3644
+ if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
3645
+ }
3225
3646
  /**
3226
3647
  * Rebuild the target message's `reactions` from the authoritative fold and
3227
3648
  * re-emit. No-op when the target isn't present yet (its tally is attached the
@@ -3251,6 +3672,46 @@ var Chat = class {
3251
3672
  if (sameReactions(m.reactions, tally)) return m;
3252
3673
  return { ...m, reactions: tally };
3253
3674
  }
3675
+ /**
3676
+ * Rebuild the target message's rendered `text` + `edited` flag from the
3677
+ * authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
3678
+ * (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
3679
+ * base`; base is the seeded original so a forged/ignored edit leaves it intact.
3680
+ * No-op when the target isn't present yet (the fold already recorded it; the
3681
+ * overlay applies the moment the target lands) or when unchanged.
3682
+ */
3683
+ recomputeEdit(targetClientMsgId) {
3684
+ if (!targetClientMsgId) return;
3685
+ const editText = this.editFold.text(targetClientMsgId);
3686
+ const foldEdited = this.editFold.isEdited(targetClientMsgId);
3687
+ let changed = false;
3688
+ this.messageList = this.messageList.map((m) => {
3689
+ if (m.clientMsgId !== targetClientMsgId) return m;
3690
+ const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3691
+ const text = editText ?? base;
3692
+ const edited = foldEdited || m.edited;
3693
+ if (m.text === text && m.edited === edited) return m;
3694
+ changed = true;
3695
+ return { ...m, text, edited };
3696
+ });
3697
+ if (changed) this.emit();
3698
+ }
3699
+ /**
3700
+ * Overlay the authoritative edit fold's winning text + flag onto a message as it
3701
+ * is appended/merged. The fold WINS when it has an edit for this target;
3702
+ * otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
3703
+ * history fold) is preserved. PRESERVES reactions + replyTo.
3704
+ */
3705
+ applyEditOverlay(m) {
3706
+ if (!m.clientMsgId) return m;
3707
+ const editText = this.editFold.text(m.clientMsgId);
3708
+ const foldEdited = this.editFold.isEdited(m.clientMsgId);
3709
+ if (editText === null && !foldEdited) return m;
3710
+ const text = editText ?? m.text;
3711
+ const edited = foldEdited || m.edited;
3712
+ if (m.text === text && m.edited === edited) return m;
3713
+ return { ...m, text, edited };
3714
+ }
3254
3715
  /** @internal — called by the backend's conv subscription. */
3255
3716
  applyConv(event, payload) {
3256
3717
  const userId = typeof payload.user_id === "string" ? payload.user_id : null;
@@ -3305,6 +3766,8 @@ var Chat = class {
3305
3766
  this.memberCache = m;
3306
3767
  this.emit();
3307
3768
  }
3769
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3770
+ for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3308
3771
  }
3309
3772
  seedMembersFromGroup(group) {
3310
3773
  const seed = [
@@ -3383,6 +3846,7 @@ var Chat = class {
3383
3846
  this.seenKeys.add(key);
3384
3847
  if (clientMsgId) {
3385
3848
  this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
3849
+ this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
3386
3850
  }
3387
3851
  this.messageList.push({
3388
3852
  id: this.publicId(receipt.serverSeq),
@@ -3396,7 +3860,11 @@ var Chat = class {
3396
3860
  replyTo: resolvedReplyTo,
3397
3861
  // Attach any tally already folded for this own-sent message (rare, but keeps
3398
3862
  // the dangling-target invariant uniform across every append path).
3399
- reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
3863
+ reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3864
+ // Own-sent edits fold via edit() after the fact; new sends start unedited.
3865
+ edited: false,
3866
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
3867
+ isDeleted: false
3400
3868
  });
3401
3869
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3402
3870
  this.emit();
@@ -3477,6 +3945,83 @@ var Chat = class {
3477
3945
  });
3478
3946
  this.recomputeReactions(message.clientMsgId);
3479
3947
  }
3948
+ // ── Edit ──
3949
+ /** Edit an own text message (edit-by-supersession). No-op if the message isn't
3950
+ * editable (empty clientMsgId, or not a `text` kind). The edit folds locally
3951
+ * with the server receipt's `(epoch, serverSeq)` so the target's text updates
3952
+ * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3953
+ * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3954
+ * reactions + reply context. Only the original author's edits count — for an own
3955
+ * message self IS the author, so the author-gate passes. */
3956
+ async edit(message, newText) {
3957
+ if (!message.clientMsgId || message.kind !== "text") return;
3958
+ const group = await this.materializeIfNeeded();
3959
+ const clientMsgId = mintClientMsgId();
3960
+ const { receipt } = await this.backend.sendEdit(group, {
3961
+ clientMsgId,
3962
+ targetClientMsgId: message.clientMsgId,
3963
+ newText
3964
+ });
3965
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3966
+ this.editFold.ingest(
3967
+ {
3968
+ targetClientMsgId: message.clientMsgId,
3969
+ editorUserId: this.backend.selfUserId,
3970
+ newText,
3971
+ epoch: receipt.epoch,
3972
+ serverSeq: receipt.serverSeq,
3973
+ eventClientMsgId: clientMsgId
3974
+ },
3975
+ this.authorOfTarget
3976
+ );
3977
+ this.recomputeEdit(message.clientMsgId);
3978
+ }
3979
+ // ── Delete ──
3980
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
3981
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
3982
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
3983
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
3984
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
3985
+ * server stays blind), folds the own delete locally so the target scrubs in
3986
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
3987
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
3988
+ async deleteForEveryone(message) {
3989
+ if (!message.clientMsgId) return;
3990
+ const group = await this.materializeIfNeeded();
3991
+ const clientMsgId = mintClientMsgId();
3992
+ const { receipt } = await this.backend.sendDelete(group, {
3993
+ clientMsgId,
3994
+ targetClientMsgId: message.clientMsgId
3995
+ });
3996
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3997
+ this.deleteFold.ingest(
3998
+ {
3999
+ targetClientMsgId: message.clientMsgId,
4000
+ actorUserId: this.backend.selfUserId,
4001
+ epoch: receipt.epoch,
4002
+ serverSeq: receipt.serverSeq,
4003
+ eventClientMsgId: clientMsgId
4004
+ },
4005
+ this.authorOfTarget
4006
+ );
4007
+ this.emit();
4008
+ }
4009
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4010
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4011
+ * suppression key persists per chat (survives reload). The key is the message's
4012
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4013
+ async deleteForMe(message) {
4014
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4015
+ if (this.suppressed.has(key)) return;
4016
+ this.suppressed.add(key);
4017
+ this.emit();
4018
+ if (this._group) {
4019
+ try {
4020
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4021
+ } catch {
4022
+ }
4023
+ }
4024
+ }
3480
4025
  };
3481
4026
  function sameReactions(a, b) {
3482
4027
  const ak = Object.keys(a);
@@ -3656,6 +4201,8 @@ var MessageDeliverySource = class {
3656
4201
  const decoded = decodeEnvelope(received.data);
3657
4202
  const { text, clientMsgId, replyTo } = decoded;
3658
4203
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4204
+ const isEdit = decoded.type === "edit" && decoded.edit != null;
4205
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
3659
4206
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3660
4207
  const stored = {
3661
4208
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3683,6 +4230,29 @@ var MessageDeliverySource = class {
3683
4230
  emoji: decoded.reaction.emoji,
3684
4231
  op: decoded.reaction.op
3685
4232
  }
4233
+ } : {},
4234
+ // Thread the edit discriminator + new text through the persisted row so an
4235
+ // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4236
+ // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4237
+ // `'text'`/no-edit (backward-compat).
4238
+ ...isEdit && decoded.edit ? {
4239
+ envelopeType: "edit",
4240
+ edit: {
4241
+ targetClientMsgId: decoded.edit.targetClientMsgId,
4242
+ newText: decoded.edit.newText
4243
+ }
4244
+ } : {},
4245
+ // Thread the delete discriminator + target through the persisted row so a
4246
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4247
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4248
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4249
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4250
+ ...isDelete && decoded.delete ? {
4251
+ envelopeType: "delete",
4252
+ delete: {
4253
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4254
+ scope: decoded.delete.scope
4255
+ }
3686
4256
  } : {}
3687
4257
  };
3688
4258
  try {
@@ -3701,7 +4271,9 @@ var MessageDeliverySource = class {
3701
4271
  clientMsgId,
3702
4272
  replyRef: replyTo,
3703
4273
  envelopeType: decoded.type ?? "text",
3704
- reaction: isReaction ? decoded.reaction : null
4274
+ reaction: isReaction ? decoded.reaction : null,
4275
+ edit: isEdit ? decoded.edit : null,
4276
+ delete: isDelete ? decoded.delete : null
3705
4277
  });
3706
4278
  return true;
3707
4279
  }
@@ -5523,6 +6095,36 @@ var SignatureKeyStore = class {
5523
6095
  }
5524
6096
  };
5525
6097
 
6098
+ // src/messaging/suppression.ts
6099
+ var SuppressionStore = class {
6100
+ constructor(kv) {
6101
+ this.kv = kv;
6102
+ }
6103
+ kv;
6104
+ key(rfcGroupId) {
6105
+ return `supp:${rfcGroupId}`;
6106
+ }
6107
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6108
+ async load(rfcGroupId) {
6109
+ const raw = await this.kv.get(this.key(rfcGroupId));
6110
+ if (!raw) return [];
6111
+ try {
6112
+ const parsed = JSON.parse(decodeUtf8(raw));
6113
+ return Array.isArray(parsed) ? parsed : [];
6114
+ } catch {
6115
+ return [];
6116
+ }
6117
+ }
6118
+ /** Persist the full suppression key set for a chat (deterministic order). */
6119
+ async save(rfcGroupId, keys) {
6120
+ const sorted = [...new Set(keys)].sort();
6121
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6122
+ }
6123
+ async wipe() {
6124
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6125
+ }
6126
+ };
6127
+
5526
6128
  // src/messaging/coordinator.ts
5527
6129
  var MessagingCoordinator = class {
5528
6130
  constructor(rt) {
@@ -5532,6 +6134,7 @@ var MessagingCoordinator = class {
5532
6134
  this.sigStore = new SignatureKeyStore(this.kv);
5533
6135
  this.groupStore = new GroupStateStorage(this.kv);
5534
6136
  this.kpStore = new KeyPackageStorage(this.kv);
6137
+ this.suppressionStore = new SuppressionStore(this.kv);
5535
6138
  this.registry.attachChatList(
5536
6139
  (chats) => {
5537
6140
  this.chatList = chats;
@@ -5546,6 +6149,7 @@ var MessagingCoordinator = class {
5546
6149
  sigStore;
5547
6150
  groupStore;
5548
6151
  kpStore;
6152
+ suppressionStore;
5549
6153
  registry = new GroupRegistry();
5550
6154
  resolved = null;
5551
6155
  resolvePromise = null;
@@ -5709,6 +6313,22 @@ var MessagingCoordinator = class {
5709
6313
  const r = await this.resolve();
5710
6314
  return r.groups.sendReaction(group, args);
5711
6315
  }
6316
+ async sendEdit(group, args) {
6317
+ const r = await this.resolve();
6318
+ return r.groups.sendEdit(group, args);
6319
+ }
6320
+ async sendDelete(group, args) {
6321
+ const r = await this.resolve();
6322
+ return r.groups.sendDelete(group, args);
6323
+ }
6324
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6325
+ loadSuppressed(group) {
6326
+ return this.suppressionStore.load(group.rfcGroupId);
6327
+ }
6328
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6329
+ saveSuppressed(group, keys) {
6330
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6331
+ }
5712
6332
  async history(group, limit, before) {
5713
6333
  const r = await this.resolve();
5714
6334
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -5805,9 +6425,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5805
6425
  eventClientMsgId: s.clientMsgId ?? `${s.id}`
5806
6426
  });
5807
6427
  }
6428
+ const editFold = new EditFold();
6429
+ const deleteFold = new DeleteFold();
6430
+ const authorByClientMsgId = /* @__PURE__ */ new Map();
6431
+ for (const s of rows) {
6432
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6433
+ continue;
6434
+ const cid = s.clientMsgId ?? "";
6435
+ if (!cid) continue;
6436
+ const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6437
+ if (author != null) authorByClientMsgId.set(cid, author);
6438
+ }
6439
+ const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6440
+ for (const s of rows) {
6441
+ if (s.envelopeType !== "edit" || !s.edit) continue;
6442
+ const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6443
+ editFold.ingest(
6444
+ {
6445
+ targetClientMsgId: s.edit.targetClientMsgId,
6446
+ editorUserId: editor,
6447
+ newText: s.edit.newText,
6448
+ epoch: s.epoch,
6449
+ serverSeq: s.serverSeq,
6450
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6451
+ },
6452
+ authorOfTarget
6453
+ );
6454
+ }
6455
+ editFold.reevaluateHeld(authorOfTarget);
6456
+ for (const s of rows) {
6457
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6458
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6459
+ deleteFold.ingest(
6460
+ {
6461
+ targetClientMsgId: s.delete.targetClientMsgId,
6462
+ actorUserId: actor,
6463
+ epoch: s.epoch,
6464
+ serverSeq: s.serverSeq,
6465
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6466
+ },
6467
+ authorOfTarget
6468
+ );
6469
+ }
6470
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
5808
6471
  const lookup = /* @__PURE__ */ new Map();
5809
6472
  for (const s of rows) {
5810
- if (s.envelopeType === "reaction") continue;
6473
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6474
+ continue;
5811
6475
  const cid = s.clientMsgId ?? "";
5812
6476
  if (cid && s.text !== null) {
5813
6477
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -5816,8 +6480,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5816
6480
  }
5817
6481
  const out = [];
5818
6482
  for (const s of rows) {
5819
- if (s.envelopeType === "reaction") continue;
6483
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6484
+ continue;
5820
6485
  const clientMsgId = s.clientMsgId ?? "";
6486
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6487
+ if (isDeleted) {
6488
+ out.push({
6489
+ id: `${displayId}#${s.serverSeq}`,
6490
+ kind: "text",
6491
+ direction: s.direction,
6492
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6493
+ text: DELETED_DESCRIPTOR,
6494
+ serverSeq: s.serverSeq,
6495
+ sentAt: new Date(s.at),
6496
+ clientMsgId,
6497
+ replyTo: null,
6498
+ reactions: {},
6499
+ edited: false,
6500
+ isDeleted: true
6501
+ });
6502
+ continue;
6503
+ }
5821
6504
  let replyTo = null;
5822
6505
  if (s.replyTo) {
5823
6506
  const ref = {
@@ -5832,17 +6515,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5832
6515
  };
5833
6516
  replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
5834
6517
  }
6518
+ const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6519
+ const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
5835
6520
  out.push({
5836
6521
  id: `${displayId}#${s.serverSeq}`,
5837
6522
  kind: s.text != null ? "text" : "system",
5838
6523
  direction: s.direction,
5839
6524
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
5840
- text: s.text,
6525
+ text: editText ?? s.text,
5841
6526
  serverSeq: s.serverSeq,
5842
6527
  sentAt: new Date(s.at),
5843
6528
  clientMsgId,
5844
6529
  replyTo,
5845
- reactions: clientMsgId ? fold.tally(clientMsgId) : {}
6530
+ reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6531
+ edited,
6532
+ isDeleted: false
5846
6533
  });
5847
6534
  }
5848
6535
  return out;
@@ -6580,7 +7267,7 @@ function defaultSessionStorage(key) {
6580
7267
  }
6581
7268
 
6582
7269
  // src/version.ts
6583
- var VERSION = "1.2.1";
7270
+ var VERSION = "1.4.0";
6584
7271
 
6585
7272
  // src/runtime.ts
6586
7273
  function buildRuntime(config) {