@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.
@@ -2628,6 +2628,69 @@ var PalbeFlags = class {
2628
2628
  }
2629
2629
  };
2630
2630
 
2631
+ // src/messaging/delete-fold.ts
2632
+ var DeleteFold = class {
2633
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2634
+ tombstoned = /* @__PURE__ */ new Set();
2635
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2636
+ pending = /* @__PURE__ */ new Map();
2637
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2638
+ seen = /* @__PURE__ */ new Set();
2639
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2640
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2641
+ held = [];
2642
+ /**
2643
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2644
+ * userId (null = target absent locally → defer).
2645
+ */
2646
+ ingest(e, authorOfTarget) {
2647
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2648
+ if (this.seen.has(e.eventClientMsgId)) return;
2649
+ if (this.heldContains(e.eventClientMsgId)) return;
2650
+ const author = authorOfTarget(e.targetClientMsgId);
2651
+ if (author !== null) {
2652
+ this.seen.add(e.eventClientMsgId);
2653
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2654
+ this.tombstoned.add(e.targetClientMsgId);
2655
+ } else if (e.actorUserId !== null) {
2656
+ this.seen.add(e.eventClientMsgId);
2657
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2658
+ } else {
2659
+ this.held.push(e);
2660
+ }
2661
+ }
2662
+ /** True once a valid tombstone has absorbed this target. */
2663
+ isTombstoned(targetClientMsgId) {
2664
+ return this.tombstoned.has(targetClientMsgId);
2665
+ }
2666
+ /**
2667
+ * When a target message newly arrives with a resolved `author`, re-check any
2668
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2669
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2670
+ * the in-order path.
2671
+ */
2672
+ reevaluatePending(target, author) {
2673
+ const actor = this.pending.get(target);
2674
+ if (actor !== void 0) {
2675
+ if (author !== null && actor === author) {
2676
+ this.tombstoned.add(target);
2677
+ this.pending.delete(target);
2678
+ } else if (author !== null) {
2679
+ this.pending.delete(target);
2680
+ }
2681
+ }
2682
+ if (this.held.length === 0) return;
2683
+ const pendingHeld = this.held;
2684
+ this.held = [];
2685
+ for (const e of pendingHeld) {
2686
+ this.ingest(e, (t) => t === target ? author : null);
2687
+ }
2688
+ }
2689
+ heldContains(eventClientMsgId) {
2690
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2691
+ }
2692
+ };
2693
+
2631
2694
  // src/messaging/edit-fold.ts
2632
2695
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2633
2696
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2673,7 +2736,8 @@ var EditFold = class {
2673
2736
  orderEpoch: e.epoch,
2674
2737
  orderSeq: e.serverSeq,
2675
2738
  lastEventId: e.eventClientMsgId,
2676
- text: e.newText
2739
+ text: e.newText,
2740
+ bodyRanges: e.bodyRanges ?? null
2677
2741
  });
2678
2742
  this.editedTargets.add(e.targetClientMsgId);
2679
2743
  }
