@palbase/web 1.4.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.
@@ -2521,7 +2521,8 @@ var EditFold = class {
2521
2521
  orderEpoch: e.epoch,
2522
2522
  orderSeq: e.serverSeq,
2523
2523
  lastEventId: e.eventClientMsgId,
2524
- text: e.newText
2524
+ text: e.newText,
2525
+ bodyRanges: e.bodyRanges ?? null
2525
2526
  });
2526
2527
  this.editedTargets.add(e.targetClientMsgId);
2527
2528
  }
@@ -2542,6 +2543,15 @@ var EditFold = class {
2542
2543
  isEdited(targetClientMsgId) {
2543
2544
  return this.editedTargets.has(targetClientMsgId);
2544
2545
  }
2546
+ /**
2547
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2548
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2549
+ * normalizes these against the edited text to compute the edited message's mentions
2550
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2551
+ */
2552
+ bodyRanges(targetClientMsgId) {
2553
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2554
+ }
2545
2555
  /**
2546
2556
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2547
2557
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2715,7 +2725,14 @@ function encodeEdit(args) {
2715
2725
  type: "edit",
2716
2726
  client_msg_id: args.clientMsgId,
2717
2727
  target_client_msg_id: args.targetClientMsgId,
2718
- new_text: args.newText
2728
+ new_text: args.newText,
2729
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2730
+ body_ranges: args.bodyRanges.map((r) => ({
2731
+ start: r.start,
2732
+ length: r.length,
2733
+ mentioned_user_id: r.mentionedUserId
2734
+ }))
2735
+ } : {}
2719
2736
  })
2720
2737
  );
2721
2738
  }
@@ -2737,7 +2754,14 @@ function encodeEnvelope(args) {
2737
2754
  type: "text",
2738
2755
  client_msg_id: args.clientMsgId,
2739
2756
  text: args.text,
2740
- ...args.replyTo ? { reply_to: args.replyTo } : {}
2757
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
2758
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2759
+ body_ranges: args.bodyRanges.map((r) => ({
2760
+ start: r.start,
2761
+ length: r.length,
2762
+ mentioned_user_id: r.mentionedUserId
2763
+ }))
2764
+ } : {}
2741
2765
  };
2742
2766
  return encodeUtf8(JSON.stringify(env));
2743
2767
  }
@@ -2771,6 +2795,7 @@ function decodeEnvelope(bytes) {
2771
2795
  };
2772
2796
  }
2773
2797
  if (typeof o === "object" && o !== null && o.type === "edit") {
2798
+ const editRanges = decodeBodyRanges(o.body_ranges);
2774
2799
  return {
2775
2800
  type: "edit",
2776
2801
  text: null,
@@ -2779,15 +2804,18 @@ function decodeEnvelope(bytes) {
2779
2804
  edit: {
2780
2805
  targetClientMsgId: o.target_client_msg_id ?? "",
2781
2806
  newText: o.new_text ?? ""
2782
- }
2807
+ },
2808
+ ...editRanges ? { bodyRanges: editRanges } : {}
2783
2809
  };
2784
2810
  }
2785
2811
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2812
+ const textRanges = decodeBodyRanges(o.body_ranges);
2786
2813
  return {
2787
2814
  type: "text",
2788
2815
  text: o.text ?? null,
2789
2816
  clientMsgId: o.client_msg_id ?? "",
2790
- replyTo: o.reply_to ?? null
2817
+ replyTo: o.reply_to ?? null,
2818
+ ...textRanges ? { bodyRanges: textRanges } : {}
2791
2819
  };
2792
2820
  }
2793
2821
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2799,6 +2827,14 @@ function decodeEnvelope(bytes) {
2799
2827
  }
2800
2828
  return { text: s, clientMsgId: "", replyTo: null };
2801
2829
  }
2830
+ function decodeBodyRanges(raw) {
2831
+ if (!raw || raw.length === 0) return void 0;
2832
+ return raw.map((r) => ({
2833
+ start: r.start,
2834
+ length: r.length,
2835
+ mentionedUserId: r.mentioned_user_id
2836
+ }));
2837
+ }
2802
2838
  function resolveReply(ref, lookup) {
2803
2839
  const parent = lookup(ref.client_msg_id);
2804
2840
  if (parent !== null) {
@@ -3010,9 +3046,9 @@ var GroupMessaging = class {
3010
3046
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3011
3047
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3012
3048
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3013
- async sendText(group, text, replyTo) {
3049
+ async sendText(group, text, replyTo, bodyRanges) {
3014
3050
  const clientMsgId = mintClientMsgId();
3015
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3051
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3016
3052
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3017
3053
  const body = {
3018
3054
  ciphertext_b64: toBase64(ct),
@@ -3038,7 +3074,10 @@ var GroupMessaging = class {
3038
3074
  previewBody: replyTo.preview?.body ?? null,
3039
3075
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3040
3076
  previewKind: replyTo.preview?.kind ?? "text"
3041
- } : null
3077
+ } : null,
3078
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3079
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3080
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3042
3081
  };
3043
3082
  try {
3044
3083
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3106,7 +3145,8 @@ var GroupMessaging = class {
3106
3145
  const plaintext = encodeEdit({
3107
3146
  clientMsgId: args.clientMsgId,
3108
3147
  targetClientMsgId: args.targetClientMsgId,
3109
- newText: args.newText
3148
+ newText: args.newText,
3149
+ bodyRanges: args.bodyRanges
3110
3150
  });
3111
3151
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3112
3152
  const body = {
@@ -3132,7 +3172,10 @@ var GroupMessaging = class {
3132
3172
  envelopeType: "edit",
3133
3173
  edit: {
3134
3174
  targetClientMsgId: args.targetClientMsgId,
3135
- newText: args.newText
3175
+ newText: args.newText,
3176
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3177
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3178
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3136
3179
  }
3137
3180
  };
3138
3181
  try {
@@ -3254,6 +3297,41 @@ var GroupMessaging = class {
3254
3297
  }
3255
3298
  };
3256
3299
 
3300
+ // src/messaging/mention-ranges.ts
3301
+ function normalizeMentionRangesUtf16(ranges, text) {
3302
+ const n = text.length;
3303
+ function splitsSurrogatePair(index) {
3304
+ if (index <= 0 || index >= n) return false;
3305
+ const before = text.charCodeAt(index - 1);
3306
+ const at = text.charCodeAt(index);
3307
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3308
+ const atIsLow = at >= 56320 && at <= 57343;
3309
+ return beforeIsHigh && atIsLow;
3310
+ }
3311
+ const survivors = [];
3312
+ for (let idx = 0; idx < ranges.length; idx++) {
3313
+ const r = ranges[idx];
3314
+ if (r === void 0) continue;
3315
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3316
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3317
+ survivors.push({ idx, range: r });
3318
+ }
3319
+ survivors.sort((lhs, rhs) => {
3320
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3321
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3322
+ return lhs.idx - rhs.idx;
3323
+ });
3324
+ const kept = [];
3325
+ let prevEnd = Number.NEGATIVE_INFINITY;
3326
+ for (const s of survivors) {
3327
+ if (s.range.start >= prevEnd) {
3328
+ kept.push(s.range);
3329
+ prevEnd = s.range.start + s.range.length;
3330
+ }
3331
+ }
3332
+ return kept;
3333
+ }
3334
+
3257
3335
  // src/messaging/reaction-fold.ts
3258
3336
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3259
3337
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3341,6 +3419,13 @@ var Chat = class {
3341
3419
  /** True once the persisted suppression set has been loaded (so the omit applies
3342
3420
  * even on the cold-launch hydrate path before a fresh deleteForMe). */
3343
3421
  suppressedLoaded = false;
3422
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3423
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3424
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3425
+ elevated = /* @__PURE__ */ new Set();
3426
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3427
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3428
+ elevatedLoaded = false;
3344
3429
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3345
3430
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3346
3431
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3352,6 +3437,14 @@ var Chat = class {
3352
3437
  wired = false;
3353
3438
  liveUnsub = null;
3354
3439
  listeners = /* @__PURE__ */ new Set();
3440
+ /**
3441
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3442
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3443
+ * via the persisted elevation set, so this never double-fires for one mention. The
3444
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3445
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3446
+ */
3447
+ onMentionElevation;
3355
3448
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3356
3449
  constructor(args) {
3357
3450
  this.backend = args.backend;
@@ -3429,7 +3522,8 @@ var Chat = class {
3429
3522
  reactions: {},
3430
3523
  replyTo: null,
3431
3524
  edited: false,
3432
- isDeleted: true
3525
+ isDeleted: true,
3526
+ mentions: []
3433
3527
  });
3434
3528
  continue;
3435
3529
  }
@@ -3465,6 +3559,7 @@ var Chat = class {
3465
3559
  this.wired = true;
3466
3560
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3467
3561
  void this.loadSuppressed();
3562
+ void this.loadElevated();
3468
3563
  void this.hydrateHistory();
3469
3564
  void this.refreshMembers();
3470
3565
  }
@@ -3486,6 +3581,17 @@ var Chat = class {
3486
3581
  } catch {
3487
3582
  }
3488
3583
  }
