@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.
@@ -2591,6 +2591,69 @@ var PalbeFlags = class {
2591
2591
  }
2592
2592
  };
2593
2593
 
2594
+ // src/messaging/delete-fold.ts
2595
+ var DeleteFold = class {
2596
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2597
+ tombstoned = /* @__PURE__ */ new Set();
2598
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2599
+ pending = /* @__PURE__ */ new Map();
2600
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2601
+ seen = /* @__PURE__ */ new Set();
2602
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2603
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2604
+ held = [];
2605
+ /**
2606
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2607
+ * userId (null = target absent locally → defer).
2608
+ */
2609
+ ingest(e, authorOfTarget) {
2610
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2611
+ if (this.seen.has(e.eventClientMsgId)) return;
2612
+ if (this.heldContains(e.eventClientMsgId)) return;
2613
+ const author = authorOfTarget(e.targetClientMsgId);
2614
+ if (author !== null) {
2615
+ this.seen.add(e.eventClientMsgId);
2616
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2617
+ this.tombstoned.add(e.targetClientMsgId);
2618
+ } else if (e.actorUserId !== null) {
2619
+ this.seen.add(e.eventClientMsgId);
2620
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2621
+ } else {
2622
+ this.held.push(e);
2623
+ }
2624
+ }
2625
+ /** True once a valid tombstone has absorbed this target. */
2626
+ isTombstoned(targetClientMsgId) {
2627
+ return this.tombstoned.has(targetClientMsgId);
2628
+ }
2629
+ /**
2630
+ * When a target message newly arrives with a resolved `author`, re-check any
2631
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2632
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2633
+ * the in-order path.
2634
+ */
2635
+ reevaluatePending(target, author) {
2636
+ const actor = this.pending.get(target);
2637
+ if (actor !== void 0) {
2638
+ if (author !== null && actor === author) {
2639
+ this.tombstoned.add(target);
2640
+ this.pending.delete(target);
2641
+ } else if (author !== null) {
2642
+ this.pending.delete(target);
2643
+ }
2644
+ }
2645
+ if (this.held.length === 0) return;
2646
+ const pendingHeld = this.held;
2647
+ this.held = [];
2648
+ for (const e of pendingHeld) {
2649
+ this.ingest(e, (t) => t === target ? author : null);
2650
+ }
2651
+ }
2652
+ heldContains(eventClientMsgId) {
2653
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2654
+ }
2655
+ };
2656
+
2594
2657
  // src/messaging/edit-fold.ts
2595
2658
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2596
2659
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2636,7 +2699,8 @@ var EditFold = class {
2636
2699
  orderEpoch: e.epoch,
2637
2700
  orderSeq: e.serverSeq,
2638
2701
  lastEventId: e.eventClientMsgId,
2639
- text: e.newText
2702
+ text: e.newText,
2703
+ bodyRanges: e.bodyRanges ?? null
2640
2704
  });
2641
2705
  this.editedTargets.add(e.targetClientMsgId);
2642
2706
  }
