@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/index.cjs CHANGED
@@ -1607,6 +1607,151 @@ var PalbeFlags = class {
1607
1607
  }
1608
1608
  };
1609
1609
 
1610
+ // src/messaging/delete-fold.ts
1611
+ var DeleteFold = class {
1612
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
1613
+ tombstoned = /* @__PURE__ */ new Set();
1614
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
1615
+ pending = /* @__PURE__ */ new Map();
1616
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
1617
+ seen = /* @__PURE__ */ new Set();
1618
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
1619
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
1620
+ held = [];
1621
+ /**
1622
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
1623
+ * userId (null = target absent locally → defer).
1624
+ */
1625
+ ingest(e, authorOfTarget) {
1626
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
1627
+ if (this.seen.has(e.eventClientMsgId)) return;
1628
+ if (this.heldContains(e.eventClientMsgId)) return;
1629
+ const author = authorOfTarget(e.targetClientMsgId);
1630
+ if (author !== null) {
1631
+ this.seen.add(e.eventClientMsgId);
1632
+ if (e.actorUserId === null || e.actorUserId !== author) return;
1633
+ this.tombstoned.add(e.targetClientMsgId);
1634
+ } else if (e.actorUserId !== null) {
1635
+ this.seen.add(e.eventClientMsgId);
1636
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
1637
+ } else {
1638
+ this.held.push(e);
1639
+ }
1640
+ }
1641
+ /** True once a valid tombstone has absorbed this target. */
1642
+ isTombstoned(targetClientMsgId) {
1643
+ return this.tombstoned.has(targetClientMsgId);
1644
+ }
1645
+ /**
1646
+ * When a target message newly arrives with a resolved `author`, re-check any
1647
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
1648
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
1649
+ * the in-order path.
1650
+ */
1651
+ reevaluatePending(target, author) {
1652
+ const actor = this.pending.get(target);
1653
+ if (actor !== void 0) {
1654
+ if (author !== null && actor === author) {
1655
+ this.tombstoned.add(target);
1656
+ this.pending.delete(target);
1657
+ } else if (author !== null) {
1658
+ this.pending.delete(target);
1659
+ }
1660
+ }
1661
+ if (this.held.length === 0) return;
1662
+ const pendingHeld = this.held;
1663
+ this.held = [];
1664
+ for (const e of pendingHeld) {
1665
+ this.ingest(e, (t) => t === target ? author : null);
1666
+ }
1667
+ }
1668
+ heldContains(eventClientMsgId) {
1669
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
1670
+ }
1671
+ };
1672
+
1673
+ // src/messaging/edit-fold.ts
1674
+ function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
1675
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
1676
+ return aSeq < bSeq;
1677
+ }
1678
+ function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
1679
+ return aEpoch === bEpoch && aSeq === bSeq;
1680
+ }
1681
+ var EditFold = class {
1682
+ // target → winning edit state
1683
+ states = /* @__PURE__ */ new Map();
1684
+ // dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
1685
+ seenEvents = /* @__PURE__ */ new Set();
1686
+ // events parked because target/author or sender was unresolved at ingest time
1687
+ held = [];
1688
+ // targets that have had ≥1 valid edit applied (write-once)
1689
+ editedTargets = /* @__PURE__ */ new Set();
1690
+ /**
1691
+ * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
1692
+ * (null = target unknown/dangling → HOLD).
1693
+ */
1694
+ ingest(e, authorOfTarget) {
1695
+ const author = authorOfTarget(e.targetClientMsgId);
1696
+ if (author === null) {
1697
+ this.holdIfNew(e);
1698
+ return;
1699
+ }
1700
+ if (e.editorUserId === null) {
1701
+ this.holdIfNew(e);
1702
+ return;
1703
+ }
1704
+ if (e.editorUserId !== author) return;
1705
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
1706
+ this.seenEvents.add(e.eventClientMsgId);
1707
+ const prev = this.states.get(e.targetClientMsgId);
1708
+ if (prev !== void 0) {
1709
+ if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
1710
+ if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
1711
+ return;
1712
+ }
1713
+ }
1714
+ this.states.set(e.targetClientMsgId, {
1715
+ orderEpoch: e.epoch,
1716
+ orderSeq: e.serverSeq,
1717
+ lastEventId: e.eventClientMsgId,
1718
+ text: e.newText
1719
+ });
1720
+ this.editedTargets.add(e.targetClientMsgId);
1721
+ }
1722
+ /**
1723
+ * Park an event for later re-attempt, deduping held re-deliveries by
1724
+ * eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
1725
+ * once (and never double-applies when it finally resolves on reevaluate).
1726
+ */
1727
+ holdIfNew(e) {
1728
+ if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
1729
+ this.held.push(e);
1730
+ }
1731
+ /** The winning edit text for a target, or null if no valid edit has applied. */
1732
+ text(targetClientMsgId) {
1733
+ return this.states.get(targetClientMsgId)?.text ?? null;
1734
+ }
1735
+ /** Write-once: true once any valid edit applied to the target. */
1736
+ isEdited(targetClientMsgId) {
1737
+ return this.editedTargets.has(targetClientMsgId);
1738
+ }
1739
+ /**
1740
+ * Re-run HELD edits when the roster/target newly resolves (call on member/roster
1741
+ * change and when a target message arrives). Clears `held` and re-ingests each
1742
+ * event with the fresh `authorOfTarget` — events that still don't resolve are
1743
+ * simply re-held; events that now resolve fold via the normal LWW path.
1744
+ * Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
1745
+ * `holdIfNew` (still-held events), so reevaluating repeatedly can neither
1746
+ * double-apply nor lose an edit.
1747
+ */
1748
+ reevaluateHeld(authorOfTarget) {
1749
+ const pending = this.held;
1750
+ this.held = [];
1751
+ for (const e of pending) this.ingest(e, authorOfTarget);
1752
+ }
1753
+ };
1754
+
1610
1755
  // src/messaging/util.ts