@@ -2694,6 +2758,15 @@ var EditFold = class {
2694
2758
  isEdited(targetClientMsgId) {
2695
2759
  return this.editedTargets.has(targetClientMsgId);
2696
2760
  }
2761
+ /**
2762
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2763
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2764
+ * normalizes these against the edited text to compute the edited message's mentions
2765
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2766
+ */
2767
+ bodyRanges(targetClientMsgId) {
2768
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2769
+ }
2697
2770
  /**
2698
2771
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2699
2772
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2849,6 +2922,17 @@ async function listDevices(rt, userId) {
2849
2922
  }
2850
2923
 
2851
2924
  // src/messaging/group-messaging.ts
2925
+ function encodeDelete(args) {
2926
+ return encodeUtf8(
2927
+ JSON.stringify({
2928
+ v: 1,
2929
+ type: "delete",
2930
+ client_msg_id: args.clientMsgId,
2931
+ target_client_msg_id: args.targetClientMsgId,
2932
+ scope: "everyone"
2933
+ })
2934
+ );
2935
+ }
2852
2936
  function encodeEdit(args) {
2853
2937
  return encodeUtf8(
2854
2938
  JSON.stringify({
@@ -2856,7 +2940,14 @@ function encodeEdit(args) {
2856
2940
  type: "edit",
2857
2941
  client_msg_id: args.clientMsgId,
2858
2942
  target_client_msg_id: args.targetClientMsgId,
2859
- new_text: args.newText
2943
+ new_text: args.newText,
2944
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2945
+ body_ranges: args.bodyRanges.map((r) => ({
2946
+ start: r.start,
2947
+ length: r.length,
2948
+ mentioned_user_id: r.mentionedUserId
2949
+ }))
2950
+ } : {}
2860
2951
  })
2861
2952
  );
2862
2953
  }
@@ -2878,7 +2969,14 @@ function encodeEnvelope(args) {
2878
2969
  type: "text",
2879
2970
  client_msg_id: args.clientMsgId,
2880
2971
  text: args.text,
2881
- ...args.replyTo ? { reply_to: args.replyTo } : {}
2972
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
2973
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2974
+ body_ranges: args.bodyRanges.map((r) => ({
2975
+ start: r.start,
2976
+ length: r.length,
2977
+ mentioned_user_id: r.mentionedUserId
2978
+ }))
2979
+ } : {}
2882
2980
  };
2883
2981
  return encodeUtf8(JSON.stringify(env));
2884
2982
  }
@@ -2886,6 +2984,18 @@ function decodeEnvelope(bytes) {
2886
2984
  const s = decodeUtf8(bytes);
2887
2985
  try {
2888
2986
  const o = JSON.parse(s);
2987
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2988
+ return {
2989
+ type: "delete",
2990
+ text: null,
2991
+ clientMsgId: o.client_msg_id ?? "",
2992
+ replyTo: null,
2993
+ delete: {
2994
+ targetClientMsgId: o.target_client_msg_id ?? "",
2995
+ scope: o.scope ?? "everyone"
2996
+ }
2997
+ };
2998
+ }
2889
2999
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2890
3000
  return {
2891
3001
  type: "reaction",
@@ -2900,6 +3010,7 @@ function decodeEnvelope(bytes) {
2900
3010
  };
2901
3011
  }
2902
3012
  if (typeof o === "object" && o !== null && o.type === "edit") {
3013
+ const editRanges = decodeBodyRanges(o.body_ranges);
2903
3014
  return {
2904
3015
  type: "edit",
2905
3016
  text: null,
@@ -2908,15 +3019,18 @@ function decodeEnvelope(bytes) {
2908
3019
  edit: {
2909
3020
  targetClientMsgId: o.target_client_msg_id ?? "",
2910
3021
  newText: o.new_text ?? ""
2911
- }
3022
+ },
3023
+ ...editRanges ? { bodyRanges: editRanges } : {}
2912
3024
  };
2913
3025
  }
2914
3026
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
3027
+ const textRanges = decodeBodyRanges(o.body_ranges);
2915
3028
  return {
2916
3029
  type: "text",
2917
3030
  text: o.text ?? null,
2918
3031
  clientMsgId: o.client_msg_id ?? "",
2919
- replyTo: o.reply_to ?? null
3032
+ replyTo: o.reply_to ?? null,
3033
+ ...textRanges ? { bodyRanges: textRanges } : {}
2920
3034
  };
2921
3035
  }
2922
3036
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2928,6 +3042,14 @@ function decodeEnvelope(bytes) {
2928
3042
  }
2929
3043
  return { text: s, clientMsgId: "", replyTo: null };
2930
3044
  }
3045
+ function decodeBodyRanges(raw) {
3046
+ if (!raw || raw.length === 0) return void 0;
3047
+ return raw.map((r) => ({
3048
+ start: r.start,
3049
+ length: r.length,
3050
+ mentionedUserId: r.mentioned_user_id
3051
+ }));
3052
+ }
2931
3053
  function resolveReply(ref, lookup) {
2932
3054
  const parent = lookup(ref.client_msg_id);
2933
3055
  if (parent !== null) {
@@ -3139,9 +3261,9 @@ var GroupMessaging = class {
3139
3261
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3140
3262
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3141
3263
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3142
- async sendText(group, text, replyTo) {
3264
+ async sendText(group, text, replyTo, bodyRanges) {
3143
3265
  const clientMsgId = mintClientMsgId();
3144
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3266
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3145
3267
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3146
3268
  const body = {
3147
3269
  ciphertext_b64: toBase64(ct),
@@ -3167,7 +3289,10 @@ var GroupMessaging = class {
3167
3289
  previewBody: replyTo.preview?.body ?? null,
3168
3290
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3169
3291
  previewKind: replyTo.preview?.kind ?? "text"
3170
- } : null
3292
+ } : null,
3293
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3294
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3295
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3171
3296
  };
3172
3297
  try {
3173
3298
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3235,7 +3360,8 @@ var GroupMessaging = class {
3235
3360
  const plaintext = encodeEdit({
3236
3361
  clientMsgId: args.clientMsgId,
3237
3362
  targetClientMsgId: args.targetClientMsgId,
3238
- newText: args.newText
3363
+ newText: args.newText,
3364
+ bodyRanges: args.bodyRanges
3239
3365
  });
3240
3366
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3241
3367
  const body = {
@@ -3261,7 +3387,59 @@ var GroupMessaging = class {
3261
3387
  envelopeType: "edit",
3262
3388
  edit: {
3263
3389
  targetClientMsgId: args.targetClientMsgId,
3264
- newText: args.newText
3390
+ newText: args.newText,
3391
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3392
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3393
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3394
+ }
3395
+ };
3396
+ try {
3397
+ await this.messageStore.append(group.rfcGroupId, stored);
3398
+ } catch {
3399
+ }
3400
+ return {
3401
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3402
+ clientMsgId: args.clientMsgId
3403
+ };
3404
+ }
3405
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3406
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3407
+ * application path as `sendText` (the server stays blind — a delete is just
3408
+ * another opaque application message; the original ciphertext row is NOT
3409
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3410
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3411
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3412
+ * rebases (epoch-bound like any application message). */
3413
+ async sendDelete(group, args) {
3414
+ const plaintext = encodeDelete({
3415
+ clientMsgId: args.clientMsgId,
3416
+ targetClientMsgId: args.targetClientMsgId
3417
+ });
3418
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3419
+ const body = {
3420
+ ciphertext_b64: toBase64(ct),
3421
+ client_idem_key: randomId()
3422
+ };
3423
+ const wire = await palbeRequest(
3424
+ this.rt,
3425
+ "POST",
3426
+ MessagingPaths.groupMessages(group.displayId),
3427
+ { body }
3428
+ );
3429
+ const stored = {
3430
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3431
+ direction: "outgoing",
3432
+ text: null,
3433
+ senderDeviceId: this.selfDeviceId,
3434
+ epoch: wire.epoch,
3435
+ serverSeq: wire.server_seq,
3436
+ at: Date.now(),
3437
+ clientMsgId: args.clientMsgId,
3438
+ replyTo: null,
3439
+ envelopeType: "delete",
3440
+ delete: {
3441
+ targetClientMsgId: args.targetClientMsgId,
3442
+ scope: "everyone"
3265
3443
  }
3266
3444
  };
3267
3445
  try {
@@ -3334,6 +3512,41 @@ var GroupMessaging = class {
3334
3512
  }
3335
3513
  };
3336
3514
 
3515
+ // src/messaging/mention-ranges.ts
3516
+ function normalizeMentionRangesUtf16(ranges, text) {
3517
+ const n = text.length;
3518
+ function splitsSurrogatePair(index) {
3519
+ if (index <= 0 || index >= n) return false;
3520
+ const before = text.charCodeAt(index - 1);
3521
+ const at = text.charCodeAt(index);
3522
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3523
+ const atIsLow = at >= 56320 && at <= 57343;
3524
+ return beforeIsHigh && atIsLow;
3525
+ }
3526
+ const survivors = [];
3527
+ for (let idx = 0; idx < ranges.length; idx++) {
3528
+ const r = ranges[idx];
3529
+ if (r === void 0) continue;
3530
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3531
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3532
+ survivors.push({ idx, range: r });
3533
+ }
3534
+ survivors.sort((lhs, rhs) => {
3535
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3536
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3537
+ return lhs.idx - rhs.idx;
3538
+ });
3539
+ const kept = [];
3540
+ let prevEnd = Number.NEGATIVE_INFINITY;
3541
+ for (const s of survivors) {
3542
+ if (s.range.start >= prevEnd) {
3543
+ kept.push(s.range);
3544
+ prevEnd = s.range.start + s.range.length;
3545
+ }
3546
+ }
3547
+ return kept;
3548
+ }
3549
+
3337
3550
  // src/messaging/reaction-fold.ts
3338
3551
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3339
3552
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3390,6 +3603,7 @@ var ReactionFold = class {
3390
3603
  };
3391
3604
 
3392
3605
  // src/messaging/chat.ts
3606
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3393
3607
  var Chat = class {
3394
3608
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3395
3609
  id;
@@ -3411,6 +3625,22 @@ var Chat = class {
3411
3625
  reactionFold = new ReactionFold();
3412
3626
  /** The single authoritative edit fold for this chat (live + own-send + history). */
3413
3627
  editFold = new EditFold();
3628
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3629
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3630
+ deleteFold = new DeleteFold();
3631
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3632
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3633
+ suppressed = /* @__PURE__ */ new Set();
3634
+ /** True once the persisted suppression set has been loaded (so the omit applies
3635
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3636
+ suppressedLoaded = false;
3637
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3638
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3639
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3640
+ elevated = /* @__PURE__ */ new Set();
3641
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3642
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3643
+ elevatedLoaded = false;
3414
3644
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3415
3645
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3416
3646
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3422,6 +3652,14 @@ var Chat = class {
3422
3652
  wired = false;
3423
3653
  liveUnsub = null;
3424
3654
  listeners = /* @__PURE__ */ new Set();
3655
+ /**
3656
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3657
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3658
+ * via the persisted elevation set, so this never double-fires for one mention. The
3659
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3660
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3661
+ */
3662
+ onMentionElevation;
3425
3663
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3426
3664
  constructor(args) {
3427
3665
  this.backend = args.backend;
@@ -3459,7 +3697,7 @@ var Chat = class {
3459
3697
  return this.kind === "direct";
3460
3698
  }
3461
3699
  get messages() {
3462
- return this.messageList;
3700
+ return this.surfaced();
3463
3701
  }
3464
3702
  get members() {
3465
3703
  return this.memberCache;
@@ -3468,13 +3706,50 @@ var Chat = class {
3468
3706
  return this.typingList;
3469
3707
  }
3470
3708
  get lastMessage() {
3471
- return this.messageList.at(-1) ?? null;
3709
+ return this.surfaced().at(-1) ?? null;
3472
3710
  }
3473
3711
  get unreadCount() {
3474
- return this.messageList.filter(
3475
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3712
+ return this.surfaced().filter(
3713
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3476
3714
  ).length;
3477
3715
  }
3716
+ /**
3717
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3718
+ * through it identically). Over the raw `messageList` (which already carries the
3719
+ * folded edit text + reactions + reply):
3720
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3721
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3722
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3723
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3724
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3725
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3726
+ */
3727
+ surfaced() {
3728
+ const out = [];
3729
+ for (const m of this.messageList) {
3730
+ const key = this.suppressionKey(m);
3731
+ if (this.suppressed.has(key)) continue;
3732
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3733
+ if (tombstoned) {
3734
+ out.push({
3735
+ ...m,
3736
+ text: DELETED_DESCRIPTOR,
3737
+ reactions: {},
3738
+ replyTo: null,
3739
+ edited: false,
3740
+ isDeleted: true,
3741
+ mentions: []
3742
+ });
3743
+ continue;
3744
+ }
3745
+ out.push(m);
3746
+ }
3747
+ return out;
3748
+ }
3749
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3750
+ suppressionKey(m) {
3751
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3752
+ }
3478
3753
  get title() {
3479
3754
  if (this.titleOverride) return this.titleOverride;
3480
3755
  if (this._group?.name) return this._group.name;
@@ -3498,9 +3773,40 @@ var Chat = class {
3498
3773
  if (this.wired || this._state !== "active" || !this._group) return;
3499
3774
  this.wired = true;
3500
3775
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3776
+ void this.loadSuppressed();
3777
+ void this.loadElevated();
3501
3778
  void this.hydrateHistory();
3502
3779
  void this.refreshMembers();
3503
3780
  }
3781
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3782
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3783
+ async loadSuppressed() {
3784
+ if (this.suppressedLoaded || !this._group) return;
3785
+ this.suppressedLoaded = true;
3786
+ try {
3787
+ const keys = await this.backend.loadSuppressed(this._group);
3788
+ let changed = false;
3789
+ for (const k of keys) {
3790
+ if (!this.suppressed.has(k)) {
3791
+ this.suppressed.add(k);
3792
+ changed = true;
3793
+ }
3794
+ }
3795
+ if (changed) this.emit();
3796
+ } catch {
3797
+ }
3798
+ }
3799
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
3800
+ * gates the elevation DECISION, it does not change what renders. */
3801
+ async loadElevated() {
3802
+ if (this.elevatedLoaded || !this._group) return;
3803
+ this.elevatedLoaded = true;
3804
+ try {
3805
+ const keys = await this.backend.loadElevated(this._group);
3806
+ for (const k of keys) this.elevated.add(k);
3807
+ } catch {
3808
+ }
3809
+ }
3504
3810
  async hydrateHistory() {
3505
3811
  if (this.historyLoaded || !this._group) return;
3506
3812
  this.historyLoaded = true;
@@ -3511,13 +3817,13 @@ var Chat = class {
3511
3817
  let changed = false;
3512
3818
  for (const m of incoming) {
3513
3819
  if (m.serverSeq <= 0) continue;
3514
- if (m.clientMsgId && m.text !== null) {
3820
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3515
3821
  this.byClientMsgId.set(m.clientMsgId, {
3516
3822
  text: m.text,
3517
3823
  senderUserId: m.senderUserId ?? ""
3518
3824
  });
3519
3825
  }
3520
- if (m.clientMsgId) {
3826
+ if (m.clientMsgId && !m.isDeleted) {
3521
3827
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3522
3828
  }
3523
3829
  }
@@ -3527,7 +3833,12 @@ var Chat = class {
3527
3833
  const key = this.internalKey(m.serverSeq);
3528
3834
  if (this.seenKeys.has(key)) continue;
3529
3835
  this.seenKeys.add(key);
3530
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3836
+ if (m.clientMsgId && !m.isDeleted) {
3837
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3838
+ }
3839
+ this.messageList.push(
3840
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3841
+ );
3531
3842
  changed = true;
3532
3843
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3533
3844
  }
@@ -3577,19 +3888,38 @@ var Chat = class {
3577
3888
  newText: incoming.edit.newText,
3578
3889
  epoch: incoming.epoch,
3579
3890
  serverSeq: incoming.serverSeq,
3580
- eventClientMsgId: incoming.clientMsgId
3891
+ eventClientMsgId: incoming.clientMsgId,
3892
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
3893
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
3894
+ bodyRanges: incoming.bodyRanges
3581
3895
  },
3582
3896
  this.authorOfTarget
3583
3897
  );
3584
3898
  this.recomputeEdit(incoming.edit.targetClientMsgId);
3585
3899
  return;
3586
3900
  }
3901
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3902
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3903
+ this.deleteFold.ingest(
3904
+ {
3905
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3906
+ actorUserId,
3907
+ epoch: incoming.epoch,
3908
+ serverSeq: incoming.serverSeq,
3909
+ eventClientMsgId: incoming.clientMsgId
3910
+ },
3911
+ this.authorOfTarget
3912
+ );
3913
+ this.emit();
3914
+ return;
3915
+ }
3587
3916
  const incomingClientMsgId = incoming.clientMsgId;
3588
3917
  const incomingReplyRef = incoming.replyRef;
3589
3918
  let resolvedReplyTo = null;
3590
3919
  if (incomingReplyRef) {
3591
3920
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3592
3921
  }
3922
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3593
3923
  const msg = {
3594
3924
  id: this.publicId(incoming.serverSeq),
3595
3925
  kind: this.kindOf(incoming),
@@ -3604,8 +3934,12 @@ var Chat = class {
3604
3934
  // BEFORE its target — the dangling case — renders the moment the target lands).
3605
3935
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3606
3936
  // Default false; applyEditOverlay below folds any edit that arrived first.
3607
- edited: false
3937
+ edited: false,
3938
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3939
+ isDeleted: false,
3940
+ mentions
3608
3941
  };
3942
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3609
3943
  if (incomingClientMsgId && incoming.text !== null) {
3610
3944
  this.byClientMsgId.set(incomingClientMsgId, {
3611
3945
  text: incoming.text,
@@ -3615,6 +3949,7 @@ var Chat = class {
3615
3949
  if (incomingClientMsgId) {
3616
3950
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3617
3951
  this.editFold.reevaluateHeld(this.authorOfTarget);
3952
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3618
3953
  }
3619
3954
  this.messageList.push(this.applyEditOverlay(msg));
3620
3955
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3628,6 +3963,75 @@ var Chat = class {
3628
3963
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3629
3964
  * so it can be passed to the pure EditFold. */
3630
3965
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3966
+ // ── Mentions (mentions T6) ──
3967
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3968
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
3969
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
3970
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
3971
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
3972
+ resolveMentions(text, bodyRanges) {
3973
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
3974
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
3975
+ if (normalized.length === 0) return [];
3976
+ return normalized.map((r) => ({
3977
+ start: r.start,
3978
+ length: r.length,
3979
+ mentionedUserId: r.mentionedUserId,
3980
+ displayName: this.displayNameOf(r.mentionedUserId)
3981
+ }));
3982
+ }
3983
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
3984
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
3985
+ * A member rename then reflects on old messages. Returns the message unchanged when
3986
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
3987
+ resolveMentionNames(m) {
3988
+ if (!m.mentions || m.mentions.length === 0) {
3989
+ return m.mentions ? m : { ...m, mentions: [] };
3990
+ }
3991
+ let changed = false;
3992
+ const reresolved = m.mentions.map((span) => {
3993
+ const name = this.displayNameOf(span.mentionedUserId);
3994
+ if (name === span.displayName) return span;
3995
+ changed = true;
3996
+ return { ...span, displayName: name };
3997
+ });
3998
+ if (!changed) return m;
3999
+ return { ...m, mentions: reresolved };
4000
+ }
4001
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
4002
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
4003
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
4004
+ editMentions(targetClientMsgId, newText) {
4005
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
4006
+ if (!ranges) return [];
4007
+ return this.resolveMentions(newText, ranges);
4008
+ }
4009
+ /** Resolve a userId → its roster display name (null if not a known member). */
4010
+ displayNameOf(userId) {
4011
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
4012
+ }
4013
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
4014
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
4015
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
4016
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
4017
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
4018
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
4019
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
4020
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
4021
+ const me = this.backend.selfUserId;
4022
+ if (envelopeType === "edit") return;
4023
+ if (senderUserId === me) return;
4024
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
4025
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4026
+ const key = `${me}|${idPart}`;
4027
+ if (this.elevated.has(key)) return;
4028
+ this.elevated.add(key);
4029
+ if (this._group) {
4030
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
4031
+ });
4032
+ }
4033
+ this.onMentionElevation?.(message);
4034
+ }
3631
4035
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3632
4036
  * (a later own/peer edit must not overwrite the original we render against). The
3633
4037
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3684,9 +4088,10 @@ var Chat = class {
3684
4088
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3685
4089
  const text = editText ?? base;
3686
4090
  const edited = foldEdited || m.edited;
3687
- if (m.text === text && m.edited === edited) return m;
4091
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
4092
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3688
4093
  changed = true;
3689
- return { ...m, text, edited };
4094
+ return { ...m, text, edited, mentions };
3690
4095
  });
3691
4096
  if (changed) this.emit();
3692
4097
  }
@@ -3703,8 +4108,9 @@ var Chat = class {
3703
4108
  if (editText === null && !foldEdited) return m;
3704
4109
  const text = editText ?? m.text;
3705
4110
  const edited = foldEdited || m.edited;
3706
- if (m.text === text && m.edited === edited) return m;
3707
- return { ...m, text, edited };
4111
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
4112
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
4113
+ return { ...m, text, edited, mentions };
3708
4114
  }
3709
4115
  /** @internal — called by the backend's conv subscription. */
3710
4116
  applyConv(event, payload) {
@@ -3762,6 +4168,18 @@ var Chat = class {
3762
4168
  }
3763
4169
  this.editFold.reevaluateHeld(this.authorOfTarget);
3764
4170
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
4171
+ this.reresolveAllMentionNames();
4172
+ }
4173
+ /** Re-resolve roster display names across the whole transcript (called on a roster
4174
+ * change). Re-emits only if any name actually changed. */
4175
+ reresolveAllMentionNames() {
4176
+ let changed = false;
4177
+ this.messageList = this.messageList.map((m) => {
4178
+ const reresolved = this.resolveMentionNames(m);
4179
+ if (reresolved !== m) changed = true;
4180
+ return reresolved;
4181
+ });
4182
+ if (changed) this.emit();
3765
4183
  }
3766
4184
  seedMembersFromGroup(group) {
3767
4185
  const seed = [
@@ -3829,11 +4247,12 @@ var Chat = class {
3829
4247
  };
3830
4248
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
3831
4249
  }
3832
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
3833
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4250
+ const bodyRanges = opts?.mentions ?? null;
4251
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4252
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
3834
4253
  return receipt;
3835
4254
  }
3836
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4255
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
3837
4256
  if (receipt.serverSeq <= 0) return;
3838
4257
  const key = this.internalKey(receipt.serverSeq);
3839
4258
  if (this.seenKeys.has(key)) return;
@@ -3856,7 +4275,12 @@ var Chat = class {
3856
4275
  // the dangling-target invariant uniform across every append path).
3857
4276
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3858
4277
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3859
- edited: false
4278
+ edited: false,
4279
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4280
+ isDeleted: false,
4281
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4282
+ // sender never gets a wire echo of its own message — this is the only local copy).
4283
+ mentions: this.resolveMentions(text, bodyRanges)
3860
4284
  });
3861
4285
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3862
4286
  this.emit();
@@ -3944,15 +4368,18 @@ var Chat = class {
3944
4368
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3945
4369
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3946
4370
  * reactions + reply context. Only the original author's edits count — for an own
3947
- * message self IS the author, so the author-gate passes. */
3948
- async edit(message, newText) {
4371
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4372
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4373
+ async edit(message, newText, opts) {
3949
4374
  if (!message.clientMsgId || message.kind !== "text") return;
3950
4375
  const group = await this.materializeIfNeeded();
3951
4376
  const clientMsgId = mintClientMsgId();
4377
+ const bodyRanges = opts?.mentions ?? null;
3952
4378
  const { receipt } = await this.backend.sendEdit(group, {
3953
4379
  clientMsgId,
3954
4380
  targetClientMsgId: message.clientMsgId,
3955
- newText
4381
+ newText,
4382
+ bodyRanges
3956
4383
  });
3957
4384
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3958
4385
  this.editFold.ingest(
@@ -3962,13 +4389,72 @@ var Chat = class {
3962
4389
  newText,
3963
4390
  epoch: receipt.epoch,
3964
4391
  serverSeq: receipt.serverSeq,
3965
- eventClientMsgId: clientMsgId
4392
+ eventClientMsgId: clientMsgId,
4393
+ bodyRanges
3966
4394
  },
3967
4395
  this.authorOfTarget
3968
4396
  );
3969
4397
  this.recomputeEdit(message.clientMsgId);
3970
4398
  }
4399
+ // ── Delete ──
4400
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
4401
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
4402
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
4403
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
4404
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
4405
+ * server stays blind), folds the own delete locally so the target scrubs in
4406
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
4407
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
4408
+ async deleteForEveryone(message) {
4409
+ if (!message.clientMsgId) return;
4410
+ const group = await this.materializeIfNeeded();
4411
+ const clientMsgId = mintClientMsgId();
4412
+ const { receipt } = await this.backend.sendDelete(group, {
4413
+ clientMsgId,
4414
+ targetClientMsgId: message.clientMsgId
4415
+ });
4416
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4417
+ this.deleteFold.ingest(
4418
+ {
4419
+ targetClientMsgId: message.clientMsgId,
4420
+ actorUserId: this.backend.selfUserId,
4421
+ epoch: receipt.epoch,
4422
+ serverSeq: receipt.serverSeq,
4423
+ eventClientMsgId: clientMsgId
4424
+ },
4425
+ this.authorOfTarget
4426
+ );
4427
+ this.emit();
4428
+ }
4429
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4430
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4431
+ * suppression key persists per chat (survives reload). The key is the message's
4432
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4433
+ async deleteForMe(message) {
4434
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4435
+ if (this.suppressed.has(key)) return;
4436
+ this.suppressed.add(key);
4437
+ this.emit();
4438
+ if (this._group) {
4439
+ try {
4440
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4441
+ } catch {
4442
+ }
4443
+ }
4444
+ }
3971
4445
  };
4446
+ function sameMentions(a, b) {
4447
+ if (a.length !== b.length) return false;
4448
+ for (let i = 0; i < a.length; i++) {
4449
+ const x = a[i];
4450
+ const y = b[i];
4451
+ if (!x || !y) return false;
4452
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4453
+ return false;
4454
+ }
4455
+ }
4456
+ return true;
4457
+ }
3972
4458
  function sameReactions(a, b) {
3973
4459
  const ak = Object.keys(a);
3974
4460
  const bk = Object.keys(b);
@@ -4148,6 +4634,7 @@ var MessageDeliverySource = class {
4148
4634
  const { text, clientMsgId, replyTo } = decoded;
4149
4635
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4150
4636
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4637
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
4151
4638
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4152
4639
  const stored = {
4153
4640
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4179,12 +4666,31 @@ var MessageDeliverySource = class {
4179
4666
  // Thread the edit discriminator + new text through the persisted row so an
4180
4667
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4181
4668
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4182
- // `'text'`/no-edit (backward-compat).
4669
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
4670
+ // along so the edited message's mentions re-resolve on cold launch (T6).
4183
4671
  ...isEdit && decoded.edit ? {
4184
4672
  envelopeType: "edit",
4185
4673
  edit: {
4186
4674
  targetClientMsgId: decoded.edit.targetClientMsgId,
4187
- newText: decoded.edit.newText
4675
+ newText: decoded.edit.newText,
4676
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4677
+ }
4678
+ } : {},
4679
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
4680
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
4681
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
4682
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
4683
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4684
+ // Thread the delete discriminator + target through the persisted row so a
4685
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4686
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4687
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4688
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4689
+ ...isDelete && decoded.delete ? {
4690
+ envelopeType: "delete",
4691
+ delete: {
4692
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4693
+ scope: decoded.delete.scope
4188
4694
  }
4189
4695
  } : {}
4190
4696
  };
@@ -4205,7 +4711,11 @@ var MessageDeliverySource = class {
4205
4711
  replyRef: replyTo,
4206
4712
  envelopeType: decoded.type ?? "text",
4207
4713
  reaction: isReaction ? decoded.reaction : null,
4208
- edit: isEdit ? decoded.edit : null
4714
+ edit: isEdit ? decoded.edit : null,
4715
+ delete: isDelete ? decoded.delete : null,
4716
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
4717
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4718
+ bodyRanges: decoded.bodyRanges ?? null
4209
4719
  });
4210
4720
  return true;
4211
4721
  }
@@ -4360,6 +4870,36 @@ var GroupCatalog = class {
4360
4870
  }
4361
4871
  };
4362
4872
 
4873
+ // src/messaging/mention-elevation.ts
4874
+ var MentionElevationStore = class {
4875
+ constructor(kv) {
4876
+ this.kv = kv;
4877
+ }
4878
+ kv;
4879
+ key(rfcGroupId) {
4880
+ return `elev:${rfcGroupId}`;
4881
+ }
4882
+ /** Load the persisted elevation keys for a chat (empty array if none). */
4883
+ async load(rfcGroupId) {
4884
+ const raw = await this.kv.get(this.key(rfcGroupId));
4885
+ if (!raw) return [];
4886
+ try {
4887
+ const parsed = JSON.parse(decodeUtf8(raw));
4888
+ return Array.isArray(parsed) ? parsed : [];
4889
+ } catch {
4890
+ return [];
4891
+ }
4892
+ }
4893
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
4894
+ async save(rfcGroupId, keys) {
4895
+ const sorted = [...new Set(keys)].sort();
4896
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
4897
+ }
4898
+ async wipe() {
4899
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
4900
+ }
4901
+ };
4902
+
4363
4903
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4364
4904
  var palbe_mls_bg_exports = {};
4365
4905
  __export(palbe_mls_bg_exports, {
@@ -6027,6 +6567,36 @@ var SignatureKeyStore = class {
6027
6567
  }
6028
6568
  };
6029
6569
 
6570
+ // src/messaging/suppression.ts
6571
+ var SuppressionStore = class {
6572
+ constructor(kv) {
6573
+ this.kv = kv;
6574
+ }
6575
+ kv;
6576
+ key(rfcGroupId) {
6577
+ return `supp:${rfcGroupId}`;
6578
+ }
6579
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6580
+ async load(rfcGroupId) {
6581
+ const raw = await this.kv.get(this.key(rfcGroupId));
6582
+ if (!raw) return [];
6583
+ try {
6584
+ const parsed = JSON.parse(decodeUtf8(raw));
6585
+ return Array.isArray(parsed) ? parsed : [];
6586
+ } catch {
6587
+ return [];
6588
+ }
6589
+ }
6590
+ /** Persist the full suppression key set for a chat (deterministic order). */
6591
+ async save(rfcGroupId, keys) {
6592
+ const sorted = [...new Set(keys)].sort();
6593
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6594
+ }
6595
+ async wipe() {
6596
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6597
+ }
6598
+ };
6599
+
6030
6600
  // src/messaging/coordinator.ts
6031
6601
  var MessagingCoordinator = class {
6032
6602
  constructor(rt) {
@@ -6036,6 +6606,8 @@ var MessagingCoordinator = class {
6036
6606
  this.sigStore = new SignatureKeyStore(this.kv);
6037
6607
  this.groupStore = new GroupStateStorage(this.kv);
6038
6608
  this.kpStore = new KeyPackageStorage(this.kv);
6609
+ this.suppressionStore = new SuppressionStore(this.kv);
6610
+ this.elevationStore = new MentionElevationStore(this.kv);
6039
6611
  this.registry.attachChatList(
6040
6612
  (chats) => {
6041
6613
  this.chatList = chats;
@@ -6050,6 +6622,8 @@ var MessagingCoordinator = class {
6050
6622
  sigStore;
6051
6623
  groupStore;
6052
6624
  kpStore;
6625
+ suppressionStore;
6626
+ elevationStore;
6053
6627
  registry = new GroupRegistry();
6054
6628
  resolved = null;
6055
6629
  resolvePromise = null;
@@ -6205,9 +6779,9 @@ var MessagingCoordinator = class {
6205
6779
  });
6206
6780
  return group;
6207
6781
  }
6208
- async sendText(group, text, replyTo) {
6782
+ async sendText(group, text, replyTo, bodyRanges) {
6209
6783
  const r = await this.resolve();
6210
- return r.groups.sendText(group, text, replyTo);
6784
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6211
6785
  }
6212
6786
  async sendReaction(group, args) {
6213
6787
  const r = await this.resolve();
@@ -6217,6 +6791,26 @@ var MessagingCoordinator = class {
6217
6791
  const r = await this.resolve();
6218
6792
  return r.groups.sendEdit(group, args);
6219
6793
  }
6794
+ async sendDelete(group, args) {
6795
+ const r = await this.resolve();
6796
+ return r.groups.sendDelete(group, args);
6797
+ }
6798
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6799
+ loadSuppressed(group) {
6800
+ return this.suppressionStore.load(group.rfcGroupId);
6801
+ }
6802
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6803
+ saveSuppressed(group, keys) {
6804
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6805
+ }
6806
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
6807
+ loadElevated(group) {
6808
+ return this.elevationStore.load(group.rfcGroupId);
6809
+ }
6810
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
6811
+ saveElevated(group, keys) {
6812
+ return this.elevationStore.save(group.rfcGroupId, keys);
6813
+ }
6220
6814
  async history(group, limit, before) {
6221
6815
  const r = await this.resolve();
6222
6816
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6314,9 +6908,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6314
6908
  });
6315
6909
  }
6316
6910
  const editFold = new EditFold();
6911
+ const deleteFold = new DeleteFold();
6317
6912
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6318
6913
  for (const s of rows) {
6319
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6914
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6915
+ continue;
6320
6916
  const cid = s.clientMsgId ?? "";
6321
6917
  if (!cid) continue;
6322
6918
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -6333,15 +6929,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6333
6929
  newText: s.edit.newText,
6334
6930
  epoch: s.epoch,
6335
6931
  serverSeq: s.serverSeq,
6336
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
6932
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
6933
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
6934
+ // edit's ranges drive the edited message's mentions on cold launch.
6935
+ bodyRanges: s.edit.bodyRanges ?? null
6337
6936
  },
6338
6937
  authorOfTarget
6339
6938
  );
6340
6939
  }
6341
6940
  editFold.reevaluateHeld(authorOfTarget);
6941
+ for (const s of rows) {
6942
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6943
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6944
+ deleteFold.ingest(
6945
+ {
6946
+ targetClientMsgId: s.delete.targetClientMsgId,
6947
+ actorUserId: actor,
6948
+ epoch: s.epoch,
6949
+ serverSeq: s.serverSeq,
6950
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6951
+ },
6952
+ authorOfTarget
6953
+ );
6954
+ }
6955
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
6342
6956
  const lookup = /* @__PURE__ */ new Map();
6343
6957
  for (const s of rows) {
6344
- if (s.envelopeType === "reaction") continue;
6958
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6959
+ continue;
6345
6960
  const cid = s.clientMsgId ?? "";
6346
6961
  if (cid && s.text !== null) {
6347
6962
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6350,8 +6965,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6350
6965
  }
6351
6966
  const out = [];
6352
6967
  for (const s of rows) {
6353
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6968
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6969
+ continue;
6354
6970
  const clientMsgId = s.clientMsgId ?? "";
6971
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6972
+ if (isDeleted) {
6973
+ out.push({
6974
+ id: `${displayId}#${s.serverSeq}`,
6975
+ kind: "text",
6976
+ direction: s.direction,
6977
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6978
+ text: DELETED_DESCRIPTOR,
6979
+ serverSeq: s.serverSeq,
6980
+ sentAt: new Date(s.at),
6981
+ clientMsgId,
6982
+ replyTo: null,
6983
+ reactions: {},
6984
+ edited: false,
6985
+ isDeleted: true,
6986
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6987
+ mentions: []
6988
+ });
6989
+ continue;
6990
+ }
6355
6991
  let replyTo = null;
6356
6992
  if (s.replyTo) {
6357
6993
  const ref = {
@@ -6368,22 +7004,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6368
7004
  }
6369
7005
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6370
7006
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
7007
+ const text = editText ?? s.text;
7008
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
7009
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6371
7010
  out.push({
6372
7011
  id: `${displayId}#${s.serverSeq}`,
6373
7012
  kind: s.text != null ? "text" : "system",
6374
7013
  direction: s.direction,
6375
7014
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6376
- text: editText ?? s.text,
7015
+ text,
6377
7016
  serverSeq: s.serverSeq,
6378
7017
  sentAt: new Date(s.at),
6379
7018
  clientMsgId,
6380
7019
  replyTo,
6381
7020
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6382
- edited
7021
+ edited,
7022
+ isDeleted: false,
7023
+ mentions
6383
7024
  });
6384
7025
  }
6385
7026
  return out;
6386
7027
  }
7028
+ function normalizeMentionsNullNames(raw, text) {
7029
+ if (text === null || !raw || raw.length === 0) return [];
7030
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
7031
+ start: r.start,
7032
+ length: r.length,
7033
+ mentionedUserId: r.mentionedUserId,
7034
+ displayName: null
7035
+ }));
7036
+ }
6387
7037
 
6388
7038
  // src/messaging/facade.ts
6389
7039
  var PalbeMessaging = class {
@@ -7117,7 +7767,7 @@ function defaultSessionStorage(key) {
7117
7767
  }
7118
7768
 
7119
7769
  // src/version.ts
7120
- var VERSION = "1.3.0";
7770
+ var VERSION = "1.5.0";
7121
7771
 
7122
7772
  // src/runtime.ts
7123
7773
  function buildRuntime(config) {