3584
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
3585
+ * gates the elevation DECISION, it does not change what renders. */
3586
+ async loadElevated() {
3587
+ if (this.elevatedLoaded || !this._group) return;
3588
+ this.elevatedLoaded = true;
3589
+ try {
3590
+ const keys = await this.backend.loadElevated(this._group);
3591
+ for (const k of keys) this.elevated.add(k);
3592
+ } catch {
3593
+ }
3594
+ }
3489
3595
  async hydrateHistory() {
3490
3596
  if (this.historyLoaded || !this._group) return;
3491
3597
  this.historyLoaded = true;
@@ -3515,7 +3621,9 @@ var Chat = class {
3515
3621
  if (m.clientMsgId && !m.isDeleted) {
3516
3622
  this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3517
3623
  }
3518
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3624
+ this.messageList.push(
3625
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3626
+ );
3519
3627
  changed = true;
3520
3628
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3521
3629
  }
@@ -3565,7 +3673,10 @@ var Chat = class {
3565
3673
  newText: incoming.edit.newText,
3566
3674
  epoch: incoming.epoch,
3567
3675
  serverSeq: incoming.serverSeq,
3568
- eventClientMsgId: incoming.clientMsgId
3676
+ eventClientMsgId: incoming.clientMsgId,
3677
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
3678
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
3679
+ bodyRanges: incoming.bodyRanges
3569
3680
  },
3570
3681
  this.authorOfTarget
3571
3682
  );