@@ -2657,6 +2721,15 @@ var EditFold = class {
2657
2721
  isEdited(targetClientMsgId) {
2658
2722
  return this.editedTargets.has(targetClientMsgId);
2659
2723
  }
2724
+ /**
2725
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2726
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2727
+ * normalizes these against the edited text to compute the edited message's mentions
2728
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2729
+ */
2730
+ bodyRanges(targetClientMsgId) {
2731
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2732
+ }
2660
2733
  /**
2661
2734
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2662
2735
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2812,6 +2885,17 @@ async function listDevices(rt, userId) {
2812
2885
  }
2813
2886
 
2814
2887
  // src/messaging/group-messaging.ts
2888
+ function encodeDelete(args) {
2889
+ return encodeUtf8(
2890
+ JSON.stringify({
2891
+ v: 1,
2892
+ type: "delete",
2893
+ client_msg_id: args.clientMsgId,
2894
+ target_client_msg_id: args.targetClientMsgId,
2895
+ scope: "everyone"
2896
+ })
2897
+ );
2898
+ }
2815
2899
  function encodeEdit(args) {
2816
2900
  return encodeUtf8(
2817
2901
  JSON.stringify({
@@ -2819,7 +2903,14 @@ function encodeEdit(args) {
2819
2903
  type: "edit",
2820
2904
  client_msg_id: args.clientMsgId,
2821
2905
  target_client_msg_id: args.targetClientMsgId,
2822
- new_text: args.newText
2906
+ new_text: args.newText,
2907
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2908
+ body_ranges: args.bodyRanges.map((r) => ({
2909
+ start: r.start,
2910
+ length: r.length,
2911
+ mentioned_user_id: r.mentionedUserId
2912
+ }))
2913
+ } : {}
2823
2914
  })
2824
2915
  );
2825
2916
  }
@@ -2841,7 +2932,14 @@ function encodeEnvelope(args) {
2841
2932
  type: "text",
2842
2933
  client_msg_id: args.clientMsgId,
2843
2934
  text: args.text,
2844
- ...args.replyTo ? { reply_to: args.replyTo } : {}
2935
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
2936
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2937
+ body_ranges: args.bodyRanges.map((r) => ({
2938
+ start: r.start,
2939
+ length: r.length,
2940
+ mentioned_user_id: r.mentionedUserId
2941
+ }))
2942
+ } : {}
2845
2943
  };
2846
2944
  return encodeUtf8(JSON.stringify(env));
2847
2945
  }
@@ -2849,6 +2947,18 @@ function decodeEnvelope(bytes) {
2849
2947
  const s = decodeUtf8(bytes);
2850
2948
  try {
2851
2949
  const o = JSON.parse(s);
2950
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2951
+ return {
2952
+ type: "delete",
2953
+ text: null,
2954
+ clientMsgId: o.client_msg_id ?? "",
2955
+ replyTo: null,
2956
+ delete: {
2957
+ targetClientMsgId: o.target_client_msg_id ?? "",
2958
+ scope: o.scope ?? "everyone"
2959
+ }
2960
+ };
2961
+ }
2852
2962
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2853
2963
  return {
2854
2964
  type: "reaction",
@@ -2863,6 +2973,7 @@ function decodeEnvelope(bytes) {
2863
2973
  };
2864
2974
  }
2865
2975
  if (typeof o === "object" && o !== null && o.type === "edit") {
2976
+ const editRanges = decodeBodyRanges(o.body_ranges);
2866
2977
  return {
2867
2978
  type: "edit",
2868
2979
  text: null,
@@ -2871,15 +2982,18 @@ function decodeEnvelope(bytes) {
2871
2982
  edit: {
2872
2983
  targetClientMsgId: o.target_client_msg_id ?? "",
2873
2984
  newText: o.new_text ?? ""
2874
- }
2985
+ },
2986
+ ...editRanges ? { bodyRanges: editRanges } : {}
2875
2987
  };
2876
2988
  }
2877
2989
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2990
+ const textRanges = decodeBodyRanges(o.body_ranges);
2878
2991
  return {
2879
2992
  type: "text",
2880
2993
  text: o.text ?? null,
2881
2994
  clientMsgId: o.client_msg_id ?? "",
2882
- replyTo: o.reply_to ?? null
2995
+ replyTo: o.reply_to ?? null,
2996
+ ...textRanges ? { bodyRanges: textRanges } : {}
2883
2997
  };
2884
2998
  }
2885
2999
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2891,6 +3005,14 @@ function decodeEnvelope(bytes) {
2891
3005
  }
2892
3006
  return { text: s, clientMsgId: "", replyTo: null };
2893
3007
  }
3008
+ function decodeBodyRanges(raw) {
3009
+ if (!raw || raw.length === 0) return void 0;
3010
+ return raw.map((r) => ({
3011
+ start: r.start,
3012
+ length: r.length,
3013
+ mentionedUserId: r.mentioned_user_id
3014
+ }));
3015
+ }
2894
3016
  function resolveReply(ref, lookup) {
2895
3017
  const parent = lookup(ref.client_msg_id);
2896
3018
  if (parent !== null) {
@@ -3102,9 +3224,9 @@ var GroupMessaging = class {
3102
3224
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3103
3225
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3104
3226
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3105
- async sendText(group, text, replyTo) {
3227
+ async sendText(group, text, replyTo, bodyRanges) {
3106
3228
  const clientMsgId = mintClientMsgId();
3107
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3229
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3108
3230
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3109
3231
  const body = {
3110
3232
  ciphertext_b64: toBase64(ct),
@@ -3130,7 +3252,10 @@ var GroupMessaging = class {
3130
3252
  previewBody: replyTo.preview?.body ?? null,
3131
3253
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3132
3254
  previewKind: replyTo.preview?.kind ?? "text"
3133
- } : null
3255
+ } : null,
3256
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3257
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3258
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3134
3259
  };
3135
3260
  try {
3136
3261
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3198,7 +3323,8 @@ var GroupMessaging = class {
3198
3323
  const plaintext = encodeEdit({
3199
3324
  clientMsgId: args.clientMsgId,
3200
3325
  targetClientMsgId: args.targetClientMsgId,
3201
- newText: args.newText
3326
+ newText: args.newText,
3327
+ bodyRanges: args.bodyRanges
3202
3328
  });
3203
3329
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3204
3330
  const body = {
@@ -3224,7 +3350,59 @@ var GroupMessaging = class {
3224
3350
  envelopeType: "edit",
3225
3351
  edit: {
3226
3352
  targetClientMsgId: args.targetClientMsgId,
3227
- newText: args.newText
3353
+ newText: args.newText,
3354
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3355
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3356
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3357
+ }
3358
+ };
3359
+ try {
3360
+ await this.messageStore.append(group.rfcGroupId, stored);
3361
+ } catch {
3362
+ }
3363
+ return {
3364
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3365
+ clientMsgId: args.clientMsgId
3366
+ };
3367
+ }
3368
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3369
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3370
+ * application path as `sendText` (the server stays blind — a delete is just
3371
+ * another opaque application message; the original ciphertext row is NOT
3372
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3373
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3374
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3375
+ * rebases (epoch-bound like any application message). */
3376
+ async sendDelete(group, args) {
3377
+ const plaintext = encodeDelete({
3378
+ clientMsgId: args.clientMsgId,
3379
+ targetClientMsgId: args.targetClientMsgId
3380
+ });
3381
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3382
+ const body = {
3383
+ ciphertext_b64: toBase64(ct),
3384
+ client_idem_key: randomId()
3385
+ };
3386
+ const wire = await palbeRequest(
3387
+ this.rt,
3388
+ "POST",
3389
+ MessagingPaths.groupMessages(group.displayId),
3390
+ { body }
3391
+ );
3392
+ const stored = {
3393
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3394
+ direction: "outgoing",
3395
+ text: null,
3396
+ senderDeviceId: this.selfDeviceId,
3397
+ epoch: wire.epoch,
3398
+ serverSeq: wire.server_seq,
3399
+ at: Date.now(),
3400
+ clientMsgId: args.clientMsgId,
3401
+ replyTo: null,
3402
+ envelopeType: "delete",
3403
+ delete: {
3404
+ targetClientMsgId: args.targetClientMsgId,
3405
+ scope: "everyone"
3228
3406
  }
3229
3407
  };
3230
3408
  try {
@@ -3297,6 +3475,41 @@ var GroupMessaging = class {
3297
3475
  }
3298
3476
  };
3299
3477
 
3478
+ // src/messaging/mention-ranges.ts
3479
+ function normalizeMentionRangesUtf16(ranges, text) {
3480
+ const n = text.length;
3481
+ function splitsSurrogatePair(index) {
3482
+ if (index <= 0 || index >= n) return false;
3483
+ const before = text.charCodeAt(index - 1);
3484
+ const at = text.charCodeAt(index);
3485
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3486
+ const atIsLow = at >= 56320 && at <= 57343;
3487
+ return beforeIsHigh && atIsLow;
3488
+ }
3489
+ const survivors = [];
3490
+ for (let idx = 0; idx < ranges.length; idx++) {
3491
+ const r = ranges[idx];
3492
+ if (r === void 0) continue;
3493
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3494
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3495
+ survivors.push({ idx, range: r });
3496
+ }
3497
+ survivors.sort((lhs, rhs) => {
3498
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3499
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3500
+ return lhs.idx - rhs.idx;
3501
+ });
3502
+ const kept = [];
3503
+ let prevEnd = Number.NEGATIVE_INFINITY;
3504
+ for (const s of survivors) {
3505
+ if (s.range.start >= prevEnd) {
3506
+ kept.push(s.range);
3507
+ prevEnd = s.range.start + s.range.length;
3508
+ }
3509
+ }
3510
+ return kept;
3511
+ }
3512
+
3300
3513
  // src/messaging/reaction-fold.ts
3301
3514
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3302
3515
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3353,6 +3566,7 @@ var ReactionFold = class {
3353
3566
  };
3354
3567
 
3355
3568
  // src/messaging/chat.ts
3569
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3356
3570
  var Chat = class {
3357
3571
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3358
3572
  id;
@@ -3374,6 +3588,22 @@ var Chat = class {
3374
3588
  reactionFold = new ReactionFold();
3375
3589
  /** The single authoritative edit fold for this chat (live + own-send + history). */
3376
3590
  editFold = new EditFold();
3591
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3592
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3593
+ deleteFold = new DeleteFold();
3594
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3595
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3596
+ suppressed = /* @__PURE__ */ new Set();
3597
+ /** True once the persisted suppression set has been loaded (so the omit applies
3598
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3599
+ suppressedLoaded = false;
3600
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3601
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3602
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3603
+ elevated = /* @__PURE__ */ new Set();
3604
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3605
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3606
+ elevatedLoaded = false;
3377
3607
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3378
3608
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3379
3609
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3385,6 +3615,14 @@ var Chat = class {
3385
3615
  wired = false;
3386
3616
  liveUnsub = null;
3387
3617
  listeners = /* @__PURE__ */ new Set();
3618
+ /**
3619
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3620
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3621
+ * via the persisted elevation set, so this never double-fires for one mention. The
3622
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3623
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3624
+ */
3625
+ onMentionElevation;
3388
3626
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3389
3627
  constructor(args) {
3390
3628
  this.backend = args.backend;
@@ -3422,7 +3660,7 @@ var Chat = class {
3422
3660
  return this.kind === "direct";
3423
3661
  }
3424
3662
  get messages() {
3425
- return this.messageList;
3663
+ return this.surfaced();
3426
3664
  }
3427
3665
  get members() {
3428
3666
  return this.memberCache;
@@ -3431,13 +3669,50 @@ var Chat = class {
3431
3669
  return this.typingList;
3432
3670
  }
3433
3671
  get lastMessage() {
3434
- return this.messageList.at(-1) ?? null;
3672
+ return this.surfaced().at(-1) ?? null;
3435
3673
  }
3436
3674
  get unreadCount() {
3437
- return this.messageList.filter(
3438
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3675
+ return this.surfaced().filter(
3676
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3439
3677
  ).length;
3440
3678
  }
3679
+ /**
3680
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3681
+ * through it identically). Over the raw `messageList` (which already carries the
3682
+ * folded edit text + reactions + reply):
3683
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3684
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3685
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3686
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3687
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3688
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3689
+ */
3690
+ surfaced() {
3691
+ const out = [];
3692
+ for (const m of this.messageList) {
3693
+ const key = this.suppressionKey(m);
3694
+ if (this.suppressed.has(key)) continue;
3695
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3696
+ if (tombstoned) {
3697
+ out.push({
3698
+ ...m,
3699
+ text: DELETED_DESCRIPTOR,
3700
+ reactions: {},
3701
+ replyTo: null,
3702
+ edited: false,
3703
+ isDeleted: true,
3704
+ mentions: []
3705
+ });
3706
+ continue;
3707
+ }
3708
+ out.push(m);
3709
+ }
3710
+ return out;
3711
+ }
3712
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3713
+ suppressionKey(m) {
3714
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3715
+ }
3441
3716
  get title() {
3442
3717
  if (this.titleOverride) return this.titleOverride;
3443
3718
  if (this._group?.name) return this._group.name;
@@ -3461,9 +3736,40 @@ var Chat = class {
3461
3736
  if (this.wired || this._state !== "active" || !this._group) return;
3462
3737
  this.wired = true;
3463
3738
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3739
+ void this.loadSuppressed();
3740
+ void this.loadElevated();
3464
3741
  void this.hydrateHistory();
3465
3742
  void this.refreshMembers();
3466
3743
  }
3744
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3745
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3746
+ async loadSuppressed() {
3747
+ if (this.suppressedLoaded || !this._group) return;
3748
+ this.suppressedLoaded = true;
3749
+ try {
3750
+ const keys = await this.backend.loadSuppressed(this._group);
3751
+ let changed = false;
3752
+ for (const k of keys) {
3753
+ if (!this.suppressed.has(k)) {
3754
+ this.suppressed.add(k);
3755
+ changed = true;
3756
+ }
3757
+ }
3758
+ if (changed) this.emit();
3759
+ } catch {
3760
+ }
3761
+ }
3762
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
3763
+ * gates the elevation DECISION, it does not change what renders. */
3764
+ async loadElevated() {
3765
+ if (this.elevatedLoaded || !this._group) return;
3766
+ this.elevatedLoaded = true;
3767
+ try {
3768
+ const keys = await this.backend.loadElevated(this._group);
3769
+ for (const k of keys) this.elevated.add(k);
3770
+ } catch {
3771
+ }
3772
+ }
3467
3773
  async hydrateHistory() {
3468
3774
  if (this.historyLoaded || !this._group) return;
3469
3775
  this.historyLoaded = true;
@@ -3474,13 +3780,13 @@ var Chat = class {
3474
3780
  let changed = false;
3475
3781
  for (const m of incoming) {
3476
3782
  if (m.serverSeq <= 0) continue;
3477
- if (m.clientMsgId && m.text !== null) {
3783
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3478
3784
  this.byClientMsgId.set(m.clientMsgId, {
3479
3785
  text: m.text,
3480
3786
  senderUserId: m.senderUserId ?? ""
3481
3787
  });
3482
3788
  }
3483
- if (m.clientMsgId) {
3789
+ if (m.clientMsgId && !m.isDeleted) {
3484
3790
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3485
3791
  }
3486
3792
  }
@@ -3490,7 +3796,12 @@ var Chat = class {
3490
3796
  const key = this.internalKey(m.serverSeq);
3491
3797
  if (this.seenKeys.has(key)) continue;
3492
3798
  this.seenKeys.add(key);
3493
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3799
+ if (m.clientMsgId && !m.isDeleted) {
3800
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3801
+ }
3802
+ this.messageList.push(
3803
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3804
+ );
3494
3805
  changed = true;
3495
3806
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3496
3807
  }
@@ -3540,19 +3851,38 @@ var Chat = class {
3540
3851
  newText: incoming.edit.newText,
3541
3852
  epoch: incoming.epoch,
3542
3853
  serverSeq: incoming.serverSeq,
3543
- eventClientMsgId: incoming.clientMsgId
3854
+ eventClientMsgId: incoming.clientMsgId,
3855
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
3856
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
3857
+ bodyRanges: incoming.bodyRanges
3544
3858
  },
3545
3859
  this.authorOfTarget
3546
3860
  );
3547
3861
  this.recomputeEdit(incoming.edit.targetClientMsgId);
3548
3862
  return;
3549
3863
  }
3864
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3865
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3866
+ this.deleteFold.ingest(
3867
+ {
3868
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3869
+ actorUserId,
3870
+ epoch: incoming.epoch,
3871
+ serverSeq: incoming.serverSeq,
3872
+ eventClientMsgId: incoming.clientMsgId
3873
+ },
3874
+ this.authorOfTarget
3875
+ );
3876
+ this.emit();
3877
+ return;
3878
+ }
3550
3879
  const incomingClientMsgId = incoming.clientMsgId;
3551
3880
  const incomingReplyRef = incoming.replyRef;
3552
3881
  let resolvedReplyTo = null;
3553
3882
  if (incomingReplyRef) {
3554
3883
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3555
3884
  }
3885
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3556
3886
  const msg = {
3557
3887
  id: this.publicId(incoming.serverSeq),
3558
3888
  kind: this.kindOf(incoming),
@@ -3567,8 +3897,12 @@ var Chat = class {
3567
3897
  // BEFORE its target — the dangling case — renders the moment the target lands).
3568
3898
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3569
3899
  // Default false; applyEditOverlay below folds any edit that arrived first.
3570
- edited: false
3900
+ edited: false,
3901
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3902
+ isDeleted: false,
3903
+ mentions
3571
3904
  };
3905
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3572
3906
  if (incomingClientMsgId && incoming.text !== null) {
3573
3907
  this.byClientMsgId.set(incomingClientMsgId, {
3574
3908
  text: incoming.text,
@@ -3578,6 +3912,7 @@ var Chat = class {
3578
3912
  if (incomingClientMsgId) {
3579
3913
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3580
3914
  this.editFold.reevaluateHeld(this.authorOfTarget);
3915
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3581
3916
  }
3582
3917
  this.messageList.push(this.applyEditOverlay(msg));
3583
3918
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3591,6 +3926,75 @@ var Chat = class {
3591
3926
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3592
3927
  * so it can be passed to the pure EditFold. */
3593
3928
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3929
+ // ── Mentions (mentions T6) ──
3930
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3931
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
3932
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
3933
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
3934
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
3935
+ resolveMentions(text, bodyRanges) {
3936
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
3937
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
3938
+ if (normalized.length === 0) return [];
3939
+ return normalized.map((r) => ({
3940
+ start: r.start,
3941
+ length: r.length,
3942
+ mentionedUserId: r.mentionedUserId,
3943
+ displayName: this.displayNameOf(r.mentionedUserId)
3944
+ }));
3945
+ }
3946
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
3947
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
3948
+ * A member rename then reflects on old messages. Returns the message unchanged when
3949
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
3950
+ resolveMentionNames(m) {
3951
+ if (!m.mentions || m.mentions.length === 0) {
3952
+ return m.mentions ? m : { ...m, mentions: [] };
3953
+ }
3954
+ let changed = false;
3955
+ const reresolved = m.mentions.map((span) => {
3956
+ const name = this.displayNameOf(span.mentionedUserId);
3957
+ if (name === span.displayName) return span;
3958
+ changed = true;
3959
+ return { ...span, displayName: name };
3960
+ });
3961
+ if (!changed) return m;
3962
+ return { ...m, mentions: reresolved };
3963
+ }
3964
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
3965
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
3966
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
3967
+ editMentions(targetClientMsgId, newText) {
3968
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
3969
+ if (!ranges) return [];
3970
+ return this.resolveMentions(newText, ranges);
3971
+ }
3972
+ /** Resolve a userId → its roster display name (null if not a known member). */
3973
+ displayNameOf(userId) {
3974
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
3975
+ }
3976
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
3977
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
3978
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
3979
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
3980
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
3981
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
3982
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
3983
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
3984
+ const me = this.backend.selfUserId;
3985
+ if (envelopeType === "edit") return;
3986
+ if (senderUserId === me) return;
3987
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
3988
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3989
+ const key = `${me}|${idPart}`;
3990
+ if (this.elevated.has(key)) return;
3991
+ this.elevated.add(key);
3992
+ if (this._group) {
3993
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
3994
+ });
3995
+ }
3996
+ this.onMentionElevation?.(message);
3997
+ }
3594
3998
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3595
3999
  * (a later own/peer edit must not overwrite the original we render against). The
3596
4000
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3647,9 +4051,10 @@ var Chat = class {
3647
4051
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3648
4052
  const text = editText ?? base;
3649
4053
  const edited = foldEdited || m.edited;
3650
- if (m.text === text && m.edited === edited) return m;
4054
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
4055
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3651
4056
  changed = true;
3652
- return { ...m, text, edited };
4057
+ return { ...m, text, edited, mentions };
3653
4058
  });
3654
4059
  if (changed) this.emit();
3655
4060
  }
@@ -3666,8 +4071,9 @@ var Chat = class {
3666
4071
  if (editText === null && !foldEdited) return m;
3667
4072
  const text = editText ?? m.text;
3668
4073
  const edited = foldEdited || m.edited;
3669
- if (m.text === text && m.edited === edited) return m;
3670
- return { ...m, text, edited };
4074
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
4075
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
4076
+ return { ...m, text, edited, mentions };
3671
4077
  }
3672
4078
  /** @internal — called by the backend's conv subscription. */
3673
4079
  applyConv(event, payload) {
@@ -3725,6 +4131,18 @@ var Chat = class {
3725
4131
  }
3726
4132
  this.editFold.reevaluateHeld(this.authorOfTarget);
3727
4133
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
4134
+ this.reresolveAllMentionNames();
4135
+ }
4136
+ /** Re-resolve roster display names across the whole transcript (called on a roster
4137
+ * change). Re-emits only if any name actually changed. */
4138
+ reresolveAllMentionNames() {
4139
+ let changed = false;
4140
+ this.messageList = this.messageList.map((m) => {
4141
+ const reresolved = this.resolveMentionNames(m);
4142
+ if (reresolved !== m) changed = true;
4143
+ return reresolved;
4144
+ });
4145
+ if (changed) this.emit();
3728
4146
  }
3729
4147
  seedMembersFromGroup(group) {
3730
4148
  const seed = [
@@ -3792,11 +4210,12 @@ var Chat = class {
3792
4210
  };
3793
4211
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
3794
4212
  }
3795
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
3796
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4213
+ const bodyRanges = opts?.mentions ?? null;
4214
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4215
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
3797
4216
  return receipt;
3798
4217
  }
3799
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4218
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
3800
4219
  if (receipt.serverSeq <= 0) return;
3801
4220
  const key = this.internalKey(receipt.serverSeq);
3802
4221
  if (this.seenKeys.has(key)) return;
@@ -3819,7 +4238,12 @@ var Chat = class {
3819
4238
  // the dangling-target invariant uniform across every append path).
3820
4239
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3821
4240
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3822
- edited: false
4241
+ edited: false,
4242
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4243
+ isDeleted: false,
4244
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4245
+ // sender never gets a wire echo of its own message — this is the only local copy).
4246
+ mentions: this.resolveMentions(text, bodyRanges)
3823
4247
  });
3824
4248
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3825
4249
  this.emit();
@@ -3907,15 +4331,18 @@ var Chat = class {
3907
4331
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3908
4332
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3909
4333
  * reactions + reply context. Only the original author's edits count — for an own
3910
- * message self IS the author, so the author-gate passes. */
3911
- async edit(message, newText) {
4334
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4335
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4336
+ async edit(message, newText, opts) {
3912
4337
  if (!message.clientMsgId || message.kind !== "text") return;
3913
4338
  const group = await this.materializeIfNeeded();
3914
4339
  const clientMsgId = mintClientMsgId();
4340
+ const bodyRanges = opts?.mentions ?? null;
3915
4341
  const { receipt } = await this.backend.sendEdit(group, {
3916
4342
  clientMsgId,
3917
4343
  targetClientMsgId: message.clientMsgId,
3918
- newText
4344
+ newText,
4345
+ bodyRanges
3919
4346
  });
3920
4347
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3921
4348
  this.editFold.ingest(
@@ -3925,13 +4352,72 @@ var Chat = class {
3925
4352
  newText,
3926
4353
  epoch: receipt.epoch,
3927
4354
  serverSeq: receipt.serverSeq,
3928
- eventClientMsgId: clientMsgId
4355
+ eventClientMsgId: clientMsgId,
4356
+ bodyRanges
3929
4357
  },
3930
4358
  this.authorOfTarget
3931
4359
  );
3932
4360
  this.recomputeEdit(message.clientMsgId);
3933
4361
  }
4362
+ // ── Delete ──
4363
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
4364
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
4365
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
4366
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
4367
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
4368
+ * server stays blind), folds the own delete locally so the target scrubs in
4369
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
4370
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
4371
+ async deleteForEveryone(message) {
4372
+ if (!message.clientMsgId) return;
4373
+ const group = await this.materializeIfNeeded();
4374
+ const clientMsgId = mintClientMsgId();
4375
+ const { receipt } = await this.backend.sendDelete(group, {
4376
+ clientMsgId,
4377
+ targetClientMsgId: message.clientMsgId
4378
+ });
4379
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4380
+ this.deleteFold.ingest(
4381
+ {
4382
+ targetClientMsgId: message.clientMsgId,
4383
+ actorUserId: this.backend.selfUserId,
4384
+ epoch: receipt.epoch,
4385
+ serverSeq: receipt.serverSeq,
4386
+ eventClientMsgId: clientMsgId
4387
+ },
4388
+ this.authorOfTarget
4389
+ );
4390
+ this.emit();
4391
+ }
4392
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4393
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4394
+ * suppression key persists per chat (survives reload). The key is the message's
4395
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4396
+ async deleteForMe(message) {
4397
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4398
+ if (this.suppressed.has(key)) return;
4399
+ this.suppressed.add(key);
4400
+ this.emit();
4401
+ if (this._group) {
4402
+ try {
4403
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4404
+ } catch {
4405
+ }
4406
+ }
4407
+ }
3934
4408
  };
4409
+ function sameMentions(a, b) {
4410
+ if (a.length !== b.length) return false;
4411
+ for (let i = 0; i < a.length; i++) {
4412
+ const x = a[i];
4413
+ const y = b[i];
4414
+ if (!x || !y) return false;
4415
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4416
+ return false;
4417
+ }
4418
+ }
4419
+ return true;
4420
+ }
3935
4421
  function sameReactions(a, b) {
3936
4422
  const ak = Object.keys(a);
3937
4423
  const bk = Object.keys(b);
@@ -4111,6 +4597,7 @@ var MessageDeliverySource = class {
4111
4597
  const { text, clientMsgId, replyTo } = decoded;
4112
4598
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4113
4599
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4600
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
4114
4601
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4115
4602
  const stored = {
4116
4603
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4142,12 +4629,31 @@ var MessageDeliverySource = class {
4142
4629
  // Thread the edit discriminator + new text through the persisted row so an
4143
4630
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4144
4631
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4145
- // `'text'`/no-edit (backward-compat).
4632
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
4633
+ // along so the edited message's mentions re-resolve on cold launch (T6).
4146
4634
  ...isEdit && decoded.edit ? {
4147
4635
  envelopeType: "edit",
4148
4636
  edit: {
4149
4637
  targetClientMsgId: decoded.edit.targetClientMsgId,
4150
- newText: decoded.edit.newText
4638
+ newText: decoded.edit.newText,
4639
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4640
+ }
4641
+ } : {},
4642
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
4643
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
4644
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
4645
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
4646
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4647
+ // Thread the delete discriminator + target through the persisted row so a
4648
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4649
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4650
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4651
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4652
+ ...isDelete && decoded.delete ? {
4653
+ envelopeType: "delete",
4654
+ delete: {
4655
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4656
+ scope: decoded.delete.scope
4151
4657
  }
4152
4658
  } : {}
4153
4659
  };
@@ -4168,7 +4674,11 @@ var MessageDeliverySource = class {
4168
4674
  replyRef: replyTo,
4169
4675
  envelopeType: decoded.type ?? "text",
4170
4676
  reaction: isReaction ? decoded.reaction : null,
4171
- edit: isEdit ? decoded.edit : null
4677
+ edit: isEdit ? decoded.edit : null,
4678
+ delete: isDelete ? decoded.delete : null,
4679
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
4680
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4681
+ bodyRanges: decoded.bodyRanges ?? null
4172
4682
  });
4173
4683
  return true;
4174
4684
  }
@@ -4323,6 +4833,36 @@ var GroupCatalog = class {
4323
4833
  }
4324
4834
  };
4325
4835
 
4836
+ // src/messaging/mention-elevation.ts
4837
+ var MentionElevationStore = class {
4838
+ constructor(kv) {
4839
+ this.kv = kv;
4840
+ }
4841
+ kv;
4842
+ key(rfcGroupId) {
4843
+ return `elev:${rfcGroupId}`;
4844
+ }
4845
+ /** Load the persisted elevation keys for a chat (empty array if none). */
4846
+ async load(rfcGroupId) {
4847
+ const raw = await this.kv.get(this.key(rfcGroupId));
4848
+ if (!raw) return [];
4849
+ try {
4850
+ const parsed = JSON.parse(decodeUtf8(raw));
4851
+ return Array.isArray(parsed) ? parsed : [];
4852
+ } catch {
4853
+ return [];
4854
+ }
4855
+ }
4856
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
4857
+ async save(rfcGroupId, keys) {
4858
+ const sorted = [...new Set(keys)].sort();
4859
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
4860
+ }
4861
+ async wipe() {
4862
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
4863
+ }
4864
+ };
4865
+
4326
4866
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4327
4867
  var palbe_mls_bg_exports = {};
4328
4868
  __export(palbe_mls_bg_exports, {
@@ -5989,6 +6529,36 @@ var SignatureKeyStore = class {
5989
6529
  }
5990
6530
  };
5991
6531
 
6532
+ // src/messaging/suppression.ts
6533
+ var SuppressionStore = class {
6534
+ constructor(kv) {
6535
+ this.kv = kv;
6536
+ }
6537
+ kv;
6538
+ key(rfcGroupId) {
6539
+ return `supp:${rfcGroupId}`;
6540
+ }
6541
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6542
+ async load(rfcGroupId) {
6543
+ const raw = await this.kv.get(this.key(rfcGroupId));
6544
+ if (!raw) return [];
6545
+ try {
6546
+ const parsed = JSON.parse(decodeUtf8(raw));
6547
+ return Array.isArray(parsed) ? parsed : [];
6548
+ } catch {
6549
+ return [];
6550
+ }
6551
+ }
6552
+ /** Persist the full suppression key set for a chat (deterministic order). */
6553
+ async save(rfcGroupId, keys) {
6554
+ const sorted = [...new Set(keys)].sort();
6555
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6556
+ }
6557
+ async wipe() {
6558
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6559
+ }
6560
+ };
6561
+
5992
6562
  // src/messaging/coordinator.ts
5993
6563
  var MessagingCoordinator = class {
5994
6564
  constructor(rt) {
@@ -5998,6 +6568,8 @@ var MessagingCoordinator = class {
5998
6568
  this.sigStore = new SignatureKeyStore(this.kv);
5999
6569
  this.groupStore = new GroupStateStorage(this.kv);
6000
6570
  this.kpStore = new KeyPackageStorage(this.kv);
6571
+ this.suppressionStore = new SuppressionStore(this.kv);
6572
+ this.elevationStore = new MentionElevationStore(this.kv);
6001
6573
  this.registry.attachChatList(
6002
6574
  (chats) => {
6003
6575
  this.chatList = chats;
@@ -6012,6 +6584,8 @@ var MessagingCoordinator = class {
6012
6584
  sigStore;
6013
6585
  groupStore;
6014
6586
  kpStore;
6587
+ suppressionStore;
6588
+ elevationStore;
6015
6589
  registry = new GroupRegistry();
6016
6590
  resolved = null;
6017
6591
  resolvePromise = null;
@@ -6167,9 +6741,9 @@ var MessagingCoordinator = class {
6167
6741
  });
6168
6742
  return group;
6169
6743
  }
6170
- async sendText(group, text, replyTo) {
6744
+ async sendText(group, text, replyTo, bodyRanges) {
6171
6745
  const r = await this.resolve();
6172
- return r.groups.sendText(group, text, replyTo);
6746
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6173
6747
  }
6174
6748
  async sendReaction(group, args) {
6175
6749
  const r = await this.resolve();
@@ -6179,6 +6753,26 @@ var MessagingCoordinator = class {
6179
6753
  const r = await this.resolve();
6180
6754
  return r.groups.sendEdit(group, args);
6181
6755
  }
6756
+ async sendDelete(group, args) {
6757
+ const r = await this.resolve();
6758
+ return r.groups.sendDelete(group, args);
6759
+ }
6760
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6761
+ loadSuppressed(group) {
6762
+ return this.suppressionStore.load(group.rfcGroupId);
6763
+ }
6764
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6765
+ saveSuppressed(group, keys) {
6766
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6767
+ }
6768
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
6769
+ loadElevated(group) {
6770
+ return this.elevationStore.load(group.rfcGroupId);
6771
+ }
6772
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
6773
+ saveElevated(group, keys) {
6774
+ return this.elevationStore.save(group.rfcGroupId, keys);
6775
+ }
6182
6776
  async history(group, limit, before) {
6183
6777
  const r = await this.resolve();
6184
6778
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6276,9 +6870,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6276
6870
  });
6277
6871
  }
6278
6872
  const editFold = new EditFold();
6873
+ const deleteFold = new DeleteFold();
6279
6874
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6280
6875
  for (const s of rows) {
6281
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6876
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6877
+ continue;
6282
6878
  const cid = s.clientMsgId ?? "";
6283
6879
  if (!cid) continue;
6284
6880
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -6295,15 +6891,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6295
6891
  newText: s.edit.newText,
6296
6892
  epoch: s.epoch,
6297
6893
  serverSeq: s.serverSeq,
6298
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
6894
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
6895
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
6896
+ // edit's ranges drive the edited message's mentions on cold launch.
6897
+ bodyRanges: s.edit.bodyRanges ?? null
6299
6898
  },
6300
6899
  authorOfTarget
6301
6900
  );
6302
6901
  }
6303
6902
  editFold.reevaluateHeld(authorOfTarget);
6903
+ for (const s of rows) {
6904
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6905
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6906
+ deleteFold.ingest(
6907
+ {
6908
+ targetClientMsgId: s.delete.targetClientMsgId,
6909
+ actorUserId: actor,
6910
+ epoch: s.epoch,
6911
+ serverSeq: s.serverSeq,
6912
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6913
+ },
6914
+ authorOfTarget
6915
+ );
6916
+ }
6917
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
6304
6918
  const lookup = /* @__PURE__ */ new Map();
6305
6919
  for (const s of rows) {
6306
- if (s.envelopeType === "reaction") continue;
6920
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6921
+ continue;
6307
6922
  const cid = s.clientMsgId ?? "";
6308
6923
  if (cid && s.text !== null) {
6309
6924
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6312,8 +6927,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6312
6927
  }
6313
6928
  const out = [];
6314
6929
  for (const s of rows) {
6315
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6930
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6931
+ continue;
6316
6932
  const clientMsgId = s.clientMsgId ?? "";
6933
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6934
+ if (isDeleted) {
6935
+ out.push({
6936
+ id: `${displayId}#${s.serverSeq}`,
6937
+ kind: "text",
6938
+ direction: s.direction,
6939
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6940
+ text: DELETED_DESCRIPTOR,
6941
+ serverSeq: s.serverSeq,
6942
+ sentAt: new Date(s.at),
6943
+ clientMsgId,
6944
+ replyTo: null,
6945
+ reactions: {},
6946
+ edited: false,
6947
+ isDeleted: true,
6948
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6949
+ mentions: []
6950
+ });
6951
+ continue;
6952
+ }
6317
6953
  let replyTo = null;
6318
6954
  if (s.replyTo) {
6319
6955
  const ref = {
@@ -6330,22 +6966,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6330
6966
  }
6331
6967
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6332
6968
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6969
+ const text = editText ?? s.text;
6970
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
6971
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6333
6972
  out.push({
6334
6973
  id: `${displayId}#${s.serverSeq}`,
6335
6974
  kind: s.text != null ? "text" : "system",
6336
6975
  direction: s.direction,
6337
6976
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6338
- text: editText ?? s.text,
6977
+ text,
6339
6978
  serverSeq: s.serverSeq,
6340
6979
  sentAt: new Date(s.at),
6341
6980
  clientMsgId,
6342
6981
  replyTo,
6343
6982
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6344
- edited
6983
+ edited,
6984
+ isDeleted: false,
6985
+ mentions
6345
6986
  });
6346
6987
  }
6347
6988
  return out;
6348
6989
  }
6990
+ function normalizeMentionsNullNames(raw, text) {
6991
+ if (text === null || !raw || raw.length === 0) return [];
6992
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
6993
+ start: r.start,
6994
+ length: r.length,
6995
+ mentionedUserId: r.mentionedUserId,
6996
+ displayName: null
6997
+ }));
6998
+ }
6349
6999
 
6350
7000
  // src/messaging/facade.ts
6351
7001
  var PalbeMessaging = class {
@@ -7079,7 +7729,7 @@ function defaultSessionStorage(key) {
7079
7729
  }
7080
7730
 
7081
7731
  // src/version.ts
7082
- var VERSION = "1.3.0";
7732
+ var VERSION = "1.5.0";
7083
7733
 
7084
7734
  // src/runtime.ts
7085
7735
  function buildRuntime(config) {
@@ -7439,4 +8089,4 @@ export {
7439
8089
  pb,
7440
8090
  createBoundClient
7441
8091
  };
7442
- //# sourceMappingURL=chunk-UX43AB4W.js.map
8092
+ //# sourceMappingURL=chunk-MBA2NAKS.js.map