1611
1756
  function toBase64(bytes) {
1612
1757
  if (typeof Buffer !== "undefined") {
@@ -1746,6 +1891,28 @@ async function listDevices(rt, userId) {
1746
1891
  }
1747
1892
 
1748
1893
  // src/messaging/group-messaging.ts
1894
+ function encodeDelete(args) {
1895
+ return encodeUtf8(
1896
+ JSON.stringify({
1897
+ v: 1,
1898
+ type: "delete",
1899
+ client_msg_id: args.clientMsgId,
1900
+ target_client_msg_id: args.targetClientMsgId,
1901
+ scope: "everyone"
1902
+ })
1903
+ );
1904
+ }
1905
+ function encodeEdit(args) {
1906
+ return encodeUtf8(
1907
+ JSON.stringify({
1908
+ v: 1,
1909
+ type: "edit",
1910
+ client_msg_id: args.clientMsgId,
1911
+ target_client_msg_id: args.targetClientMsgId,
1912
+ new_text: args.newText
1913
+ })
1914
+ );
1915
+ }
1749
1916
  function encodeReaction(args) {
1750
1917
  return encodeUtf8(
1751
1918
  JSON.stringify({
@@ -1772,6 +1939,18 @@ function decodeEnvelope(bytes) {
1772
1939
  const s = decodeUtf8(bytes);
1773
1940
  try {
1774
1941
  const o = JSON.parse(s);
1942
+ if (typeof o === "object" && o !== null && o.type === "delete") {
1943
+ return {
1944
+ type: "delete",
1945
+ text: null,
1946
+ clientMsgId: o.client_msg_id ?? "",
1947
+ replyTo: null,
1948
+ delete: {
1949
+ targetClientMsgId: o.target_client_msg_id ?? "",
1950
+ scope: o.scope ?? "everyone"
1951
+ }
1952
+ };
1953
+ }
1775
1954
  if (typeof o === "object" && o !== null && o.type === "reaction") {
1776
1955
  return {
1777
1956
  type: "reaction",
@@ -1785,6 +1964,18 @@ function decodeEnvelope(bytes) {
1785
1964
  }
1786
1965
  };
1787
1966
  }
1967
+ if (typeof o === "object" && o !== null && o.type === "edit") {
1968
+ return {
1969
+ type: "edit",
1970
+ text: null,
1971
+ clientMsgId: o.client_msg_id ?? "",
1972
+ replyTo: null,
1973
+ edit: {
1974
+ targetClientMsgId: o.target_client_msg_id ?? "",
1975
+ newText: o.new_text ?? ""
1976
+ }
1977
+ };
1978
+ }
1788
1979
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
1789
1980
  return {
1790
1981
  type: "text",
@@ -2099,6 +2290,103 @@ var GroupMessaging = class {
2099
2290
  clientMsgId: args.clientMsgId
2100
2291
  };
2101
2292
  }
2293
+ /** Send an edit (edit-by-supersession on a target message). Encrypts a
2294
+ * `type:'edit'` envelope at the current epoch and sends through the SAME MLS
2295
+ * application path as `sendText` (the server stays blind — an edit is just
2296
+ * another application message). Persists the outgoing edit row so it re-folds
2297
+ * onto its target's text after a reload (the own-send half of the reload
2298
+ * parity). NEVER rebases (epoch-bound like any application message). */
2299
+ async sendEdit(group, args) {
2300
+ const plaintext = encodeEdit({
2301
+ clientMsgId: args.clientMsgId,
2302
+ targetClientMsgId: args.targetClientMsgId,
2303
+ newText: args.newText
2304
+ });
2305
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2306
+ const body = {
2307
+ ciphertext_b64: toBase64(ct),
2308
+ client_idem_key: randomId()
2309
+ };
2310
+ const wire = await palbeRequest(
2311
+ this.rt,
2312
+ "POST",
2313
+ MessagingPaths.groupMessages(group.displayId),
2314
+ { body }
2315
+ );
2316
+ const stored = {
2317
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
2318
+ direction: "outgoing",
2319
+ text: null,
2320
+ senderDeviceId: this.selfDeviceId,
2321
+ epoch: wire.epoch,
2322
+ serverSeq: wire.server_seq,
2323
+ at: Date.now(),
2324
+ clientMsgId: args.clientMsgId,
2325
+ replyTo: null,
2326
+ envelopeType: "edit",
2327
+ edit: {
2328
+ targetClientMsgId: args.targetClientMsgId,
2329
+ newText: args.newText
2330
+ }
2331
+ };
2332
+ try {
2333
+ await this.messageStore.append(group.rfcGroupId, stored);
2334
+ } catch {
2335
+ }
2336
+ return {
2337
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
2338
+ clientMsgId: args.clientMsgId
2339
+ };
2340
+ }
2341
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
2342
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
2343
+ * application path as `sendText` (the server stays blind — a delete is just
2344
+ * another opaque application message; the original ciphertext row is NOT
2345
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
2346
+ * target after a reload (the own-send half of the reload parity — the iOS-review
2347
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
2348
+ * rebases (epoch-bound like any application message). */
2349
+ async sendDelete(group, args) {
2350
+ const plaintext = encodeDelete({
2351
+ clientMsgId: args.clientMsgId,
2352
+ targetClientMsgId: args.targetClientMsgId
2353
+ });
2354
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2355
+ const body = {
2356
+ ciphertext_b64: toBase64(ct),
2357
+ client_idem_key: randomId()
2358
+ };
2359
+ const wire = await palbeRequest(
2360
+ this.rt,
2361
+ "POST",
2362
+ MessagingPaths.groupMessages(group.displayId),
2363
+ { body }
2364
+ );
2365
+ const stored = {
2366
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
2367
+ direction: "outgoing",
2368
+ text: null,
2369
+ senderDeviceId: this.selfDeviceId,
2370
+ epoch: wire.epoch,
2371
+ serverSeq: wire.server_seq,
2372
+ at: Date.now(),
2373
+ clientMsgId: args.clientMsgId,
2374
+ replyTo: null,
2375
+ envelopeType: "delete",
2376
+ delete: {
2377
+ targetClientMsgId: args.targetClientMsgId,
2378
+ scope: "everyone"
2379
+ }
2380
+ };
2381
+ try {
2382
+ await this.messageStore.append(group.rfcGroupId, stored);
2383
+ } catch {
2384
+ }
2385
+ return {
2386
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
2387
+ clientMsgId: args.clientMsgId
2388
+ };
2389
+ }
2102
2390
  // ── The rebase loop ──
2103
2391
  async commitWithRebase(rfcGroupId, build) {
2104
2392
  const gidBytes = fromBase64(rfcGroupId);
@@ -2216,6 +2504,7 @@ var ReactionFold = class {
2216
2504
  };
2217
2505
 
2218
2506
  // src/messaging/chat.ts
2507
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
2219
2508
  var Chat = class {
2220
2509
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
2221
2510
  id;
@@ -2235,6 +2524,23 @@ var Chat = class {
2235
2524
  byClientMsgId = /* @__PURE__ */ new Map();
2236
2525
  /** The single authoritative reaction fold for this chat (live + own-send + history). */
2237
2526
  reactionFold = new ReactionFold();
2527
+ /** The single authoritative edit fold for this chat (live + own-send + history). */
2528
+ editFold = new EditFold();
2529
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
2530
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
2531
+ deleteFold = new DeleteFold();
2532
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
2533
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
2534
+ suppressed = /* @__PURE__ */ new Set();
2535
+ /** True once the persisted suppression set has been loaded (so the omit applies
2536
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
2537
+ suppressedLoaded = false;
2538
+ /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
2539
+ * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
2540
+ originalTextByClientMsgId = /* @__PURE__ */ new Map();
2541
+ /** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
2542
+ * projection time from senderUserId; '' = resolved-but-unknown peer). */
2543
+ authorByClientMsgId = /* @__PURE__ */ new Map();
2238
2544
  loadedEarliestSeq = null;
2239
2545
  historyLoaded = false;
2240
2546
  wired = false;
@@ -2277,7 +2583,7 @@ var Chat = class {
2277
2583
  return this.kind === "direct";
2278
2584
  }
2279
2585
  get messages() {
2280
- return this.messageList;
2586
+ return this.surfaced();
2281
2587
  }
2282
2588
  get members() {
2283
2589
  return this.memberCache;
@@ -2286,13 +2592,49 @@ var Chat = class {
2286
2592
  return this.typingList;
2287
2593
  }
2288
2594
  get lastMessage() {
2289
- return this.messageList.at(-1) ?? null;
2595
+ return this.surfaced().at(-1) ?? null;
2290
2596
  }
2291
2597
  get unreadCount() {
2292
- return this.messageList.filter(
2293
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
2598
+ return this.surfaced().filter(
2599
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
2294
2600
  ).length;
2295
2601
  }
2602
+ /**
2603
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
2604
+ * through it identically). Over the raw `messageList` (which already carries the
2605
+ * folded edit text + reactions + reply):
2606
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
2607
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
2608
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
2609
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
2610
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
2611
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
2612
+ */
2613
+ surfaced() {
2614
+ const out = [];
2615
+ for (const m of this.messageList) {
2616
+ const key = this.suppressionKey(m);
2617
+ if (this.suppressed.has(key)) continue;
2618
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
2619
+ if (tombstoned) {
2620
+ out.push({
2621
+ ...m,
2622
+ text: DELETED_DESCRIPTOR,
2623
+ reactions: {},
2624
+ replyTo: null,
2625
+ edited: false,
2626
+ isDeleted: true
2627
+ });
2628
+ continue;
2629
+ }
2630
+ out.push(m);
2631
+ }
2632
+ return out;
2633
+ }
2634
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
2635
+ suppressionKey(m) {
2636
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
2637
+ }
2296
2638
  get title() {
2297
2639
  if (this.titleOverride) return this.titleOverride;
2298
2640
  if (this._group?.name) return this._group.name;
@@ -2316,9 +2658,28 @@ var Chat = class {
2316
2658
  if (this.wired || this._state !== "active" || !this._group) return;
2317
2659
  this.wired = true;
2318
2660
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
2661
+ void this.loadSuppressed();
2319
2662
  void this.hydrateHistory();
2320
2663
  void this.refreshMembers();
2321
2664
  }
2665
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
2666
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
2667
+ async loadSuppressed() {
2668
+ if (this.suppressedLoaded || !this._group) return;
2669
+ this.suppressedLoaded = true;
2670
+ try {
2671
+ const keys = await this.backend.loadSuppressed(this._group);
2672
+ let changed = false;
2673
+ for (const k of keys) {
2674
+ if (!this.suppressed.has(k)) {
2675
+ this.suppressed.add(k);
2676
+ changed = true;
2677
+ }
2678
+ }
2679
+ if (changed) this.emit();
2680
+ } catch {
2681
+ }
2682
+ }
2322
2683
  async hydrateHistory() {
2323
2684
  if (this.historyLoaded || !this._group) return;
2324
2685
  this.historyLoaded = true;
@@ -2329,19 +2690,26 @@ var Chat = class {
2329
2690
  let changed = false;
2330
2691
  for (const m of incoming) {
2331
2692
  if (m.serverSeq <= 0) continue;
2332
- if (m.clientMsgId && m.text !== null) {
2693
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
2333
2694
  this.byClientMsgId.set(m.clientMsgId, {
2334
2695
  text: m.text,
2335
2696
  senderUserId: m.senderUserId ?? ""
2336
2697
  });
2337
2698
  }
2699
+ if (m.clientMsgId && !m.isDeleted) {
2700
+ this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
2701
+ }
2338
2702
  }
2703
+ this.editFold.reevaluateHeld(this.authorOfTarget);
2339
2704
  for (const m of incoming) {
2340
2705
  if (m.serverSeq <= 0) continue;
2341
2706
  const key = this.internalKey(m.serverSeq);
2342
2707
  if (this.seenKeys.has(key)) continue;
2343
2708
  this.seenKeys.add(key);
2344
- this.messageList.push(this.applyReactionTally(m));
2709
+ if (m.clientMsgId && !m.isDeleted) {
2710
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
2711
+ }
2712
+ this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
2345
2713
  changed = true;
2346
2714
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
2347
2715
  }
@@ -2382,6 +2750,37 @@ var Chat = class {
2382
2750
  }
2383
2751
  return;
2384
2752
  }
2753
+ if (incoming.envelopeType === "edit" && incoming.edit) {
2754
+ const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
2755
+ this.editFold.ingest(
2756
+ {
2757
+ targetClientMsgId: incoming.edit.targetClientMsgId,
2758
+ editorUserId,
2759
+ newText: incoming.edit.newText,
2760
+ epoch: incoming.epoch,
2761
+ serverSeq: incoming.serverSeq,
2762
+ eventClientMsgId: incoming.clientMsgId
2763
+ },
2764
+ this.authorOfTarget
2765
+ );
2766
+ this.recomputeEdit(incoming.edit.targetClientMsgId);
2767
+ return;
2768
+ }
2769
+ if (incoming.envelopeType === "delete" && incoming.delete) {
2770
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
2771
+ this.deleteFold.ingest(
2772
+ {
2773
+ targetClientMsgId: incoming.delete.targetClientMsgId,
2774
+ actorUserId,
2775
+ epoch: incoming.epoch,
2776
+ serverSeq: incoming.serverSeq,
2777
+ eventClientMsgId: incoming.clientMsgId
2778
+ },
2779
+ this.authorOfTarget
2780
+ );
2781
+ this.emit();
2782
+ return;
2783
+ }
2385
2784
  const incomingClientMsgId = incoming.clientMsgId;
2386
2785
  const incomingReplyRef = incoming.replyRef;
2387
2786
  let resolvedReplyTo = null;
@@ -2400,7 +2799,11 @@ var Chat = class {
2400
2799
  replyTo: resolvedReplyTo,
2401
2800
  // Attach any tally already folded for this message (a reaction that arrived
2402
2801
  // BEFORE its target — the dangling case — renders the moment the target lands).
2403
- reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
2802
+ reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
2803
+ // Default false; applyEditOverlay below folds any edit that arrived first.
2804
+ edited: false,
2805
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
2806
+ isDeleted: false
2404
2807
  };
2405
2808
  if (incomingClientMsgId && incoming.text !== null) {
2406
2809
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -2408,7 +2811,12 @@ var Chat = class {
2408
2811
  senderUserId: senderUser ?? ""
2409
2812
  });
2410
2813
  }
2411
- this.messageList.push(msg);
2814
+ if (incomingClientMsgId) {
2815
+ this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
2816
+ this.editFold.reevaluateHeld(this.authorOfTarget);
2817
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
2818
+ }
2819
+ this.messageList.push(this.applyEditOverlay(msg));
2412
2820
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
2413
2821
  this.loadedEarliestSeq = Math.min(
2414
2822
  this.loadedEarliestSeq ?? incoming.serverSeq,
@@ -2416,6 +2824,19 @@ var Chat = class {
2416
2824
  );
2417
2825
  this.emit();
2418
2826
  }
2827
+ /** The EditFold author-gate input: the target message's resolved author userId
2828
+ * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
2829
+ * so it can be passed to the pure EditFold. */
2830
+ authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
2831
+ /** Seed the per-target base text + author for the edit fold. Base is write-once
2832
+ * (a later own/peer edit must not overwrite the original we render against). The
2833
+ * author is (re)recorded whenever a non-empty resolution is available. */
2834
+ seedEditBase(clientMsgId, text, author) {
2835
+ if (!this.originalTextByClientMsgId.has(clientMsgId)) {
2836
+ this.originalTextByClientMsgId.set(clientMsgId, text);
2837
+ }
2838
+ if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
2839
+ }
2419
2840
  /**
2420
2841
  * Rebuild the target message's `reactions` from the authoritative fold and
2421
2842
  * re-emit. No-op when the target isn't present yet (its tally is attached the
@@ -2445,6 +2866,46 @@ var Chat = class {
2445
2866
  if (sameReactions(m.reactions, tally)) return m;
2446
2867
  return { ...m, reactions: tally };
2447
2868
  }
2869
+ /**
2870
+ * Rebuild the target message's rendered `text` + `edited` flag from the
2871
+ * authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
2872
+ * (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
2873
+ * base`; base is the seeded original so a forged/ignored edit leaves it intact.
2874
+ * No-op when the target isn't present yet (the fold already recorded it; the
2875
+ * overlay applies the moment the target lands) or when unchanged.
2876
+ */
2877
+ recomputeEdit(targetClientMsgId) {
2878
+ if (!targetClientMsgId) return;
2879
+ const editText = this.editFold.text(targetClientMsgId);
2880
+ const foldEdited = this.editFold.isEdited(targetClientMsgId);
2881
+ let changed = false;
2882
+ this.messageList = this.messageList.map((m) => {
2883
+ if (m.clientMsgId !== targetClientMsgId) return m;
2884
+ const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
2885
+ const text = editText ?? base;
2886
+ const edited = foldEdited || m.edited;
2887
+ if (m.text === text && m.edited === edited) return m;
2888
+ changed = true;
2889
+ return { ...m, text, edited };
2890
+ });
2891
+ if (changed) this.emit();
2892
+ }
2893
+ /**
2894
+ * Overlay the authoritative edit fold's winning text + flag onto a message as it
2895
+ * is appended/merged. The fold WINS when it has an edit for this target;
2896
+ * otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
2897
+ * history fold) is preserved. PRESERVES reactions + replyTo.
2898
+ */
2899
+ applyEditOverlay(m) {
2900
+ if (!m.clientMsgId) return m;
2901
+ const editText = this.editFold.text(m.clientMsgId);
2902
+ const foldEdited = this.editFold.isEdited(m.clientMsgId);
2903
+ if (editText === null && !foldEdited) return m;
2904
+ const text = editText ?? m.text;
2905
+ const edited = foldEdited || m.edited;
2906
+ if (m.text === text && m.edited === edited) return m;
2907
+ return { ...m, text, edited };
2908
+ }
2448
2909
  /** @internal — called by the backend's conv subscription. */
2449
2910
  applyConv(event, payload) {
2450
2911
  const userId = typeof payload.user_id === "string" ? payload.user_id : null;
@@ -2499,6 +2960,8 @@ var Chat = class {
2499
2960
  this.memberCache = m;
2500
2961
  this.emit();
2501
2962
  }
2963
+ this.editFold.reevaluateHeld(this.authorOfTarget);
2964
+ for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
2502
2965
  }
2503
2966
  seedMembersFromGroup(group) {
2504
2967
  const seed = [
@@ -2577,6 +3040,7 @@ var Chat = class {
2577
3040
  this.seenKeys.add(key);
2578
3041
  if (clientMsgId) {
2579
3042
  this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
3043
+ this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
2580
3044
  }
2581
3045
  this.messageList.push({
2582
3046
  id: this.publicId(receipt.serverSeq),
@@ -2590,7 +3054,11 @@ var Chat = class {
2590
3054
  replyTo: resolvedReplyTo,
2591
3055
  // Attach any tally already folded for this own-sent message (rare, but keeps
2592
3056
  // the dangling-target invariant uniform across every append path).
2593
- reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
3057
+ reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3058
+ // Own-sent edits fold via edit() after the fact; new sends start unedited.
3059
+ edited: false,
3060
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
3061
+ isDeleted: false
2594
3062
  });
2595
3063
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
2596
3064
  this.emit();
@@ -2671,6 +3139,83 @@ var Chat = class {
2671
3139
  });
2672
3140
  this.recomputeReactions(message.clientMsgId);
2673
3141
  }
3142
+ // ── Edit ──
3143
+ /** Edit an own text message (edit-by-supersession). No-op if the message isn't
3144
+ * editable (empty clientMsgId, or not a `text` kind). The edit folds locally
3145
+ * with the server receipt's `(epoch, serverSeq)` so the target's text updates
3146
+ * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3147
+ * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3148
+ * reactions + reply context. Only the original author's edits count — for an own
3149
+ * message self IS the author, so the author-gate passes. */
3150
+ async edit(message, newText) {
3151
+ if (!message.clientMsgId || message.kind !== "text") return;
3152
+ const group = await this.materializeIfNeeded();
3153
+ const clientMsgId = mintClientMsgId();
3154
+ const { receipt } = await this.backend.sendEdit(group, {
3155
+ clientMsgId,
3156
+ targetClientMsgId: message.clientMsgId,
3157
+ newText
3158
+ });
3159
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3160
+ this.editFold.ingest(
3161
+ {
3162
+ targetClientMsgId: message.clientMsgId,
3163
+ editorUserId: this.backend.selfUserId,
3164
+ newText,
3165
+ epoch: receipt.epoch,
3166
+ serverSeq: receipt.serverSeq,
3167
+ eventClientMsgId: clientMsgId
3168
+ },
3169
+ this.authorOfTarget
3170
+ );
3171
+ this.recomputeEdit(message.clientMsgId);
3172
+ }
3173
+ // ── Delete ──
3174
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
3175
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
3176
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
3177
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
3178
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
3179
+ * server stays blind), folds the own delete locally so the target scrubs in
3180
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
3181
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
3182
+ async deleteForEveryone(message) {
3183
+ if (!message.clientMsgId) return;
3184
+ const group = await this.materializeIfNeeded();
3185
+ const clientMsgId = mintClientMsgId();
3186
+ const { receipt } = await this.backend.sendDelete(group, {
3187
+ clientMsgId,
3188
+ targetClientMsgId: message.clientMsgId
3189
+ });
3190
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3191
+ this.deleteFold.ingest(
3192
+ {
3193
+ targetClientMsgId: message.clientMsgId,
3194
+ actorUserId: this.backend.selfUserId,
3195
+ epoch: receipt.epoch,
3196
+ serverSeq: receipt.serverSeq,
3197
+ eventClientMsgId: clientMsgId
3198
+ },
3199
+ this.authorOfTarget
3200
+ );
3201
+ this.emit();
3202
+ }
3203
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
3204
+ * attribution, no server contact: the message is OMITTED from THIS view and the
3205
+ * suppression key persists per chat (survives reload). The key is the message's
3206
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
3207
+ async deleteForMe(message) {
3208
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3209
+ if (this.suppressed.has(key)) return;
3210
+ this.suppressed.add(key);
3211
+ this.emit();
3212
+ if (this._group) {
3213
+ try {
3214
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
3215
+ } catch {
3216
+ }
3217
+ }
3218
+ }
2674
3219
  };
2675
3220
  function sameReactions(a, b) {
2676
3221
  const ak = Object.keys(a);
@@ -2850,6 +3395,8 @@ var MessageDeliverySource = class {
2850
3395
  const decoded = decodeEnvelope(received.data);
2851
3396
  const { text, clientMsgId, replyTo } = decoded;
2852
3397
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
3398
+ const isEdit = decoded.type === "edit" && decoded.edit != null;
3399
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
2853
3400
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
2854
3401
  const stored = {
2855
3402
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -2877,6 +3424,29 @@ var MessageDeliverySource = class {
2877
3424
  emoji: decoded.reaction.emoji,
2878
3425
  op: decoded.reaction.op
2879
3426
  }
3427
+ } : {},
3428
+ // Thread the edit discriminator + new text through the persisted row so an
3429
+ // edit folded LIVE re-folds onto its target after a reload (the reload-parity
3430
+ // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
3431
+ // `'text'`/no-edit (backward-compat).
3432
+ ...isEdit && decoded.edit ? {
3433
+ envelopeType: "edit",
3434
+ edit: {
3435
+ targetClientMsgId: decoded.edit.targetClientMsgId,
3436
+ newText: decoded.edit.newText
3437
+ }
3438
+ } : {},
3439
+ // Thread the delete discriminator + target through the persisted row so a
3440
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
3441
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
3442
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
3443
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
3444
+ ...isDelete && decoded.delete ? {
3445
+ envelopeType: "delete",
3446
+ delete: {
3447
+ targetClientMsgId: decoded.delete.targetClientMsgId,
3448
+ scope: decoded.delete.scope
3449
+ }
2880
3450
  } : {}
2881
3451
  };
2882
3452
  try {
@@ -2895,7 +3465,9 @@ var MessageDeliverySource = class {
2895
3465
  clientMsgId,
2896
3466
  replyRef: replyTo,
2897
3467
  envelopeType: decoded.type ?? "text",
2898
- reaction: isReaction ? decoded.reaction : null
3468
+ reaction: isReaction ? decoded.reaction : null,
3469
+ edit: isEdit ? decoded.edit : null,
3470
+ delete: isDelete ? decoded.delete : null
2899
3471
  });
2900
3472
  return true;
2901
3473
  }
@@ -4717,6 +5289,36 @@ var SignatureKeyStore = class {
4717
5289
  }
4718
5290
  };
4719
5291
 
5292
+ // src/messaging/suppression.ts
5293
+ var SuppressionStore = class {
5294
+ constructor(kv) {
5295
+ this.kv = kv;
5296
+ }
5297
+ kv;
5298
+ key(rfcGroupId) {
5299
+ return `supp:${rfcGroupId}`;
5300
+ }
5301
+ /** Load the persisted suppression keys for a chat (empty array if none). */
5302
+ async load(rfcGroupId) {
5303
+ const raw = await this.kv.get(this.key(rfcGroupId));
5304
+ if (!raw) return [];
5305
+ try {
5306
+ const parsed = JSON.parse(decodeUtf8(raw));
5307
+ return Array.isArray(parsed) ? parsed : [];
5308
+ } catch {
5309
+ return [];
5310
+ }
5311
+ }
5312
+ /** Persist the full suppression key set for a chat (deterministic order). */
5313
+ async save(rfcGroupId, keys) {
5314
+ const sorted = [...new Set(keys)].sort();
5315
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
5316
+ }
5317
+ async wipe() {
5318
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
5319
+ }
5320
+ };
5321
+
4720
5322
  // src/messaging/coordinator.ts
4721
5323
  var MessagingCoordinator = class {
4722
5324
  constructor(rt) {
@@ -4726,6 +5328,7 @@ var MessagingCoordinator = class {
4726
5328
  this.sigStore = new SignatureKeyStore(this.kv);
4727
5329
  this.groupStore = new GroupStateStorage(this.kv);
4728
5330
  this.kpStore = new KeyPackageStorage(this.kv);
5331
+ this.suppressionStore = new SuppressionStore(this.kv);
4729
5332
  this.registry.attachChatList(
4730
5333
  (chats) => {
4731
5334
  this.chatList = chats;
@@ -4740,6 +5343,7 @@ var MessagingCoordinator = class {
4740
5343
  sigStore;
4741
5344
  groupStore;
4742
5345
  kpStore;
5346
+ suppressionStore;
4743
5347
  registry = new GroupRegistry();
4744
5348
  resolved = null;
4745
5349
  resolvePromise = null;
@@ -4903,6 +5507,22 @@ var MessagingCoordinator = class {
4903
5507
  const r = await this.resolve();
4904
5508
  return r.groups.sendReaction(group, args);
4905
5509
  }
5510
+ async sendEdit(group, args) {
5511
+ const r = await this.resolve();
5512
+ return r.groups.sendEdit(group, args);
5513
+ }
5514
+ async sendDelete(group, args) {
5515
+ const r = await this.resolve();
5516
+ return r.groups.sendDelete(group, args);
5517
+ }
5518
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
5519
+ loadSuppressed(group) {
5520
+ return this.suppressionStore.load(group.rfcGroupId);
5521
+ }
5522
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
5523
+ saveSuppressed(group, keys) {
5524
+ return this.suppressionStore.save(group.rfcGroupId, keys);
5525
+ }
4906
5526
  async history(group, limit, before) {
4907
5527
  const r = await this.resolve();
4908
5528
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -4999,9 +5619,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
4999
5619
  eventClientMsgId: s.clientMsgId ?? `${s.id}`
5000
5620
  });
5001
5621
  }
5622
+ const editFold = new EditFold();
5623
+ const deleteFold = new DeleteFold();
5624
+ const authorByClientMsgId = /* @__PURE__ */ new Map();
5625
+ for (const s of rows) {
5626
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5627
+ continue;
5628
+ const cid = s.clientMsgId ?? "";
5629
+ if (!cid) continue;
5630
+ const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
5631
+ if (author != null) authorByClientMsgId.set(cid, author);
5632
+ }
5633
+ const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
5634
+ for (const s of rows) {
5635
+ if (s.envelopeType !== "edit" || !s.edit) continue;
5636
+ const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5637
+ editFold.ingest(
5638
+ {
5639
+ targetClientMsgId: s.edit.targetClientMsgId,
5640
+ editorUserId: editor,
5641
+ newText: s.edit.newText,
5642
+ epoch: s.epoch,
5643
+ serverSeq: s.serverSeq,
5644
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
5645
+ },
5646
+ authorOfTarget
5647
+ );
5648
+ }
5649
+ editFold.reevaluateHeld(authorOfTarget);
5650
+ for (const s of rows) {
5651
+ if (s.envelopeType !== "delete" || !s.delete) continue;
5652
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5653
+ deleteFold.ingest(
5654
+ {
5655
+ targetClientMsgId: s.delete.targetClientMsgId,
5656
+ actorUserId: actor,
5657
+ epoch: s.epoch,
5658
+ serverSeq: s.serverSeq,
5659
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
5660
+ },
5661
+ authorOfTarget
5662
+ );
5663
+ }
5664
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
5002
5665
  const lookup = /* @__PURE__ */ new Map();
5003
5666
  for (const s of rows) {
5004
- if (s.envelopeType === "reaction") continue;
5667
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5668
+ continue;
5005
5669
  const cid = s.clientMsgId ?? "";
5006
5670
  if (cid && s.text !== null) {
5007
5671
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -5010,8 +5674,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5010
5674
  }
5011
5675
  const out = [];
5012
5676
  for (const s of rows) {
5013
- if (s.envelopeType === "reaction") continue;
5677
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5678
+ continue;
5014
5679
  const clientMsgId = s.clientMsgId ?? "";
5680
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
5681
+ if (isDeleted) {
5682
+ out.push({
5683
+ id: `${displayId}#${s.serverSeq}`,
5684
+ kind: "text",
5685
+ direction: s.direction,
5686
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
5687
+ text: DELETED_DESCRIPTOR,
5688
+ serverSeq: s.serverSeq,
5689
+ sentAt: new Date(s.at),
5690
+ clientMsgId,
5691
+ replyTo: null,
5692
+ reactions: {},
5693
+ edited: false,
5694
+ isDeleted: true
5695
+ });
5696
+ continue;
5697
+ }
5015
5698
  let replyTo = null;
5016
5699
  if (s.replyTo) {
5017
5700
  const ref = {
@@ -5026,17 +5709,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5026
5709
  };
5027
5710
  replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
5028
5711
  }
5712
+ const editText = clientMsgId ? editFold.text(clientMsgId) : null;
5713
+ const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
5029
5714
  out.push({
5030
5715
  id: `${displayId}#${s.serverSeq}`,
5031
5716
  kind: s.text != null ? "text" : "system",
5032
5717
  direction: s.direction,
5033
5718
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
5034
- text: s.text,
5719
+ text: editText ?? s.text,
5035
5720
  serverSeq: s.serverSeq,
5036
5721
  sentAt: new Date(s.at),
5037
5722
  clientMsgId,
5038
5723
  replyTo,
5039
- reactions: clientMsgId ? fold.tally(clientMsgId) : {}
5724
+ reactions: clientMsgId ? fold.tally(clientMsgId) : {},
5725
+ edited,
5726
+ isDeleted: false
5040
5727
  });
5041
5728
  }
5042
5729
  return out;
@@ -5893,7 +6580,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
5893
6580
  }
5894
6581
 
5895
6582
  // src/version.ts
5896
- var VERSION = "1.2.1";
6583
+ var VERSION = "1.4.0";
5897
6584
 
5898
6585
  // src/internal.ts
5899
6586
  function getRuntime() {