@@ -3593,6 +3704,7 @@ var Chat = class {
3593
3704
  if (incomingReplyRef) {
3594
3705
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3595
3706
  }
3707
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3596
3708
  const msg = {
3597
3709
  id: this.publicId(incoming.serverSeq),
3598
3710
  kind: this.kindOf(incoming),
@@ -3609,8 +3721,10 @@ var Chat = class {
3609
3721
  // Default false; applyEditOverlay below folds any edit that arrived first.
3610
3722
  edited: false,
3611
3723
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3612
- isDeleted: false
3724
+ isDeleted: false,
3725
+ mentions
3613
3726
  };
3727
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3614
3728
  if (incomingClientMsgId && incoming.text !== null) {
3615
3729
  this.byClientMsgId.set(incomingClientMsgId, {
3616
3730
  text: incoming.text,
@@ -3634,6 +3748,75 @@ var Chat = class {
3634
3748
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3635
3749
  * so it can be passed to the pure EditFold. */
3636
3750
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3751
+ // ── Mentions (mentions T6) ──
3752
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3753
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
3754
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
3755
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
3756
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
3757
+ resolveMentions(text, bodyRanges) {
3758
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
3759
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
3760
+ if (normalized.length === 0) return [];
3761
+ return normalized.map((r) => ({
3762
+ start: r.start,
3763
+ length: r.length,
3764
+ mentionedUserId: r.mentionedUserId,
3765
+ displayName: this.displayNameOf(r.mentionedUserId)
3766
+ }));
3767
+ }
3768
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
3769
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
3770
+ * A member rename then reflects on old messages. Returns the message unchanged when
3771
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
3772
+ resolveMentionNames(m) {
3773
+ if (!m.mentions || m.mentions.length === 0) {
3774
+ return m.mentions ? m : { ...m, mentions: [] };
3775
+ }
3776
+ let changed = false;
3777
+ const reresolved = m.mentions.map((span) => {
3778
+ const name = this.displayNameOf(span.mentionedUserId);
3779
+ if (name === span.displayName) return span;
3780
+ changed = true;
3781
+ return { ...span, displayName: name };
3782
+ });
3783
+ if (!changed) return m;
3784
+ return { ...m, mentions: reresolved };
3785
+ }
3786
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
3787
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
3788
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
3789
+ editMentions(targetClientMsgId, newText) {
3790
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
3791
+ if (!ranges) return [];
3792
+ return this.resolveMentions(newText, ranges);
3793
+ }
3794
+ /** Resolve a userId → its roster display name (null if not a known member). */
3795
+ displayNameOf(userId) {
3796
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
3797
+ }
3798
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
3799
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
3800
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
3801
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
3802
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
3803
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
3804
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
3805
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
3806
+ const me = this.backend.selfUserId;
3807
+ if (envelopeType === "edit") return;
3808
+ if (senderUserId === me) return;
3809
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
3810
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3811
+ const key = `${me}|${idPart}`;
3812
+ if (this.elevated.has(key)) return;
3813
+ this.elevated.add(key);
3814
+ if (this._group) {
3815
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
3816
+ });
3817
+ }
3818
+ this.onMentionElevation?.(message);
3819
+ }
3637
3820
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3638
3821
  * (a later own/peer edit must not overwrite the original we render against). The
3639
3822
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3690,9 +3873,10 @@ var Chat = class {
3690
3873
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3691
3874
  const text = editText ?? base;
3692
3875
  const edited = foldEdited || m.edited;
3693
- if (m.text === text && m.edited === edited) return m;
3876
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
3877
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3694
3878
  changed = true;
3695
- return { ...m, text, edited };
3879
+ return { ...m, text, edited, mentions };
3696
3880
  });
3697
3881
  if (changed) this.emit();
3698
3882
  }
@@ -3709,8 +3893,9 @@ var Chat = class {
3709
3893
  if (editText === null && !foldEdited) return m;
3710
3894
  const text = editText ?? m.text;
3711
3895
  const edited = foldEdited || m.edited;
3712
- if (m.text === text && m.edited === edited) return m;
3713
- return { ...m, text, edited };
3896
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
3897
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3898
+ return { ...m, text, edited, mentions };
3714
3899
  }
3715
3900
  /** @internal — called by the backend's conv subscription. */
3716
3901
  applyConv(event, payload) {
@@ -3768,6 +3953,18 @@ var Chat = class {
3768
3953
  }
3769
3954
  this.editFold.reevaluateHeld(this.authorOfTarget);
3770
3955
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3956
+ this.reresolveAllMentionNames();
3957
+ }
3958
+ /** Re-resolve roster display names across the whole transcript (called on a roster
3959
+ * change). Re-emits only if any name actually changed. */
3960
+ reresolveAllMentionNames() {
3961
+ let changed = false;
3962
+ this.messageList = this.messageList.map((m) => {
3963
+ const reresolved = this.resolveMentionNames(m);
3964
+ if (reresolved !== m) changed = true;
3965
+ return reresolved;
3966
+ });
3967
+ if (changed) this.emit();
3771
3968
  }
3772
3969
  seedMembersFromGroup(group) {
3773
3970
  const seed = [
@@ -3835,11 +4032,12 @@ var Chat = class {
3835
4032
  };
3836
4033
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
3837
4034
  }
3838
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
3839
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4035
+ const bodyRanges = opts?.mentions ?? null;
4036
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4037
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
3840
4038
  return receipt;
3841
4039
  }
3842
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4040
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
3843
4041
  if (receipt.serverSeq <= 0) return;
3844
4042
  const key = this.internalKey(receipt.serverSeq);
3845
4043
  if (this.seenKeys.has(key)) return;
@@ -3864,7 +4062,10 @@ var Chat = class {
3864
4062
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3865
4063
  edited: false,
3866
4064
  // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
3867
- isDeleted: false
4065
+ isDeleted: false,
4066
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4067
+ // sender never gets a wire echo of its own message — this is the only local copy).
4068
+ mentions: this.resolveMentions(text, bodyRanges)
3868
4069
  });
