@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/internal.cjs CHANGED
@@ -2612,6 +2612,69 @@ var PalbeFlags = class {
2612
2612
  }
2613
2613
  };
2614
2614
 
2615
+ // src/messaging/delete-fold.ts
2616
+ var DeleteFold = class {
2617
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2618
+ tombstoned = /* @__PURE__ */ new Set();
2619
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2620
+ pending = /* @__PURE__ */ new Map();
2621
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2622
+ seen = /* @__PURE__ */ new Set();
2623
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2624
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2625
+ held = [];
2626
+ /**
2627
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2628
+ * userId (null = target absent locally → defer).
2629
+ */
2630
+ ingest(e, authorOfTarget) {
2631
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2632
+ if (this.seen.has(e.eventClientMsgId)) return;
2633
+ if (this.heldContains(e.eventClientMsgId)) return;
2634
+ const author = authorOfTarget(e.targetClientMsgId);
2635
+ if (author !== null) {
2636
+ this.seen.add(e.eventClientMsgId);
2637
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2638
+ this.tombstoned.add(e.targetClientMsgId);
2639
+ } else if (e.actorUserId !== null) {
2640
+ this.seen.add(e.eventClientMsgId);
2641
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2642
+ } else {
2643
+ this.held.push(e);
2644
+ }
2645
+ }
2646
+ /** True once a valid tombstone has absorbed this target. */
2647
+ isTombstoned(targetClientMsgId) {
2648
+ return this.tombstoned.has(targetClientMsgId);
2649
+ }
2650
+ /**
2651
+ * When a target message newly arrives with a resolved `author`, re-check any
2652
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2653
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2654
+ * the in-order path.
2655
+ */
2656
+ reevaluatePending(target, author) {
2657
+ const actor = this.pending.get(target);
2658
+ if (actor !== void 0) {
2659
+ if (author !== null && actor === author) {
2660
+ this.tombstoned.add(target);
2661
+ this.pending.delete(target);
2662
+ } else if (author !== null) {
2663
+ this.pending.delete(target);
2664
+ }
2665
+ }
2666
+ if (this.held.length === 0) return;
2667
+ const pendingHeld = this.held;
2668
+ this.held = [];
2669
+ for (const e of pendingHeld) {
2670
+ this.ingest(e, (t) => t === target ? author : null);
2671
+ }
2672
+ }
2673
+ heldContains(eventClientMsgId) {
2674
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2675
+ }
2676
+ };
2677
+
2615
2678
  // src/messaging/edit-fold.ts
2616
2679
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2617
2680
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2657,7 +2720,8 @@ var EditFold = class {
2657
2720
  orderEpoch: e.epoch,
2658
2721
  orderSeq: e.serverSeq,
2659
2722
  lastEventId: e.eventClientMsgId,
2660
- text: e.newText
2723
+ text: e.newText,
2724
+ bodyRanges: e.bodyRanges ?? null
2661
2725
  });
2662
2726
  this.editedTargets.add(e.targetClientMsgId);
2663
2727
  }
