@palbase/web 1.3.0 → 1.5.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,69 @@ 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
+
1610
1673
  // src/messaging/edit-fold.ts
1611
1674
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
1612
1675
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -1652,7 +1715,8 @@ var EditFold = class {
1652
1715
  orderEpoch: e.epoch,
1653
1716
  orderSeq: e.serverSeq,
1654
1717
  lastEventId: e.eventClientMsgId,
1655
- text: e.newText
1718
+ text: e.newText,
1719
+ bodyRanges: e.bodyRanges ?? null
1656
1720
  });
1657
1721
  this.editedTargets.add(e.targetClientMsgId);
1658
1722
  }
@@ -1673,6 +1737,15 @@ var EditFold = class {
1673
1737
  isEdited(targetClientMsgId) {
1674
1738
  return this.editedTargets.has(targetClientMsgId);
1675
1739
  }
1740
+ /**
1741
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
1742
+ * or null when no valid edit applied or the winning edit carried none. The Chat
1743
+ * normalizes these against the edited text to compute the edited message's mentions
1744
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
1745
+ */
1746
+ bodyRanges(targetClientMsgId) {
1747
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
1748
+ }
1676
1749
  /**
1677
1750
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
1678
1751
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -1828,6 +1901,17 @@ async function listDevices(rt, userId) {
1828
1901
  }
1829
1902
 
1830
1903
  // src/messaging/group-messaging.ts
1904
+ function encodeDelete(args) {
1905
+ return encodeUtf8(
1906
+ JSON.stringify({
1907
+ v: 1,
1908
+ type: "delete",
1909
+ client_msg_id: args.clientMsgId,
1910
+ target_client_msg_id: args.targetClientMsgId,
1911
+ scope: "everyone"
1912
+ })
1913
+ );
1914
+ }
1831
1915
  function encodeEdit(args) {
1832
1916
  return encodeUtf8(
1833
1917
  JSON.stringify({
@@ -1835,7 +1919,14 @@ function encodeEdit(args) {
1835
1919
  type: "edit",
1836
1920
  client_msg_id: args.clientMsgId,
1837
1921
  target_client_msg_id: args.targetClientMsgId,
1838
- new_text: args.newText
1922
+ new_text: args.newText,
1923
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
1924
+ body_ranges: args.bodyRanges.map((r) => ({
1925
+ start: r.start,
1926
+ length: r.length,
1927
+ mentioned_user_id: r.mentionedUserId
1928
+ }))
1929
+ } : {}
1839
1930
  })
1840
1931
  );
1841
1932
  }
@@ -1857,7 +1948,14 @@ function encodeEnvelope(args) {
1857
1948
  type: "text",
1858
1949
  client_msg_id: args.clientMsgId,
1859
1950
  text: args.text,
1860
- ...args.replyTo ? { reply_to: args.replyTo } : {}
1951
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
1952
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
1953
+ body_ranges: args.bodyRanges.map((r) => ({
1954
+ start: r.start,
1955
+ length: r.length,
1956
+ mentioned_user_id: r.mentionedUserId
1957
+ }))
1958
+ } : {}
1861
1959
  };
1862
1960
  return encodeUtf8(JSON.stringify(env));
1863
1961
  }
@@ -1865,6 +1963,18 @@ function decodeEnvelope(bytes) {
1865
1963
  const s = decodeUtf8(bytes);
1866
1964
  try {
1867
1965
  const o = JSON.parse(s);
1966
+ if (typeof o === "object" && o !== null && o.type === "delete") {
1967
+ return {
1968
+ type: "delete",
1969
+ text: null,
1970
+ clientMsgId: o.client_msg_id ?? "",
1971
+ replyTo: null,
1972
+ delete: {
1973
+ targetClientMsgId: o.target_client_msg_id ?? "",
1974
+ scope: o.scope ?? "everyone"
1975
+ }
1976
+ };
1977
+ }
1868
1978
  if (typeof o === "object" && o !== null && o.type === "reaction") {
1869
1979
  return {
1870
1980
  type: "reaction",
@@ -1879,6 +1989,7 @@ function decodeEnvelope(bytes) {
1879
1989
  };
1880
1990
  }
1881
1991
  if (typeof o === "object" && o !== null && o.type === "edit") {
1992
+ const editRanges = decodeBodyRanges(o.body_ranges);
1882
1993
  return {
1883
1994
  type: "edit",
1884
1995
  text: null,
@@ -1887,15 +1998,18 @@ function decodeEnvelope(bytes) {
1887
1998
  edit: {
1888
1999
  targetClientMsgId: o.target_client_msg_id ?? "",
1889
2000
  newText: o.new_text ?? ""
1890
- }
2001
+ },
2002
+ ...editRanges ? { bodyRanges: editRanges } : {}
1891
2003
  };
1892
2004
  }
1893
2005
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2006
+ const textRanges = decodeBodyRanges(o.body_ranges);
1894
2007
  return {
1895
2008
  type: "text",
1896
2009
  text: o.text ?? null,
1897
2010
  clientMsgId: o.client_msg_id ?? "",
1898
- replyTo: o.reply_to ?? null
2011
+ replyTo: o.reply_to ?? null,
2012
+ ...textRanges ? { bodyRanges: textRanges } : {}
1899
2013
  };
1900
2014
  }
1901
2015
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -1907,6 +2021,14 @@ function decodeEnvelope(bytes) {
1907
2021
  }
1908
2022
  return { text: s, clientMsgId: "", replyTo: null };
1909
2023
  }
2024
+ function decodeBodyRanges(raw) {
2025
+ if (!raw || raw.length === 0) return void 0;
2026
+ return raw.map((r) => ({
2027
+ start: r.start,
2028
+ length: r.length,
2029
+ mentionedUserId: r.mentioned_user_id
2030
+ }));
2031
+ }
1910
2032
  function resolveReply(ref, lookup) {
1911
2033
  const parent = lookup(ref.client_msg_id);
1912
2034
  if (parent !== null) {
@@ -2118,9 +2240,9 @@ var GroupMessaging = class {
2118
2240
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
2119
2241
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
2120
2242
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
2121
- async sendText(group, text, replyTo) {
2243
+ async sendText(group, text, replyTo, bodyRanges) {
2122
2244
  const clientMsgId = mintClientMsgId();
2123
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
2245
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
2124
2246
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2125
2247
  const body = {
2126
2248
  ciphertext_b64: toBase64(ct),
@@ -2146,7 +2268,10 @@ var GroupMessaging = class {
2146
2268
  previewBody: replyTo.preview?.body ?? null,
2147
2269
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
2148
2270
  previewKind: replyTo.preview?.kind ?? "text"
2149
- } : null
2271
+ } : null,
2272
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
2273
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
2274
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
2150
2275
  };
2151
2276
  try {
2152
2277
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -2214,7 +2339,8 @@ var GroupMessaging = class {
2214
2339
  const plaintext = encodeEdit({
2215
2340
  clientMsgId: args.clientMsgId,
2216
2341
  targetClientMsgId: args.targetClientMsgId,
2217
- newText: args.newText
2342
+ newText: args.newText,
2343
+ bodyRanges: args.bodyRanges
2218
2344
  });
2219
2345
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2220
2346
  const body = {
@@ -2240,7 +2366,59 @@ var GroupMessaging = class {
2240
2366
  envelopeType: "edit",
2241
2367
  edit: {
2242
2368
  targetClientMsgId: args.targetClientMsgId,
2243
- newText: args.newText
2369
+ newText: args.newText,
2370
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
2371
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
2372
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
2373
+ }
2374
+ };
2375
+ try {
2376
+ await this.messageStore.append(group.rfcGroupId, stored);
2377
+ } catch {
2378
+ }
2379
+ return {
2380
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
2381
+ clientMsgId: args.clientMsgId
2382
+ };
2383
+ }
2384
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
2385
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
2386
+ * application path as `sendText` (the server stays blind — a delete is just
2387
+ * another opaque application message; the original ciphertext row is NOT
2388
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
2389
+ * target after a reload (the own-send half of the reload parity — the iOS-review
2390
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
2391
+ * rebases (epoch-bound like any application message). */
2392
+ async sendDelete(group, args) {
2393
+ const plaintext = encodeDelete({
2394
+ clientMsgId: args.clientMsgId,
2395
+ targetClientMsgId: args.targetClientMsgId
2396
+ });
2397
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2398
+ const body = {
2399
+ ciphertext_b64: toBase64(ct),
2400
+ client_idem_key: randomId()
2401
+ };
2402
+ const wire = await palbeRequest(
2403
+ this.rt,
2404
+ "POST",
2405
+ MessagingPaths.groupMessages(group.displayId),
2406
+ { body }
2407
+ );
2408
+ const stored = {
2409
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
2410
+ direction: "outgoing",
2411
+ text: null,
2412
+ senderDeviceId: this.selfDeviceId,
2413
+ epoch: wire.epoch,
2414
+ serverSeq: wire.server_seq,
2415
+ at: Date.now(),
2416
+ clientMsgId: args.clientMsgId,
2417
+ replyTo: null,
2418
+ envelopeType: "delete",
2419
+ delete: {
2420
+ targetClientMsgId: args.targetClientMsgId,
2421
+ scope: "everyone"
2244
2422
  }
2245
2423
  };
2246
2424
  try {
@@ -2313,6 +2491,41 @@ var GroupMessaging = class {
2313
2491
  }
2314
2492
  };
2315
2493
 
2494
+ // src/messaging/mention-ranges.ts
2495
+ function normalizeMentionRangesUtf16(ranges, text) {
2496
+ const n = text.length;
2497
+ function splitsSurrogatePair(index) {
2498
+ if (index <= 0 || index >= n) return false;
2499
+ const before = text.charCodeAt(index - 1);
2500
+ const at = text.charCodeAt(index);
2501
+ const beforeIsHigh = before >= 55296 && before <= 56319;
2502
+ const atIsLow = at >= 56320 && at <= 57343;
2503
+ return beforeIsHigh && atIsLow;
2504
+ }
2505
+ const survivors = [];
2506
+ for (let idx = 0; idx < ranges.length; idx++) {
2507
+ const r = ranges[idx];
2508
+ if (r === void 0) continue;
2509
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
2510
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
2511
+ survivors.push({ idx, range: r });
2512
+ }
2513
+ survivors.sort((lhs, rhs) => {
2514
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
2515
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
2516
+ return lhs.idx - rhs.idx;
2517
+ });
2518
+ const kept = [];
2519
+ let prevEnd = Number.NEGATIVE_INFINITY;
2520
+ for (const s of survivors) {
2521
+ if (s.range.start >= prevEnd) {
2522
+ kept.push(s.range);
2523
+ prevEnd = s.range.start + s.range.length;
2524
+ }
2525
+ }
2526
+ return kept;
2527
+ }
2528
+
2316
2529
  // src/messaging/reaction-fold.ts
2317
2530
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
2318
2531
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2369,6 +2582,7 @@ var ReactionFold = class {
2369
2582
  };
2370
2583
 
2371
2584
  // src/messaging/chat.ts
2585
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
2372
2586
  var Chat = class {
2373
2587
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
2374
2588
  id;
@@ -2390,6 +2604,22 @@ var Chat = class {
2390
2604
  reactionFold = new ReactionFold();
2391
2605
  /** The single authoritative edit fold for this chat (live + own-send + history). */
2392
2606
  editFold = new EditFold();
2607
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
2608
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
2609
+ deleteFold = new DeleteFold();
2610
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
2611
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
2612
+ suppressed = /* @__PURE__ */ new Set();
2613
+ /** True once the persisted suppression set has been loaded (so the omit applies
2614
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
2615
+ suppressedLoaded = false;
2616
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
2617
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
2618
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
2619
+ elevated = /* @__PURE__ */ new Set();
2620
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
2621
+ * on the cold-launch hydrate path dedups against the persisted decision). */
2622
+ elevatedLoaded = false;
2393
2623
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
2394
2624
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
2395
2625
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -2401,6 +2631,14 @@ var Chat = class {
2401
2631
  wired = false;
2402
2632
  liveUnsub = null;
2403
2633
  listeners = /* @__PURE__ */ new Set();
2634
+ /**
2635
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
2636
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
2637
+ * via the persisted elevation set, so this never double-fires for one mention. The
2638
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
2639
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
2640
+ */
2641
+ onMentionElevation;
2404
2642
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
2405
2643
  constructor(args) {
2406
2644
  this.backend = args.backend;
@@ -2438,7 +2676,7 @@ var Chat = class {
2438
2676
  return this.kind === "direct";
2439
2677
  }
2440
2678
  get messages() {
2441
- return this.messageList;
2679
+ return this.surfaced();
2442
2680
  }
2443
2681
  get members() {
2444
2682
  return this.memberCache;
@@ -2447,13 +2685,50 @@ var Chat = class {
2447
2685
  return this.typingList;
2448
2686
  }
2449
2687
  get lastMessage() {
2450
- return this.messageList.at(-1) ?? null;
2688
+ return this.surfaced().at(-1) ?? null;
2451
2689
  }
2452
2690
  get unreadCount() {
2453
- return this.messageList.filter(
2454
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
2691
+ return this.surfaced().filter(
2692
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
2455
2693
  ).length;
2456
2694
  }
2695
+ /**
2696
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
2697
+ * through it identically). Over the raw `messageList` (which already carries the
2698
+ * folded edit text + reactions + reply):
2699
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
2700
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
2701
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
2702
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
2703
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
2704
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
2705
+ */
2706
+ surfaced() {
2707
+ const out = [];
2708
+ for (const m of this.messageList) {
2709
+ const key = this.suppressionKey(m);
2710
+ if (this.suppressed.has(key)) continue;
2711
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
2712
+ if (tombstoned) {
2713
+ out.push({
2714
+ ...m,
2715
+ text: DELETED_DESCRIPTOR,
2716
+ reactions: {},
2717
+ replyTo: null,
2718
+ edited: false,
2719
+ isDeleted: true,
2720
+ mentions: []
2721
+ });
2722
+ continue;
2723
+ }
2724
+ out.push(m);
2725
+ }
2726
+ return out;
2727
+ }
2728
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
2729
+ suppressionKey(m) {
2730
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
2731
+ }
2457
2732
  get title() {
2458
2733
  if (this.titleOverride) return this.titleOverride;
2459
2734
  if (this._group?.name) return this._group.name;
@@ -2477,9 +2752,40 @@ var Chat = class {
2477
2752
  if (this.wired || this._state !== "active" || !this._group) return;
2478
2753
  this.wired = true;
2479
2754
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
2755
+ void this.loadSuppressed();
2756
+ void this.loadElevated();
2480
2757
  void this.hydrateHistory();
2481
2758
  void this.refreshMembers();
2482
2759
  }
2760
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
2761
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
2762
+ async loadSuppressed() {
2763
+ if (this.suppressedLoaded || !this._group) return;
2764
+ this.suppressedLoaded = true;
2765
+ try {
2766
+ const keys = await this.backend.loadSuppressed(this._group);
2767
+ let changed = false;
2768
+ for (const k of keys) {
2769
+ if (!this.suppressed.has(k)) {
2770
+ this.suppressed.add(k);
2771
+ changed = true;
2772
+ }
2773
+ }
2774
+ if (changed) this.emit();
2775
+ } catch {
2776
+ }
2777
+ }
2778
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
2779
+ * gates the elevation DECISION, it does not change what renders. */
2780
+ async loadElevated() {
2781
+ if (this.elevatedLoaded || !this._group) return;
2782
+ this.elevatedLoaded = true;
2783
+ try {
2784
+ const keys = await this.backend.loadElevated(this._group);
2785
+ for (const k of keys) this.elevated.add(k);
2786
+ } catch {
2787
+ }
2788
+ }
2483
2789
  async hydrateHistory() {
2484
2790
  if (this.historyLoaded || !this._group) return;
2485
2791
  this.historyLoaded = true;
@@ -2490,13 +2796,13 @@ var Chat = class {
2490
2796
  let changed = false;
2491
2797
  for (const m of incoming) {
2492
2798
  if (m.serverSeq <= 0) continue;
2493
- if (m.clientMsgId && m.text !== null) {
2799
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
2494
2800
  this.byClientMsgId.set(m.clientMsgId, {
2495
2801
  text: m.text,
2496
2802
  senderUserId: m.senderUserId ?? ""
2497
2803
  });
2498
2804
  }
2499
- if (m.clientMsgId) {
2805
+ if (m.clientMsgId && !m.isDeleted) {
2500
2806
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
2501
2807
  }
2502
2808
  }
@@ -2506,7 +2812,12 @@ var Chat = class {
2506
2812
  const key = this.internalKey(m.serverSeq);
2507
2813
  if (this.seenKeys.has(key)) continue;
2508
2814
  this.seenKeys.add(key);
2509
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
2815
+ if (m.clientMsgId && !m.isDeleted) {
2816
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
2817
+ }
2818
+ this.messageList.push(
2819
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
2820
+ );
2510
2821
  changed = true;
2511
2822
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
2512
2823
  }
@@ -2556,19 +2867,38 @@ var Chat = class {
2556
2867
  newText: incoming.edit.newText,
2557
2868
  epoch: incoming.epoch,
2558
2869
  serverSeq: incoming.serverSeq,
2559
- eventClientMsgId: incoming.clientMsgId
2870
+ eventClientMsgId: incoming.clientMsgId,
2871
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
2872
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
2873
+ bodyRanges: incoming.bodyRanges
2560
2874
  },
2561
2875
  this.authorOfTarget
2562
2876
  );
2563
2877
  this.recomputeEdit(incoming.edit.targetClientMsgId);
2564
2878
  return;
2565
2879
  }
2880
+ if (incoming.envelopeType === "delete" && incoming.delete) {
2881
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
2882
+ this.deleteFold.ingest(
2883
+ {
2884
+ targetClientMsgId: incoming.delete.targetClientMsgId,
2885
+ actorUserId,
2886
+ epoch: incoming.epoch,
2887
+ serverSeq: incoming.serverSeq,
2888
+ eventClientMsgId: incoming.clientMsgId
2889
+ },
2890
+ this.authorOfTarget
2891
+ );
2892
+ this.emit();
2893
+ return;
2894
+ }
2566
2895
  const incomingClientMsgId = incoming.clientMsgId;
2567
2896
  const incomingReplyRef = incoming.replyRef;
2568
2897
  let resolvedReplyTo = null;
2569
2898
  if (incomingReplyRef) {
2570
2899
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
2571
2900
  }
2901
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
2572
2902
  const msg = {
2573
2903
  id: this.publicId(incoming.serverSeq),
2574
2904
  kind: this.kindOf(incoming),
@@ -2583,8 +2913,12 @@ var Chat = class {
2583
2913
  // BEFORE its target — the dangling case — renders the moment the target lands).
2584
2914
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
2585
2915
  // Default false; applyEditOverlay below folds any edit that arrived first.
2586
- edited: false
2916
+ edited: false,
2917
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
2918
+ isDeleted: false,
2919
+ mentions
2587
2920
  };
2921
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
2588
2922
  if (incomingClientMsgId && incoming.text !== null) {
2589
2923
  this.byClientMsgId.set(incomingClientMsgId, {
2590
2924
  text: incoming.text,
@@ -2594,6 +2928,7 @@ var Chat = class {
2594
2928
  if (incomingClientMsgId) {
2595
2929
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
2596
2930
  this.editFold.reevaluateHeld(this.authorOfTarget);
2931
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
2597
2932
  }
2598
2933
  this.messageList.push(this.applyEditOverlay(msg));
2599
2934
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -2607,6 +2942,75 @@ var Chat = class {
2607
2942
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
2608
2943
  * so it can be passed to the pure EditFold. */
2609
2944
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
2945
+ // ── Mentions (mentions T6) ──
2946
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
2947
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
2948
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
2949
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
2950
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
2951
+ resolveMentions(text, bodyRanges) {
2952
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
2953
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
2954
+ if (normalized.length === 0) return [];
2955
+ return normalized.map((r) => ({
2956
+ start: r.start,
2957
+ length: r.length,
2958
+ mentionedUserId: r.mentionedUserId,
2959
+ displayName: this.displayNameOf(r.mentionedUserId)
2960
+ }));
2961
+ }
2962
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
2963
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
2964
+ * A member rename then reflects on old messages. Returns the message unchanged when
2965
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
2966
+ resolveMentionNames(m) {
2967
+ if (!m.mentions || m.mentions.length === 0) {
2968
+ return m.mentions ? m : { ...m, mentions: [] };
2969
+ }
2970
+ let changed = false;
2971
+ const reresolved = m.mentions.map((span) => {
2972
+ const name = this.displayNameOf(span.mentionedUserId);
2973
+ if (name === span.displayName) return span;
2974
+ changed = true;
2975
+ return { ...span, displayName: name };
2976
+ });
2977
+ if (!changed) return m;
2978
+ return { ...m, mentions: reresolved };
2979
+ }
2980
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
2981
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
2982
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
2983
+ editMentions(targetClientMsgId, newText) {
2984
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
2985
+ if (!ranges) return [];
2986
+ return this.resolveMentions(newText, ranges);
2987
+ }
2988
+ /** Resolve a userId → its roster display name (null if not a known member). */
2989
+ displayNameOf(userId) {
2990
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
2991
+ }
2992
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
2993
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
2994
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
2995
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
2996
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
2997
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
2998
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
2999
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
3000
+ const me = this.backend.selfUserId;
3001
+ if (envelopeType === "edit") return;
3002
+ if (senderUserId === me) return;
3003
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
3004
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3005
+ const key = `${me}|${idPart}`;
3006
+ if (this.elevated.has(key)) return;
3007
+ this.elevated.add(key);
3008
+ if (this._group) {
3009
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
3010
+ });
3011
+ }
3012
+ this.onMentionElevation?.(message);
3013
+ }
2610
3014
  /** Seed the per-target base text + author for the edit fold. Base is write-once
2611
3015
  * (a later own/peer edit must not overwrite the original we render against). The
2612
3016
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -2663,9 +3067,10 @@ var Chat = class {
2663
3067
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
2664
3068
  const text = editText ?? base;
2665
3069
  const edited = foldEdited || m.edited;
2666
- if (m.text === text && m.edited === edited) return m;
3070
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
3071
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
2667
3072
  changed = true;
2668
- return { ...m, text, edited };
3073
+ return { ...m, text, edited, mentions };
2669
3074
  });
2670
3075
  if (changed) this.emit();
2671
3076
  }
@@ -2682,8 +3087,9 @@ var Chat = class {
2682
3087
  if (editText === null && !foldEdited) return m;
2683
3088
  const text = editText ?? m.text;
2684
3089
  const edited = foldEdited || m.edited;
2685
- if (m.text === text && m.edited === edited) return m;
2686
- return { ...m, text, edited };
3090
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
3091
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3092
+ return { ...m, text, edited, mentions };
2687
3093
  }
2688
3094
  /** @internal — called by the backend's conv subscription. */
2689
3095
  applyConv(event, payload) {
@@ -2741,6 +3147,18 @@ var Chat = class {
2741
3147
  }
2742
3148
  this.editFold.reevaluateHeld(this.authorOfTarget);
2743
3149
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3150
+ this.reresolveAllMentionNames();
3151
+ }
3152
+ /** Re-resolve roster display names across the whole transcript (called on a roster
3153
+ * change). Re-emits only if any name actually changed. */
3154
+ reresolveAllMentionNames() {
3155
+ let changed = false;
3156
+ this.messageList = this.messageList.map((m) => {
3157
+ const reresolved = this.resolveMentionNames(m);
3158
+ if (reresolved !== m) changed = true;
3159
+ return reresolved;
3160
+ });
3161
+ if (changed) this.emit();
2744
3162
  }
2745
3163
  seedMembersFromGroup(group) {
2746
3164
  const seed = [
@@ -2808,11 +3226,12 @@ var Chat = class {
2808
3226
  };
2809
3227
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
2810
3228
  }
2811
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
2812
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
3229
+ const bodyRanges = opts?.mentions ?? null;
3230
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
3231
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
2813
3232
  return receipt;
2814
3233
  }
2815
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
3234
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
2816
3235
  if (receipt.serverSeq <= 0) return;
2817
3236
  const key = this.internalKey(receipt.serverSeq);
2818
3237
  if (this.seenKeys.has(key)) return;
@@ -2835,7 +3254,12 @@ var Chat = class {
2835
3254
  // the dangling-target invariant uniform across every append path).
2836
3255
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
2837
3256
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
2838
- edited: false
3257
+ edited: false,
3258
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
3259
+ isDeleted: false,
3260
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
3261
+ // sender never gets a wire echo of its own message — this is the only local copy).
3262
+ mentions: this.resolveMentions(text, bodyRanges)
2839
3263
  });
2840
3264
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
2841
3265
  this.emit();
@@ -2923,15 +3347,18 @@ var Chat = class {
2923
3347
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
2924
3348
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
2925
3349
  * reactions + reply context. Only the original author's edits count — for an own
2926
- * message self IS the author, so the author-gate passes. */
2927
- async edit(message, newText) {
3350
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
3351
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
3352
+ async edit(message, newText, opts) {
2928
3353
  if (!message.clientMsgId || message.kind !== "text") return;
2929
3354
  const group = await this.materializeIfNeeded();
2930
3355
  const clientMsgId = mintClientMsgId();
3356
+ const bodyRanges = opts?.mentions ?? null;
2931
3357
  const { receipt } = await this.backend.sendEdit(group, {
2932
3358
  clientMsgId,
2933
3359
  targetClientMsgId: message.clientMsgId,
2934
- newText
3360
+ newText,
3361
+ bodyRanges
2935
3362
  });
2936
3363
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
2937
3364
  this.editFold.ingest(
@@ -2941,13 +3368,72 @@ var Chat = class {
2941
3368
  newText,
2942
3369
  epoch: receipt.epoch,
2943
3370
  serverSeq: receipt.serverSeq,
2944
- eventClientMsgId: clientMsgId
3371
+ eventClientMsgId: clientMsgId,
3372
+ bodyRanges
2945
3373
  },
2946
3374
  this.authorOfTarget
2947
3375
  );
2948
3376
  this.recomputeEdit(message.clientMsgId);
2949
3377
  }
3378
+ // ── Delete ──
3379
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
3380
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
3381
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
3382
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
3383
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
3384
+ * server stays blind), folds the own delete locally so the target scrubs in
3385
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
3386
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
3387
+ async deleteForEveryone(message) {
3388
+ if (!message.clientMsgId) return;
3389
+ const group = await this.materializeIfNeeded();
3390
+ const clientMsgId = mintClientMsgId();
3391
+ const { receipt } = await this.backend.sendDelete(group, {
3392
+ clientMsgId,
3393
+ targetClientMsgId: message.clientMsgId
3394
+ });
3395
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3396
+ this.deleteFold.ingest(
3397
+ {
3398
+ targetClientMsgId: message.clientMsgId,
3399
+ actorUserId: this.backend.selfUserId,
3400
+ epoch: receipt.epoch,
3401
+ serverSeq: receipt.serverSeq,
3402
+ eventClientMsgId: clientMsgId
3403
+ },
3404
+ this.authorOfTarget
3405
+ );
3406
+ this.emit();
3407
+ }
3408
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
3409
+ * attribution, no server contact: the message is OMITTED from THIS view and the
3410
+ * suppression key persists per chat (survives reload). The key is the message's
3411
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
3412
+ async deleteForMe(message) {
3413
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3414
+ if (this.suppressed.has(key)) return;
3415
+ this.suppressed.add(key);
3416
+ this.emit();
3417
+ if (this._group) {
3418
+ try {
3419
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
3420
+ } catch {
3421
+ }
3422
+ }
3423
+ }
2950
3424
  };
3425
+ function sameMentions(a, b) {
3426
+ if (a.length !== b.length) return false;
3427
+ for (let i = 0; i < a.length; i++) {
3428
+ const x = a[i];
3429
+ const y = b[i];
3430
+ if (!x || !y) return false;
3431
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
3432
+ return false;
3433
+ }
3434
+ }
3435
+ return true;
3436
+ }
2951
3437
  function sameReactions(a, b) {
2952
3438
  const ak = Object.keys(a);
2953
3439
  const bk = Object.keys(b);
@@ -3127,6 +3613,7 @@ var MessageDeliverySource = class {
3127
3613
  const { text, clientMsgId, replyTo } = decoded;
3128
3614
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
3129
3615
  const isEdit = decoded.type === "edit" && decoded.edit != null;
3616
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
3130
3617
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3131
3618
  const stored = {
3132
3619
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3158,12 +3645,31 @@ var MessageDeliverySource = class {
3158
3645
  // Thread the edit discriminator + new text through the persisted row so an
3159
3646
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
3160
3647
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
3161
- // `'text'`/no-edit (backward-compat).
3648
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
3649
+ // along so the edited message's mentions re-resolve on cold launch (T6).
3162
3650
  ...isEdit && decoded.edit ? {
3163
3651
  envelopeType: "edit",
3164
3652
  edit: {
3165
3653
  targetClientMsgId: decoded.edit.targetClientMsgId,
3166
- newText: decoded.edit.newText
3654
+ newText: decoded.edit.newText,
3655
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
3656
+ }
3657
+ } : {},
3658
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
3659
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
3660
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
3661
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
3662
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
3663
+ // Thread the delete discriminator + target through the persisted row so a
3664
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
3665
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
3666
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
3667
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
3668
+ ...isDelete && decoded.delete ? {
3669
+ envelopeType: "delete",
3670
+ delete: {
3671
+ targetClientMsgId: decoded.delete.targetClientMsgId,
3672
+ scope: decoded.delete.scope
3167
3673
  }
3168
3674
  } : {}
3169
3675
  };
@@ -3184,7 +3690,11 @@ var MessageDeliverySource = class {
3184
3690
  replyRef: replyTo,
3185
3691
  envelopeType: decoded.type ?? "text",
3186
3692
  reaction: isReaction ? decoded.reaction : null,
3187
- edit: isEdit ? decoded.edit : null
3693
+ edit: isEdit ? decoded.edit : null,
3694
+ delete: isDelete ? decoded.delete : null,
3695
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
3696
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
3697
+ bodyRanges: decoded.bodyRanges ?? null
3188
3698
  });
3189
3699
  return true;
3190
3700
  }
@@ -3339,6 +3849,36 @@ var GroupCatalog = class {
3339
3849
  }
3340
3850
  };
3341
3851
 
3852
+ // src/messaging/mention-elevation.ts
3853
+ var MentionElevationStore = class {
3854
+ constructor(kv) {
3855
+ this.kv = kv;
3856
+ }
3857
+ kv;
3858
+ key(rfcGroupId) {
3859
+ return `elev:${rfcGroupId}`;
3860
+ }
3861
+ /** Load the persisted elevation keys for a chat (empty array if none). */
3862
+ async load(rfcGroupId) {
3863
+ const raw = await this.kv.get(this.key(rfcGroupId));
3864
+ if (!raw) return [];
3865
+ try {
3866
+ const parsed = JSON.parse(decodeUtf8(raw));
3867
+ return Array.isArray(parsed) ? parsed : [];
3868
+ } catch {
3869
+ return [];
3870
+ }
3871
+ }
3872
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
3873
+ async save(rfcGroupId, keys) {
3874
+ const sorted = [...new Set(keys)].sort();
3875
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
3876
+ }
3877
+ async wipe() {
3878
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
3879
+ }
3880
+ };
3881
+
3342
3882
  // src/messaging/wasm/pkg/palbe_mls_bg.js
3343
3883
  var palbe_mls_bg_exports = {};
3344
3884
  __export(palbe_mls_bg_exports, {
@@ -5006,6 +5546,36 @@ var SignatureKeyStore = class {
5006
5546
  }
5007
5547
  };
5008
5548
 
5549
+ // src/messaging/suppression.ts
5550
+ var SuppressionStore = class {
5551
+ constructor(kv) {
5552
+ this.kv = kv;
5553
+ }
5554
+ kv;
5555
+ key(rfcGroupId) {
5556
+ return `supp:${rfcGroupId}`;
5557
+ }
5558
+ /** Load the persisted suppression keys for a chat (empty array if none). */
5559
+ async load(rfcGroupId) {
5560
+ const raw = await this.kv.get(this.key(rfcGroupId));
5561
+ if (!raw) return [];
5562
+ try {
5563
+ const parsed = JSON.parse(decodeUtf8(raw));
5564
+ return Array.isArray(parsed) ? parsed : [];
5565
+ } catch {
5566
+ return [];
5567
+ }
5568
+ }
5569
+ /** Persist the full suppression key set for a chat (deterministic order). */
5570
+ async save(rfcGroupId, keys) {
5571
+ const sorted = [...new Set(keys)].sort();
5572
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
5573
+ }
5574
+ async wipe() {
5575
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
5576
+ }
5577
+ };
5578
+
5009
5579
  // src/messaging/coordinator.ts
5010
5580
  var MessagingCoordinator = class {
5011
5581
  constructor(rt) {
@@ -5015,6 +5585,8 @@ var MessagingCoordinator = class {
5015
5585
  this.sigStore = new SignatureKeyStore(this.kv);
5016
5586
  this.groupStore = new GroupStateStorage(this.kv);
5017
5587
  this.kpStore = new KeyPackageStorage(this.kv);
5588
+ this.suppressionStore = new SuppressionStore(this.kv);
5589
+ this.elevationStore = new MentionElevationStore(this.kv);
5018
5590
  this.registry.attachChatList(
5019
5591
  (chats) => {
5020
5592
  this.chatList = chats;
@@ -5029,6 +5601,8 @@ var MessagingCoordinator = class {
5029
5601
  sigStore;
5030
5602
  groupStore;
5031
5603
  kpStore;
5604
+ suppressionStore;
5605
+ elevationStore;
5032
5606
  registry = new GroupRegistry();
5033
5607
  resolved = null;
5034
5608
  resolvePromise = null;
@@ -5184,9 +5758,9 @@ var MessagingCoordinator = class {
5184
5758
  });
5185
5759
  return group;
5186
5760
  }
5187
- async sendText(group, text, replyTo) {
5761
+ async sendText(group, text, replyTo, bodyRanges) {
5188
5762
  const r = await this.resolve();
5189
- return r.groups.sendText(group, text, replyTo);
5763
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
5190
5764
  }
5191
5765
  async sendReaction(group, args) {
5192
5766
  const r = await this.resolve();
@@ -5196,6 +5770,26 @@ var MessagingCoordinator = class {
5196
5770
  const r = await this.resolve();
5197
5771
  return r.groups.sendEdit(group, args);
5198
5772
  }
5773
+ async sendDelete(group, args) {
5774
+ const r = await this.resolve();
5775
+ return r.groups.sendDelete(group, args);
5776
+ }
5777
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
5778
+ loadSuppressed(group) {
5779
+ return this.suppressionStore.load(group.rfcGroupId);
5780
+ }
5781
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
5782
+ saveSuppressed(group, keys) {
5783
+ return this.suppressionStore.save(group.rfcGroupId, keys);
5784
+ }
5785
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
5786
+ loadElevated(group) {
5787
+ return this.elevationStore.load(group.rfcGroupId);
5788
+ }
5789
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
5790
+ saveElevated(group, keys) {
5791
+ return this.elevationStore.save(group.rfcGroupId, keys);
5792
+ }
5199
5793
  async history(group, limit, before) {
5200
5794
  const r = await this.resolve();
5201
5795
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -5293,9 +5887,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5293
5887
  });
5294
5888
  }
5295
5889
  const editFold = new EditFold();
5890
+ const deleteFold = new DeleteFold();
5296
5891
  const authorByClientMsgId = /* @__PURE__ */ new Map();
5297
5892
  for (const s of rows) {
5298
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
5893
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5894
+ continue;
5299
5895
  const cid = s.clientMsgId ?? "";
5300
5896
  if (!cid) continue;
5301
5897
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -5312,15 +5908,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5312
5908
  newText: s.edit.newText,
5313
5909
  epoch: s.epoch,
5314
5910
  serverSeq: s.serverSeq,
5315
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
5911
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
5912
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
5913
+ // edit's ranges drive the edited message's mentions on cold launch.
5914
+ bodyRanges: s.edit.bodyRanges ?? null
5316
5915
  },
5317
5916
  authorOfTarget
5318
5917
  );
5319
5918
  }
5320
5919
  editFold.reevaluateHeld(authorOfTarget);
5920
+ for (const s of rows) {
5921
+ if (s.envelopeType !== "delete" || !s.delete) continue;
5922
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5923
+ deleteFold.ingest(
5924
+ {
5925
+ targetClientMsgId: s.delete.targetClientMsgId,
5926
+ actorUserId: actor,
5927
+ epoch: s.epoch,
5928
+ serverSeq: s.serverSeq,
5929
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
5930
+ },
5931
+ authorOfTarget
5932
+ );
5933
+ }
5934
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
5321
5935
  const lookup = /* @__PURE__ */ new Map();
5322
5936
  for (const s of rows) {
5323
- if (s.envelopeType === "reaction") continue;
5937
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5938
+ continue;
5324
5939
  const cid = s.clientMsgId ?? "";
5325
5940
  if (cid && s.text !== null) {
5326
5941
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -5329,8 +5944,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5329
5944
  }
5330
5945
  const out = [];
5331
5946
  for (const s of rows) {
5332
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
5947
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5948
+ continue;
5333
5949
  const clientMsgId = s.clientMsgId ?? "";
5950
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
5951
+ if (isDeleted) {
5952
+ out.push({
5953
+ id: `${displayId}#${s.serverSeq}`,
5954
+ kind: "text",
5955
+ direction: s.direction,
5956
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
5957
+ text: DELETED_DESCRIPTOR,
5958
+ serverSeq: s.serverSeq,
5959
+ sentAt: new Date(s.at),
5960
+ clientMsgId,
5961
+ replyTo: null,
5962
+ reactions: {},
5963
+ edited: false,
5964
+ isDeleted: true,
5965
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
5966
+ mentions: []
5967
+ });
5968
+ continue;
5969
+ }
5334
5970
  let replyTo = null;
5335
5971
  if (s.replyTo) {
5336
5972
  const ref = {
@@ -5347,22 +5983,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5347
5983
  }
5348
5984
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
5349
5985
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
5986
+ const text = editText ?? s.text;
5987
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
5988
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
5350
5989
  out.push({
5351
5990
  id: `${displayId}#${s.serverSeq}`,
5352
5991
  kind: s.text != null ? "text" : "system",
5353
5992
  direction: s.direction,
5354
5993
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
5355
- text: editText ?? s.text,
5994
+ text,
5356
5995
  serverSeq: s.serverSeq,
5357
5996
  sentAt: new Date(s.at),
5358
5997
  clientMsgId,
5359
5998
  replyTo,
5360
5999
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
5361
- edited
6000
+ edited,
6001
+ isDeleted: false,
6002
+ mentions
5362
6003
  });
5363
6004
  }
5364
6005
  return out;
5365
6006
  }
6007
+ function normalizeMentionsNullNames(raw, text) {
6008
+ if (text === null || !raw || raw.length === 0) return [];
6009
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
6010
+ start: r.start,
6011
+ length: r.length,
6012
+ mentionedUserId: r.mentionedUserId,
6013
+ displayName: null
6014
+ }));
6015
+ }
5366
6016
 
5367
6017
  // src/messaging/facade.ts
5368
6018
  var PalbeMessaging = class {
@@ -6215,7 +6865,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
6215
6865
  }
6216
6866
 
6217
6867
  // src/version.ts
6218
- var VERSION = "1.3.0";
6868
+ var VERSION = "1.5.0";
6219
6869
 
6220
6870
  // src/internal.ts
6221
6871
  function getRuntime() {