3869
4070
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3870
4071
  this.emit();
@@ -3952,15 +4153,18 @@ var Chat = class {
3952
4153
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3953
4154
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3954
4155
  * reactions + reply context. Only the original author's edits count — for an own
3955
- * message self IS the author, so the author-gate passes. */
3956
- async edit(message, newText) {
4156
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4157
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4158
+ async edit(message, newText, opts) {
3957
4159
  if (!message.clientMsgId || message.kind !== "text") return;
3958
4160
  const group = await this.materializeIfNeeded();
3959
4161
  const clientMsgId = mintClientMsgId();
4162
+ const bodyRanges = opts?.mentions ?? null;
3960
4163
  const { receipt } = await this.backend.sendEdit(group, {
3961
4164
  clientMsgId,
3962
4165
  targetClientMsgId: message.clientMsgId,
3963
- newText
4166
+ newText,
4167
+ bodyRanges
3964
4168
  });
3965
4169
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3966
4170
  this.editFold.ingest(
@@ -3970,7 +4174,8 @@ var Chat = class {
3970
4174
  newText,
3971
4175
  epoch: receipt.epoch,
3972
4176
  serverSeq: receipt.serverSeq,
3973
- eventClientMsgId: clientMsgId
4177
+ eventClientMsgId: clientMsgId,
4178
+ bodyRanges
3974
4179
  },
3975
4180
  this.authorOfTarget
3976
4181
  );
@@ -4023,6 +4228,18 @@ var Chat = class {
4023
4228
  }
4024
4229
  }
4025
4230
  };
4231
+ function sameMentions(a, b) {
4232
+ if (a.length !== b.length) return false;
4233
+ for (let i = 0; i < a.length; i++) {
4234
+ const x = a[i];
4235
+ const y = b[i];
4236
+ if (!x || !y) return false;
4237
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4238
+ return false;
4239
+ }
4240
+ }
4241
+ return true;
4242
+ }
4026
4243
  function sameReactions(a, b) {
4027
4244
  const ak = Object.keys(a);
4028
4245
  const bk = Object.keys(b);
@@ -4234,14 +4451,21 @@ var MessageDeliverySource = class {
4234
4451
  // Thread the edit discriminator + new text through the persisted row so an
4235
4452
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4236
4453
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4237
- // `'text'`/no-edit (backward-compat).
4454
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
4455
+ // along so the edited message's mentions re-resolve on cold launch (T6).
4238
4456
  ...isEdit && decoded.edit ? {
4239
4457
  envelopeType: "edit",
4240
4458
  edit: {
4241
4459
  targetClientMsgId: decoded.edit.targetClientMsgId,
4242
- newText: decoded.edit.newText
4460
+ newText: decoded.edit.newText,
4461
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4243
4462
  }
4244
4463
  } : {},
4464
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
4465
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
4466
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
4467
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
4468
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4245
4469
  // Thread the delete discriminator + target through the persisted row so a
4246
4470
  // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4247
4471
  // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
@@ -4273,7 +4497,10 @@ var MessageDeliverySource = class {
4273
4497
  envelopeType: decoded.type ?? "text",
4274
4498
  reaction: isReaction ? decoded.reaction : null,
4275
4499
  edit: isEdit ? decoded.edit : null,
4276
- delete: isDelete ? decoded.delete : null
4500
+ delete: isDelete ? decoded.delete : null,
4501
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
4502
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4503
+ bodyRanges: decoded.bodyRanges ?? null
4277
4504
  });
4278
4505
  return true;
4279
4506
  }