@@ -2678,6 +2742,15 @@ var EditFold = class {
2678
2742
  isEdited(targetClientMsgId) {
2679
2743
  return this.editedTargets.has(targetClientMsgId);
2680
2744
  }
2745
+ /**
2746
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2747
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2748
+ * normalizes these against the edited text to compute the edited message's mentions
2749
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2750
+ */
2751
+ bodyRanges(targetClientMsgId) {
2752
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2753
+ }
2681
2754
  /**
2682
2755
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2683
2756
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2833,6 +2906,17 @@ async function listDevices(rt, userId) {
2833
2906
  }
2834
2907
 
2835
2908
  // src/messaging/group-messaging.ts
2909
+ function encodeDelete(args) {
2910
+ return encodeUtf8(
2911
+ JSON.stringify({
2912
+ v: 1,
2913
+ type: "delete",
2914
+ client_msg_id: args.clientMsgId,
2915
+ target_client_msg_id: args.targetClientMsgId,
2916
+ scope: "everyone"
2917
+ })
2918
+ );
2919
+ }
2836
2920
  function encodeEdit(args) {
2837
2921
  return encodeUtf8(
2838
2922
  JSON.stringify({
@@ -2840,7 +2924,14 @@ function encodeEdit(args) {
2840
2924
  type: "edit",
2841
2925
  client_msg_id: args.clientMsgId,
2842
2926
  target_client_msg_id: args.targetClientMsgId,
2843
- new_text: args.newText
2927
+ new_text: args.newText,
2928
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2929
+ body_ranges: args.bodyRanges.map((r) => ({
2930
+ start: r.start,
2931
+ length: r.length,
2932
+ mentioned_user_id: r.mentionedUserId
2933
+ }))
2934
+ } : {}
2844
2935
  })
2845
2936
  );
2846
2937
  }
@@ -2862,7 +2953,14 @@ function encodeEnvelope(args) {
2862
2953
  type: "text",
2863
2954
  client_msg_id: args.clientMsgId,
2864
2955
  text: args.text,
2865
- ...args.replyTo ? { reply_to: args.replyTo } : {}
2956
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
2957
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2958
+ body_ranges: args.bodyRanges.map((r) => ({
2959
+ start: r.start,
2960
+ length: r.length,
2961
+ mentioned_user_id: r.mentionedUserId
2962
+ }))
2963
+ } : {}
2866
2964
  };
2867
2965
  return encodeUtf8(JSON.stringify(env));
2868
2966
  }
@@ -2870,6 +2968,18 @@ function decodeEnvelope(bytes) {
2870
2968
  const s = decodeUtf8(bytes);
2871
2969
  try {
2872
2970
  const o = JSON.parse(s);
2971
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2972
+ return {
2973
+ type: "delete",
2974
+ text: null,
2975
+ clientMsgId: o.client_msg_id ?? "",
2976
+ replyTo: null,
2977
+ delete: {
2978
+ targetClientMsgId: o.target_client_msg_id ?? "",
2979
+ scope: o.scope ?? "everyone"
2980
+ }
2981
+ };
2982
+ }
2873
2983
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2874
2984
  return {
2875
2985
  type: "reaction",
@@ -2884,6 +2994,7 @@ function decodeEnvelope(bytes) {
2884
2994
  };
2885
2995
  }
2886
2996
  if (typeof o === "object" && o !== null && o.type === "edit") {
2997
+ const editRanges = decodeBodyRanges(o.body_ranges);
2887
2998
  return {
2888
2999
  type: "edit",
2889
3000
  text: null,
@@ -2892,15 +3003,18 @@ function decodeEnvelope(bytes) {
2892
3003
  edit: {
2893
3004
  targetClientMsgId: o.target_client_msg_id ?? "",
2894
3005
  newText: o.new_text ?? ""
2895
- }
3006
+ },
3007
+ ...editRanges ? { bodyRanges: editRanges } : {}
2896
3008
  };
2897
3009
  }
2898
3010
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
3011
+ const textRanges = decodeBodyRanges(o.body_ranges);
2899
3012
  return {
2900
3013
  type: "text",
2901
3014
  text: o.text ?? null,
2902
3015
  clientMsgId: o.client_msg_id ?? "",
2903
- replyTo: o.reply_to ?? null
3016
+ replyTo: o.reply_to ?? null,
3017
+ ...textRanges ? { bodyRanges: textRanges } : {}
2904
3018
  };
2905
3019
  }
2906
3020
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2912,6 +3026,14 @@ function decodeEnvelope(bytes) {
2912
3026
  }
2913
3027
  return { text: s, clientMsgId: "", replyTo: null };
2914
3028
  }
3029
+ function decodeBodyRanges(raw) {
3030
+ if (!raw || raw.length === 0) return void 0;
3031
+ return raw.map((r) => ({
3032
+ start: r.start,
3033
+ length: r.length,
3034
+ mentionedUserId: r.mentioned_user_id
3035
+ }));
3036
+ }
2915
3037
  function resolveReply(ref, lookup) {
2916
3038
  const parent = lookup(ref.client_msg_id);
2917
3039
  if (parent !== null) {
@@ -3123,9 +3245,9 @@ var GroupMessaging = class {
3123
3245
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3124
3246
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3125
3247
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3126
- async sendText(group, text, replyTo) {
3248
+ async sendText(group, text, replyTo, bodyRanges) {
3127
3249
  const clientMsgId = mintClientMsgId();
3128
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3250
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3129
3251
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3130
3252
  const body = {
3131
3253
  ciphertext_b64: toBase64(ct),
@@ -3151,7 +3273,10 @@ var GroupMessaging = class {
3151
3273
  previewBody: replyTo.preview?.body ?? null,
3152
3274
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3153
3275
  previewKind: replyTo.preview?.kind ?? "text"
3154
- } : null
3276
+ } : null,
3277
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3278
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3279
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3155
3280
  };
3156
3281
  try {
3157
3282
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3219,7 +3344,8 @@ var GroupMessaging = class {
3219
3344
  const plaintext = encodeEdit({
3220
3345
  clientMsgId: args.clientMsgId,
3221
3346
  targetClientMsgId: args.targetClientMsgId,
3222
- newText: args.newText
3347
+ newText: args.newText,
3348
+ bodyRanges: args.bodyRanges
3223
3349
  });
3224
3350
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3225
3351
  const body = {
@@ -3245,7 +3371,59 @@ var GroupMessaging = class {
3245
3371
  envelopeType: "edit",
3246
3372
  edit: {
3247
3373
  targetClientMsgId: args.targetClientMsgId,
3248
- newText: args.newText
3374
+ newText: args.newText,
3375
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3376
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3377
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3378
+ }
3379
+ };
3380
+ try {
3381
+ await this.messageStore.append(group.rfcGroupId, stored);
3382
+ } catch {
3383
+ }
3384
+ return {
3385
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3386
+ clientMsgId: args.clientMsgId
3387
+ };
3388
+ }
3389
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3390
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3391
+ * application path as `sendText` (the server stays blind — a delete is just
3392
+ * another opaque application message; the original ciphertext row is NOT
3393
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3394
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3395
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3396
+ * rebases (epoch-bound like any application message). */
3397
+ async sendDelete(group, args) {
3398
+ const plaintext = encodeDelete({
3399
+ clientMsgId: args.clientMsgId,
3400
+ targetClientMsgId: args.targetClientMsgId
3401
+ });
3402
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3403
+ const body = {
3404
+ ciphertext_b64: toBase64(ct),
3405
+ client_idem_key: randomId()
3406
+ };
3407
+ const wire = await palbeRequest(
3408
+ this.rt,
3409
+ "POST",
3410
+ MessagingPaths.groupMessages(group.displayId),
3411
+ { body }
3412
+ );
3413
+ const stored = {
3414
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3415
+ direction: "outgoing",
3416
+ text: null,
3417
+ senderDeviceId: this.selfDeviceId,
3418
+ epoch: wire.epoch,
3419
+ serverSeq: wire.server_seq,
3420
+ at: Date.now(),
3421
+ clientMsgId: args.clientMsgId,
3422
+ replyTo: null,
3423
+ envelopeType: "delete",
3424
+ delete: {
3425
+ targetClientMsgId: args.targetClientMsgId,
3426
+ scope: "everyone"
3249
3427
  }
3250
3428
  };
3251
3429
  try {
@@ -3318,6 +3496,41 @@ var GroupMessaging = class {
3318
3496
  }
3319
3497
  };
3320
3498
 
3499
+ // src/messaging/mention-ranges.ts
3500
+ function normalizeMentionRangesUtf16(ranges, text) {
3501
+ const n = text.length;
3502
+ function splitsSurrogatePair(index) {
3503
+ if (index <= 0 || index >= n) return false;
3504
+ const before = text.charCodeAt(index - 1);
3505
+ const at = text.charCodeAt(index);
3506
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3507
+ const atIsLow = at >= 56320 && at <= 57343;
3508
+ return beforeIsHigh && atIsLow;
3509
+ }
3510
+ const survivors = [];
3511
+ for (let idx = 0; idx < ranges.length; idx++) {
3512
+ const r = ranges[idx];
3513
+ if (r === void 0) continue;
3514
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3515
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3516
+ survivors.push({ idx, range: r });
3517
+ }
3518
+ survivors.sort((lhs, rhs) => {
3519
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3520
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3521
+ return lhs.idx - rhs.idx;
3522
+ });
3523
+ const kept = [];
3524
+ let prevEnd = Number.NEGATIVE_INFINITY;
3525
+ for (const s of survivors) {
3526
+ if (s.range.start >= prevEnd) {
3527
+ kept.push(s.range);
3528
+ prevEnd = s.range.start + s.range.length;
3529
+ }
3530
+ }
3531
+ return kept;
3532
+ }
3533
+
3321
3534
  // src/messaging/reaction-fold.ts
3322
3535
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3323
3536
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3374,6 +3587,7 @@ var ReactionFold = class {
3374
3587
  };
3375
3588
 
3376
3589
  // src/messaging/chat.ts
3590
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3377
3591
  var Chat = class {
3378
3592
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3379
3593
  id;
@@ -3395,6 +3609,22 @@ var Chat = class {
3395
3609
  reactionFold = new ReactionFold();
3396
3610
  /** The single authoritative edit fold for this chat (live + own-send + history). */
3397
3611
  editFold = new EditFold();
3612
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3613
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3614
+ deleteFold = new DeleteFold();
3615
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3616
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3617
+ suppressed = /* @__PURE__ */ new Set();
3618
+ /** True once the persisted suppression set has been loaded (so the omit applies
3619
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3620
+ suppressedLoaded = false;
3621
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3622
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3623
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3624
+ elevated = /* @__PURE__ */ new Set();
3625
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3626
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3627
+ elevatedLoaded = false;
3398
3628
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3399
3629
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3400
3630
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3406,6 +3636,14 @@ var Chat = class {
3406
3636
  wired = false;
3407
3637
  liveUnsub = null;
3408
3638
  listeners = /* @__PURE__ */ new Set();
3639
+ /**
3640
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3641
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3642
+ * via the persisted elevation set, so this never double-fires for one mention. The
3643
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3644
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3645
+ */
3646
+ onMentionElevation;
3409
3647
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3410
3648
  constructor(args) {
3411
3649
  this.backend = args.backend;
@@ -3443,7 +3681,7 @@ var Chat = class {
3443
3681
  return this.kind === "direct";
3444
3682
  }
3445
3683
  get messages() {
3446
- return this.messageList;
3684
+ return this.surfaced();
3447
3685
  }
3448
3686
  get members() {
3449
3687
  return this.memberCache;
@@ -3452,13 +3690,50 @@ var Chat = class {
3452
3690
  return this.typingList;
3453
3691
  }
3454
3692
  get lastMessage() {
3455
- return this.messageList.at(-1) ?? null;
3693
+ return this.surfaced().at(-1) ?? null;
3456
3694
  }
3457
3695
  get unreadCount() {
3458
- return this.messageList.filter(
3459
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3696
+ return this.surfaced().filter(
3697
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3460
3698
  ).length;
3461
3699
  }
3700
+ /**
3701
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3702
+ * through it identically). Over the raw `messageList` (which already carries the
3703
+ * folded edit text + reactions + reply):
3704
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3705
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3706
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3707
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3708
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3709
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3710
+ */
3711
+ surfaced() {
3712
+ const out = [];
3713
+ for (const m of this.messageList) {
3714
+ const key = this.suppressionKey(m);
3715
+ if (this.suppressed.has(key)) continue;
3716
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3717
+ if (tombstoned) {
3718
+ out.push({
3719
+ ...m,
3720
+ text: DELETED_DESCRIPTOR,
3721
+ reactions: {},
3722
+ replyTo: null,
3723
+ edited: false,
3724
+ isDeleted: true,
3725
+ mentions: []
3726
+ });
3727
+ continue;
3728
+ }
3729
+ out.push(m);
3730
+ }
3731
+ return out;
3732
+ }
3733
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3734
+ suppressionKey(m) {
3735
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3736
+ }
3462
3737
  get title() {
3463
3738
  if (this.titleOverride) return this.titleOverride;
3464
3739
  if (this._group?.name) return this._group.name;
@@ -3482,9 +3757,40 @@ var Chat = class {
3482
3757
  if (this.wired || this._state !== "active" || !this._group) return;
3483
3758
  this.wired = true;
3484
3759
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3760
+ void this.loadSuppressed();
3761
+ void this.loadElevated();
3485
3762
  void this.hydrateHistory();
3486
3763
  void this.refreshMembers();
3487
3764
  }
3765
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3766
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3767
+ async loadSuppressed() {
3768
+ if (this.suppressedLoaded || !this._group) return;
3769
+ this.suppressedLoaded = true;
3770
+ try {
3771
+ const keys = await this.backend.loadSuppressed(this._group);
3772
+ let changed = false;
3773
+ for (const k of keys) {
3774
+ if (!this.suppressed.has(k)) {
3775
+ this.suppressed.add(k);
3776
+ changed = true;
3777
+ }
3778
+ }
3779
+ if (changed) this.emit();
3780
+ } catch {
3781
+ }
3782
+ }
3783
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
3784
+ * gates the elevation DECISION, it does not change what renders. */
3785
+ async loadElevated() {
3786
+ if (this.elevatedLoaded || !this._group) return;
3787
+ this.elevatedLoaded = true;
3788
+ try {
3789
+ const keys = await this.backend.loadElevated(this._group);
3790
+ for (const k of keys) this.elevated.add(k);
3791
+ } catch {
3792
+ }
3793
+ }
3488
3794
  async hydrateHistory() {
3489
3795
  if (this.historyLoaded || !this._group) return;
3490
3796
  this.historyLoaded = true;
@@ -3495,13 +3801,13 @@ var Chat = class {
3495
3801
  let changed = false;
3496
3802
  for (const m of incoming) {
3497
3803
  if (m.serverSeq <= 0) continue;
3498
- if (m.clientMsgId && m.text !== null) {
3804
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3499
3805
  this.byClientMsgId.set(m.clientMsgId, {
3500
3806
  text: m.text,
3501
3807
  senderUserId: m.senderUserId ?? ""
3502
3808
  });
3503
3809
  }
3504
- if (m.clientMsgId) {
3810
+ if (m.clientMsgId && !m.isDeleted) {
3505
3811
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3506
3812
  }
3507
3813
  }
@@ -3511,7 +3817,12 @@ var Chat = class {
3511
3817
  const key = this.internalKey(m.serverSeq);
3512
3818
  if (this.seenKeys.has(key)) continue;
3513
3819
  this.seenKeys.add(key);
3514
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3820
+ if (m.clientMsgId && !m.isDeleted) {
3821
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3822
+ }
3823
+ this.messageList.push(
3824
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3825
+ );
3515
3826
  changed = true;
3516
3827
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3517
3828
  }
@@ -3561,19 +3872,38 @@ var Chat = class {
3561
3872
  newText: incoming.edit.newText,
3562
3873
  epoch: incoming.epoch,
3563
3874
  serverSeq: incoming.serverSeq,
3564
- eventClientMsgId: incoming.clientMsgId
3875
+ eventClientMsgId: incoming.clientMsgId,
3876
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
3877
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
3878
+ bodyRanges: incoming.bodyRanges
3565
3879
  },
3566
3880
  this.authorOfTarget
3567
3881
  );
3568
3882
  this.recomputeEdit(incoming.edit.targetClientMsgId);
3569
3883
  return;
3570
3884
  }
3885
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3886
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3887
+ this.deleteFold.ingest(
3888
+ {
3889
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3890
+ actorUserId,
3891
+ epoch: incoming.epoch,
3892
+ serverSeq: incoming.serverSeq,
3893
+ eventClientMsgId: incoming.clientMsgId
3894
+ },
3895
+ this.authorOfTarget
3896
+ );
3897
+ this.emit();
3898
+ return;
3899
+ }
3571
3900
  const incomingClientMsgId = incoming.clientMsgId;
3572
3901
  const incomingReplyRef = incoming.replyRef;
3573
3902
  let resolvedReplyTo = null;
3574
3903
  if (incomingReplyRef) {
3575
3904
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3576
3905
  }
3906
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3577
3907
  const msg = {
3578
3908
  id: this.publicId(incoming.serverSeq),
3579
3909
  kind: this.kindOf(incoming),
@@ -3588,8 +3918,12 @@ var Chat = class {
3588
3918
  // BEFORE its target — the dangling case — renders the moment the target lands).
3589
3919
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3590
3920
  // Default false; applyEditOverlay below folds any edit that arrived first.
3591
- edited: false
3921
+ edited: false,
3922
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3923
+ isDeleted: false,
3924
+ mentions
3592
3925
  };
3926
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3593
3927
  if (incomingClientMsgId && incoming.text !== null) {
3594
3928
  this.byClientMsgId.set(incomingClientMsgId, {
3595
3929
  text: incoming.text,
@@ -3599,6 +3933,7 @@ var Chat = class {
3599
3933
  if (incomingClientMsgId) {
3600
3934
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3601
3935
  this.editFold.reevaluateHeld(this.authorOfTarget);
3936
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3602
3937
  }
3603
3938
  this.messageList.push(this.applyEditOverlay(msg));
3604
3939
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3612,6 +3947,75 @@ var Chat = class {
3612
3947
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3613
3948
  * so it can be passed to the pure EditFold. */
3614
3949
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3950
+ // ── Mentions (mentions T6) ──
3951
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3952
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
3953
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
3954
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
3955
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
3956
+ resolveMentions(text, bodyRanges) {
3957
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
3958
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
3959
+ if (normalized.length === 0) return [];
3960
+ return normalized.map((r) => ({
3961
+ start: r.start,
3962
+ length: r.length,
3963
+ mentionedUserId: r.mentionedUserId,
3964
+ displayName: this.displayNameOf(r.mentionedUserId)
3965
+ }));
3966
+ }
3967
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
3968
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
3969
+ * A member rename then reflects on old messages. Returns the message unchanged when
3970
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
3971
+ resolveMentionNames(m) {
3972
+ if (!m.mentions || m.mentions.length === 0) {
3973
+ return m.mentions ? m : { ...m, mentions: [] };
3974
+ }
3975
+ let changed = false;
3976
+ const reresolved = m.mentions.map((span) => {
3977
+ const name = this.displayNameOf(span.mentionedUserId);
3978
+ if (name === span.displayName) return span;
3979
+ changed = true;
3980
+ return { ...span, displayName: name };
3981
+ });
3982
+ if (!changed) return m;
3983
+ return { ...m, mentions: reresolved };
3984
+ }
3985
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
3986
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
3987
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
3988
+ editMentions(targetClientMsgId, newText) {
3989
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
3990
+ if (!ranges) return [];
3991
+ return this.resolveMentions(newText, ranges);
3992
+ }
3993
+ /** Resolve a userId → its roster display name (null if not a known member). */
3994
+ displayNameOf(userId) {
3995
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
3996
+ }
3997
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
3998
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
3999
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
4000
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
4001
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
4002
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
4003
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
4004
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
4005
+ const me = this.backend.selfUserId;
4006
+ if (envelopeType === "edit") return;
4007
+ if (senderUserId === me) return;
4008
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
4009
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4010
+ const key = `${me}|${idPart}`;
4011
+ if (this.elevated.has(key)) return;
4012
+ this.elevated.add(key);
4013
+ if (this._group) {
4014
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
4015
+ });
4016
+ }
4017
+ this.onMentionElevation?.(message);
4018
+ }
3615
4019
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3616
4020
  * (a later own/peer edit must not overwrite the original we render against). The
3617
4021
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3668,9 +4072,10 @@ var Chat = class {
3668
4072
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3669
4073
  const text = editText ?? base;
3670
4074
  const edited = foldEdited || m.edited;
3671
- if (m.text === text && m.edited === edited) return m;
4075
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
4076
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3672
4077
  changed = true;
3673
- return { ...m, text, edited };
4078
+ return { ...m, text, edited, mentions };
3674
4079
  });
3675
4080
  if (changed) this.emit();
3676
4081
  }
@@ -3687,8 +4092,9 @@ var Chat = class {
3687
4092
  if (editText === null && !foldEdited) return m;
3688
4093
  const text = editText ?? m.text;
3689
4094
  const edited = foldEdited || m.edited;
3690
- if (m.text === text && m.edited === edited) return m;
3691
- return { ...m, text, edited };
4095
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
4096
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
4097
+ return { ...m, text, edited, mentions };
3692
4098
  }
3693
4099
  /** @internal — called by the backend's conv subscription. */
3694
4100
  applyConv(event, payload) {
@@ -3746,6 +4152,18 @@ var Chat = class {
3746
4152
  }
3747
4153
  this.editFold.reevaluateHeld(this.authorOfTarget);
3748
4154
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
4155
+ this.reresolveAllMentionNames();
4156
+ }
4157
+ /** Re-resolve roster display names across the whole transcript (called on a roster
4158
+ * change). Re-emits only if any name actually changed. */
4159
+ reresolveAllMentionNames() {
4160
+ let changed = false;
4161
+ this.messageList = this.messageList.map((m) => {
4162
+ const reresolved = this.resolveMentionNames(m);
4163
+ if (reresolved !== m) changed = true;
4164
+ return reresolved;
4165
+ });
4166
+ if (changed) this.emit();
3749
4167
  }
3750
4168
  seedMembersFromGroup(group) {
3751
4169
  const seed = [
@@ -3813,11 +4231,12 @@ var Chat = class {
3813
4231
  };
3814
4232
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
3815
4233
  }
3816
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
3817
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4234
+ const bodyRanges = opts?.mentions ?? null;
4235
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4236
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
3818
4237
  return receipt;
3819
4238
  }
3820
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4239
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
3821
4240
  if (receipt.serverSeq <= 0) return;
3822
4241
  const key = this.internalKey(receipt.serverSeq);
3823
4242
  if (this.seenKeys.has(key)) return;
@@ -3840,7 +4259,12 @@ var Chat = class {
3840
4259
  // the dangling-target invariant uniform across every append path).
3841
4260
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3842
4261
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3843
- edited: false
4262
+ edited: false,
4263
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4264
+ isDeleted: false,
4265
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4266
+ // sender never gets a wire echo of its own message — this is the only local copy).
4267
+ mentions: this.resolveMentions(text, bodyRanges)
3844
4268
  });
3845
4269
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3846
4270
  this.emit();
@@ -3928,15 +4352,18 @@ var Chat = class {
3928
4352
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3929
4353
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3930
4354
  * reactions + reply context. Only the original author's edits count — for an own
3931
- * message self IS the author, so the author-gate passes. */
3932
- async edit(message, newText) {
4355
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4356
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4357
+ async edit(message, newText, opts) {
3933
4358
  if (!message.clientMsgId || message.kind !== "text") return;
3934
4359
  const group = await this.materializeIfNeeded();
3935
4360
  const clientMsgId = mintClientMsgId();
4361
+ const bodyRanges = opts?.mentions ?? null;
3936
4362
  const { receipt } = await this.backend.sendEdit(group, {
3937
4363
  clientMsgId,
3938
4364
  targetClientMsgId: message.clientMsgId,
3939
- newText
4365
+ newText,
4366
+ bodyRanges
3940
4367
  });
3941
4368
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3942
4369
  this.editFold.ingest(
@@ -3946,13 +4373,72 @@ var Chat = class {
3946
4373
  newText,
3947
4374
  epoch: receipt.epoch,
3948
4375
  serverSeq: receipt.serverSeq,
3949
- eventClientMsgId: clientMsgId
4376
+ eventClientMsgId: clientMsgId,
4377
+ bodyRanges
3950
4378
  },
3951
4379
  this.authorOfTarget
3952
4380
  );
3953
4381
  this.recomputeEdit(message.clientMsgId);
3954
4382
  }
4383
+ // ── Delete ──
4384
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
4385
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
4386
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
4387
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
4388
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
4389
+ * server stays blind), folds the own delete locally so the target scrubs in
4390
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
4391
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
4392
+ async deleteForEveryone(message) {
4393
+ if (!message.clientMsgId) return;
4394
+ const group = await this.materializeIfNeeded();
4395
+ const clientMsgId = mintClientMsgId();
4396
+ const { receipt } = await this.backend.sendDelete(group, {
4397
+ clientMsgId,
4398
+ targetClientMsgId: message.clientMsgId
4399
+ });
4400
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4401
+ this.deleteFold.ingest(
4402
+ {
4403
+ targetClientMsgId: message.clientMsgId,
4404
+ actorUserId: this.backend.selfUserId,
4405
+ epoch: receipt.epoch,
4406
+ serverSeq: receipt.serverSeq,
4407
+ eventClientMsgId: clientMsgId
4408
+ },
4409
+ this.authorOfTarget
4410
+ );
4411
+ this.emit();
4412
+ }
4413
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4414
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4415
+ * suppression key persists per chat (survives reload). The key is the message's
4416
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4417
+ async deleteForMe(message) {
4418
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4419
+ if (this.suppressed.has(key)) return;
4420
+ this.suppressed.add(key);
4421
+ this.emit();
4422
+ if (this._group) {
4423
+ try {
4424
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4425
+ } catch {
4426
+ }
4427
+ }
4428
+ }
3955
4429
  };
4430
+ function sameMentions(a, b) {
4431
+ if (a.length !== b.length) return false;
4432
+ for (let i = 0; i < a.length; i++) {
4433
+ const x = a[i];
4434
+ const y = b[i];
4435
+ if (!x || !y) return false;
4436
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4437
+ return false;
4438
+ }
4439
+ }
4440
+ return true;
4441
+ }
3956
4442
  function sameReactions(a, b) {
3957
4443
  const ak = Object.keys(a);
3958
4444
  const bk = Object.keys(b);
@@ -4132,6 +4618,7 @@ var MessageDeliverySource = class {
4132
4618
  const { text, clientMsgId, replyTo } = decoded;
4133
4619
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4134
4620
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4621
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
4135
4622
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4136
4623
  const stored = {
4137
4624
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4163,12 +4650,31 @@ var MessageDeliverySource = class {
4163
4650
  // Thread the edit discriminator + new text through the persisted row so an
4164
4651
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4165
4652
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4166
- // `'text'`/no-edit (backward-compat).
4653
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
4654
+ // along so the edited message's mentions re-resolve on cold launch (T6).
4167
4655
  ...isEdit && decoded.edit ? {
4168
4656
  envelopeType: "edit",
4169
4657
  edit: {
4170
4658
  targetClientMsgId: decoded.edit.targetClientMsgId,
4171
- newText: decoded.edit.newText
4659
+ newText: decoded.edit.newText,
4660
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4661
+ }
4662
+ } : {},
4663
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
4664
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
4665
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
4666
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
4667
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4668
+ // Thread the delete discriminator + target through the persisted row so a
4669
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4670
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4671
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4672
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4673
+ ...isDelete && decoded.delete ? {
4674
+ envelopeType: "delete",
4675
+ delete: {
4676
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4677
+ scope: decoded.delete.scope
4172
4678
  }
4173
4679
  } : {}
4174
4680
  };
@@ -4189,7 +4695,11 @@ var MessageDeliverySource = class {
4189
4695
  replyRef: replyTo,
4190
4696
  envelopeType: decoded.type ?? "text",
4191
4697
  reaction: isReaction ? decoded.reaction : null,
4192
- edit: isEdit ? decoded.edit : null
4698
+ edit: isEdit ? decoded.edit : null,
4699
+ delete: isDelete ? decoded.delete : null,
4700
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
4701
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4702
+ bodyRanges: decoded.bodyRanges ?? null
4193
4703
  });
4194
4704
  return true;
4195
4705
  }
@@ -4344,6 +4854,36 @@ var GroupCatalog = class {
4344
4854
  }
4345
4855
  };
4346
4856
 
4857
+ // src/messaging/mention-elevation.ts
4858
+ var MentionElevationStore = class {
4859
+ constructor(kv) {
4860
+ this.kv = kv;
4861
+ }
4862
+ kv;
4863
+ key(rfcGroupId) {
4864
+ return `elev:${rfcGroupId}`;
4865
+ }
4866
+ /** Load the persisted elevation keys for a chat (empty array if none). */
4867
+ async load(rfcGroupId) {
4868
+ const raw = await this.kv.get(this.key(rfcGroupId));
4869
+ if (!raw) return [];
4870
+ try {
4871
+ const parsed = JSON.parse(decodeUtf8(raw));
4872
+ return Array.isArray(parsed) ? parsed : [];
4873
+ } catch {
4874
+ return [];
4875
+ }
4876
+ }
4877
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
4878
+ async save(rfcGroupId, keys) {
4879
+ const sorted = [...new Set(keys)].sort();
4880
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
4881
+ }
4882
+ async wipe() {
4883
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
4884
+ }
4885
+ };
4886
+
4347
4887
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4348
4888
  var palbe_mls_bg_exports = {};
4349
4889
  __export(palbe_mls_bg_exports, {
@@ -6011,6 +6551,36 @@ var SignatureKeyStore = class {
6011
6551
  }
6012
6552
  };
6013
6553
 
6554
+ // src/messaging/suppression.ts
6555
+ var SuppressionStore = class {
6556
+ constructor(kv) {
6557
+ this.kv = kv;
6558
+ }
6559
+ kv;
6560
+ key(rfcGroupId) {
6561
+ return `supp:${rfcGroupId}`;
6562
+ }
6563
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6564
+ async load(rfcGroupId) {
6565
+ const raw = await this.kv.get(this.key(rfcGroupId));
6566
+ if (!raw) return [];
6567
+ try {
6568
+ const parsed = JSON.parse(decodeUtf8(raw));
6569
+ return Array.isArray(parsed) ? parsed : [];
6570
+ } catch {
6571
+ return [];
6572
+ }
6573
+ }
6574
+ /** Persist the full suppression key set for a chat (deterministic order). */
6575
+ async save(rfcGroupId, keys) {
6576
+ const sorted = [...new Set(keys)].sort();
6577
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6578
+ }
6579
+ async wipe() {
6580
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6581
+ }
6582
+ };
6583
+
6014
6584
  // src/messaging/coordinator.ts
6015
6585
  var MessagingCoordinator = class {
6016
6586
  constructor(rt) {
@@ -6020,6 +6590,8 @@ var MessagingCoordinator = class {
6020
6590
  this.sigStore = new SignatureKeyStore(this.kv);
6021
6591
  this.groupStore = new GroupStateStorage(this.kv);
6022
6592
  this.kpStore = new KeyPackageStorage(this.kv);
6593
+ this.suppressionStore = new SuppressionStore(this.kv);
6594
+ this.elevationStore = new MentionElevationStore(this.kv);
6023
6595
  this.registry.attachChatList(
6024
6596
  (chats) => {
6025
6597
  this.chatList = chats;
@@ -6034,6 +6606,8 @@ var MessagingCoordinator = class {
6034
6606
  sigStore;
6035
6607
  groupStore;
6036
6608
  kpStore;
6609
+ suppressionStore;
6610
+ elevationStore;
6037
6611
  registry = new GroupRegistry();
6038
6612
  resolved = null;
6039
6613
  resolvePromise = null;
@@ -6189,9 +6763,9 @@ var MessagingCoordinator = class {
6189
6763
  });
6190
6764
  return group;
6191
6765
  }
6192
- async sendText(group, text, replyTo) {
6766
+ async sendText(group, text, replyTo, bodyRanges) {
6193
6767
  const r = await this.resolve();
6194
- return r.groups.sendText(group, text, replyTo);
6768
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6195
6769
  }
6196
6770
  async sendReaction(group, args) {
6197
6771
  const r = await this.resolve();
@@ -6201,6 +6775,26 @@ var MessagingCoordinator = class {
6201
6775
  const r = await this.resolve();
6202
6776
  return r.groups.sendEdit(group, args);
6203
6777
  }
6778
+ async sendDelete(group, args) {
6779
+ const r = await this.resolve();
6780
+ return r.groups.sendDelete(group, args);
6781
+ }
6782
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6783
+ loadSuppressed(group) {
6784
+ return this.suppressionStore.load(group.rfcGroupId);
6785
+ }
6786
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6787
+ saveSuppressed(group, keys) {
6788
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6789
+ }
6790
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
6791
+ loadElevated(group) {
6792
+ return this.elevationStore.load(group.rfcGroupId);
6793
+ }
6794
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
6795
+ saveElevated(group, keys) {
6796
+ return this.elevationStore.save(group.rfcGroupId, keys);
6797
+ }
6204
6798
  async history(group, limit, before) {
6205
6799
  const r = await this.resolve();
6206
6800
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6298,9 +6892,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6298
6892
  });
6299
6893
  }
6300
6894
  const editFold = new EditFold();
6895
+ const deleteFold = new DeleteFold();
6301
6896
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6302
6897
  for (const s of rows) {
6303
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6898
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6899
+ continue;
6304
6900
  const cid = s.clientMsgId ?? "";
6305
6901
  if (!cid) continue;
6306
6902
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -6317,15 +6913,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6317
6913
  newText: s.edit.newText,
6318
6914
  epoch: s.epoch,
6319
6915
  serverSeq: s.serverSeq,
6320
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
6916
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
6917
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
6918
+ // edit's ranges drive the edited message's mentions on cold launch.
6919
+ bodyRanges: s.edit.bodyRanges ?? null
6321
6920
  },
6322
6921
  authorOfTarget
6323
6922
  );
6324
6923
  }
6325
6924
  editFold.reevaluateHeld(authorOfTarget);
6925
+ for (const s of rows) {
6926
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6927
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6928
+ deleteFold.ingest(
6929
+ {
6930
+ targetClientMsgId: s.delete.targetClientMsgId,
6931
+ actorUserId: actor,
6932
+ epoch: s.epoch,
6933
+ serverSeq: s.serverSeq,
6934
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6935
+ },
6936
+ authorOfTarget
6937
+ );
6938
+ }
6939
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
6326
6940
  const lookup = /* @__PURE__ */ new Map();
6327
6941
  for (const s of rows) {
6328
- if (s.envelopeType === "reaction") continue;
6942
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6943
+ continue;
6329
6944
  const cid = s.clientMsgId ?? "";
6330
6945
  if (cid && s.text !== null) {
6331
6946
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6334,8 +6949,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6334
6949
  }
6335
6950
  const out = [];
6336
6951
  for (const s of rows) {
6337
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6952
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6953
+ continue;
6338
6954
  const clientMsgId = s.clientMsgId ?? "";
6955
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6956
+ if (isDeleted) {
6957
+ out.push({
6958
+ id: `${displayId}#${s.serverSeq}`,
6959
+ kind: "text",
6960
+ direction: s.direction,
6961
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6962
+ text: DELETED_DESCRIPTOR,
6963
+ serverSeq: s.serverSeq,
6964
+ sentAt: new Date(s.at),
6965
+ clientMsgId,
6966
+ replyTo: null,
6967
+ reactions: {},
6968
+ edited: false,
6969
+ isDeleted: true,
6970
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6971
+ mentions: []
6972
+ });
6973
+ continue;
6974
+ }
6339
6975
  let replyTo = null;
6340
6976
  if (s.replyTo) {
6341
6977
  const ref = {
@@ -6352,22 +6988,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6352
6988
  }
6353
6989
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6354
6990
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6991
+ const text = editText ?? s.text;
6992
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
6993
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6355
6994
  out.push({
6356
6995
  id: `${displayId}#${s.serverSeq}`,
6357
6996
  kind: s.text != null ? "text" : "system",
6358
6997
  direction: s.direction,
6359
6998
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6360
- text: editText ?? s.text,
6999
+ text,
6361
7000
  serverSeq: s.serverSeq,
6362
7001
  sentAt: new Date(s.at),
6363
7002
  clientMsgId,
6364
7003
  replyTo,
6365
7004
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6366
- edited
7005
+ edited,
7006
+ isDeleted: false,
7007
+ mentions
6367
7008
  });
6368
7009
  }
6369
7010
  return out;
6370
7011
  }
7012
+ function normalizeMentionsNullNames(raw, text) {
7013
+ if (text === null || !raw || raw.length === 0) return [];
7014
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
7015
+ start: r.start,
7016
+ length: r.length,
7017
+ mentionedUserId: r.mentionedUserId,
7018
+ displayName: null
7019
+ }));
7020
+ }
6371
7021
 
6372
7022
  // src/messaging/facade.ts
6373
7023
  var PalbeMessaging = class {
@@ -7101,7 +7751,7 @@ function defaultSessionStorage(key) {
7101
7751
  }
7102
7752
 
7103
7753
  // src/version.ts
7104
- var VERSION = "1.3.0";
7754
+ var VERSION = "1.5.0";
7105
7755
 
7106
7756
  // src/runtime.ts
7107
7757
  function buildRuntime(config) {