@@ -4428,6 +4655,36 @@ var GroupCatalog = class {
4428
4655
  }
4429
4656
  };
4430
4657
 
4658
+ // src/messaging/mention-elevation.ts
4659
+ var MentionElevationStore = class {
4660
+ constructor(kv) {
4661
+ this.kv = kv;
4662
+ }
4663
+ kv;
4664
+ key(rfcGroupId) {
4665
+ return `elev:${rfcGroupId}`;
4666
+ }
4667
+ /** Load the persisted elevation keys for a chat (empty array if none). */
4668
+ async load(rfcGroupId) {
4669
+ const raw = await this.kv.get(this.key(rfcGroupId));
4670
+ if (!raw) return [];
4671
+ try {
4672
+ const parsed = JSON.parse(decodeUtf8(raw));
4673
+ return Array.isArray(parsed) ? parsed : [];
4674
+ } catch {
4675
+ return [];
4676
+ }
4677
+ }
4678
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
4679
+ async save(rfcGroupId, keys) {
4680
+ const sorted = [...new Set(keys)].sort();
4681
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
4682
+ }
4683
+ async wipe() {
4684
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
4685
+ }
4686
+ };
4687
+
4431
4688
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4432
4689
  var palbe_mls_bg_exports = {};
4433
4690
  __export(palbe_mls_bg_exports, {
@@ -6135,6 +6392,7 @@ var MessagingCoordinator = class {
6135
6392
  this.groupStore = new GroupStateStorage(this.kv);
6136
6393
  this.kpStore = new KeyPackageStorage(this.kv);
6137
6394
  this.suppressionStore = new SuppressionStore(this.kv);
6395
+ this.elevationStore = new MentionElevationStore(this.kv);
6138
6396
  this.registry.attachChatList(
6139
6397
  (chats) => {
6140
6398
  this.chatList = chats;
@@ -6150,6 +6408,7 @@ var MessagingCoordinator = class {
6150
6408
  groupStore;
6151
6409
  kpStore;
6152
6410
  suppressionStore;
6411
+ elevationStore;
6153
6412
  registry = new GroupRegistry();
6154
6413
  resolved = null;
6155
6414
  resolvePromise = null;
@@ -6305,9 +6564,9 @@ var MessagingCoordinator = class {
6305
6564
  });
6306
6565
  return group;
6307
6566
  }
6308
- async sendText(group, text, replyTo) {
6567
+ async sendText(group, text, replyTo, bodyRanges) {
6309
6568
  const r = await this.resolve();
6310
- return r.groups.sendText(group, text, replyTo);
6569
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6311
6570
  }
6312
6571
  async sendReaction(group, args) {
6313
6572
  const r = await this.resolve();
@@ -6329,6 +6588,14 @@ var MessagingCoordinator = class {
6329
6588
  saveSuppressed(group, keys) {
6330
6589
  return this.suppressionStore.save(group.rfcGroupId, keys);
6331
6590
  }
6591
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
6592
+ loadElevated(group) {
6593
+ return this.elevationStore.load(group.rfcGroupId);
6594
+ }
6595
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
6596
+ saveElevated(group, keys) {
6597
+ return this.elevationStore.save(group.rfcGroupId, keys);
6598
+ }
6332
6599
  async history(group, limit, before) {
6333
6600
  const r = await this.resolve();
6334
6601
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6447,7 +6714,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6447
6714
  newText: s.edit.newText,
6448
6715
  epoch: s.epoch,
6449
6716
  serverSeq: s.serverSeq,
6450
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
6717
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
6718
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
6719
+ // edit's ranges drive the edited message's mentions on cold launch.
6720
+ bodyRanges: s.edit.bodyRanges ?? null
6451
6721
  },
6452
6722
  authorOfTarget
6453
6723
  );
@@ -6497,7 +6767,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6497
6767
  replyTo: null,
6498
6768
  reactions: {},
6499
6769
  edited: false,
6500
- isDeleted: true
6770
+ isDeleted: true,
6771
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6772
+ mentions: []
6501
6773
  });
6502
6774
  continue;
6503
6775
  }
@@ -6517,23 +6789,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6517
6789
  }
6518
6790
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6519
6791
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6792
+ const text = editText ?? s.text;
6793
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
6794
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6520
6795
  out.push({
6521
6796
  id: `${displayId}#${s.serverSeq}`,
6522
6797
  kind: s.text != null ? "text" : "system",
6523
6798
  direction: s.direction,
6524
6799
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6525
- text: editText ?? s.text,
6800
+ text,
6526
6801
  serverSeq: s.serverSeq,
6527
6802
  sentAt: new Date(s.at),
6528
6803
  clientMsgId,
6529
6804
  replyTo,
6530
6805
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6531
6806
  edited,
6532
- isDeleted: false
6807
+ isDeleted: false,
6808
+ mentions
6533
6809
  });
6534
6810
  }
6535
6811
  return out;
6536
6812
  }
6813
+ function normalizeMentionsNullNames(raw, text) {
6814
+ if (text === null || !raw || raw.length === 0) return [];
6815
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
6816
+ start: r.start,
6817
+ length: r.length,
6818
+ mentionedUserId: r.mentionedUserId,
6819
+ displayName: null
6820
+ }));
6821
+ }
6537
6822
 
6538
6823
  // src/messaging/facade.ts
6539
6824
  var PalbeMessaging = class {
@@ -7267,7 +7552,7 @@ function defaultSessionStorage(key) {
7267
7552
  }
7268
7553
 
7269
7554
  // src/version.ts
7270
- var VERSION = "1.4.0";
7555
+ var VERSION = "1.5.0";
7271
7556
 
7272
7557
  // src/runtime.ts
7273
7558
  function buildRuntime(config) {