@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.
@@ -2699,7 +2699,8 @@ var EditFold = class {
2699
2699
  orderEpoch: e.epoch,
2700
2700
  orderSeq: e.serverSeq,
2701
2701
  lastEventId: e.eventClientMsgId,
2702
- text: e.newText
2702
+ text: e.newText,
2703
+ bodyRanges: e.bodyRanges ?? null
2703
2704
  });
2704
2705
  this.editedTargets.add(e.targetClientMsgId);
2705
2706
  }
@@ -2720,6 +2721,15 @@ var EditFold = class {
2720
2721
  isEdited(targetClientMsgId) {
2721
2722
  return this.editedTargets.has(targetClientMsgId);
2722
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
+ }
2723
2733
  /**
2724
2734
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2725
2735
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2893,7 +2903,14 @@ function encodeEdit(args) {
2893
2903
  type: "edit",
2894
2904
  client_msg_id: args.clientMsgId,
2895
2905
  target_client_msg_id: args.targetClientMsgId,
2896
- 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
+ } : {}
2897
2914
  })
2898
2915
  );
2899
2916
  }
@@ -2915,7 +2932,14 @@ function encodeEnvelope(args) {
2915
2932
  type: "text",
2916
2933
  client_msg_id: args.clientMsgId,
2917
2934
  text: args.text,
2918
- ...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
+ } : {}
2919
2943
  };
2920
2944
  return encodeUtf8(JSON.stringify(env));
2921
2945
  }
@@ -2949,6 +2973,7 @@ function decodeEnvelope(bytes) {
2949
2973
  };
2950
2974
  }
2951
2975
  if (typeof o === "object" && o !== null && o.type === "edit") {
2976
+ const editRanges = decodeBodyRanges(o.body_ranges);
2952
2977
  return {
2953
2978
  type: "edit",
2954
2979
  text: null,
@@ -2957,15 +2982,18 @@ function decodeEnvelope(bytes) {
2957
2982
  edit: {
2958
2983
  targetClientMsgId: o.target_client_msg_id ?? "",
2959
2984
  newText: o.new_text ?? ""
2960
- }
2985
+ },
2986
+ ...editRanges ? { bodyRanges: editRanges } : {}
2961
2987
  };
2962
2988
  }
2963
2989
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2990
+ const textRanges = decodeBodyRanges(o.body_ranges);
2964
2991
  return {
2965
2992
  type: "text",
2966
2993
  text: o.text ?? null,
2967
2994
  clientMsgId: o.client_msg_id ?? "",
2968
- replyTo: o.reply_to ?? null
2995
+ replyTo: o.reply_to ?? null,
2996
+ ...textRanges ? { bodyRanges: textRanges } : {}
2969
2997
  };
2970
2998
  }
2971
2999
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2977,6 +3005,14 @@ function decodeEnvelope(bytes) {
2977
3005
  }
2978
3006
  return { text: s, clientMsgId: "", replyTo: null };
2979
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
+ }
2980
3016
  function resolveReply(ref, lookup) {
2981
3017
  const parent = lookup(ref.client_msg_id);
2982
3018
  if (parent !== null) {
@@ -3188,9 +3224,9 @@ var GroupMessaging = class {
3188
3224
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3189
3225
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3190
3226
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3191
- async sendText(group, text, replyTo) {
3227
+ async sendText(group, text, replyTo, bodyRanges) {
3192
3228
  const clientMsgId = mintClientMsgId();
3193
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3229
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3194
3230
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3195
3231
  const body = {
3196
3232
  ciphertext_b64: toBase64(ct),
@@ -3216,7 +3252,10 @@ var GroupMessaging = class {
3216
3252
  previewBody: replyTo.preview?.body ?? null,
3217
3253
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3218
3254
  previewKind: replyTo.preview?.kind ?? "text"
3219
- } : 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 } : {}
3220
3259
  };
3221
3260
  try {
3222
3261
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3284,7 +3323,8 @@ var GroupMessaging = class {
3284
3323
  const plaintext = encodeEdit({
3285
3324
  clientMsgId: args.clientMsgId,
3286
3325
  targetClientMsgId: args.targetClientMsgId,
3287
- newText: args.newText
3326
+ newText: args.newText,
3327
+ bodyRanges: args.bodyRanges
3288
3328
  });
3289
3329
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3290
3330
  const body = {
@@ -3310,7 +3350,10 @@ var GroupMessaging = class {
3310
3350
  envelopeType: "edit",
3311
3351
  edit: {
3312
3352
  targetClientMsgId: args.targetClientMsgId,
3313
- 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 } : {}
3314
3357
  }
3315
3358
  };
3316
3359
  try {
@@ -3432,6 +3475,41 @@ var GroupMessaging = class {
3432
3475
  }
3433
3476
  };
3434
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
+
3435
3513
  // src/messaging/reaction-fold.ts
3436
3514
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3437
3515
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3519,6 +3597,13 @@ var Chat = class {
3519
3597
  /** True once the persisted suppression set has been loaded (so the omit applies
3520
3598
  * even on the cold-launch hydrate path before a fresh deleteForMe). */
3521
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;
3522
3607
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3523
3608
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3524
3609
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3530,6 +3615,14 @@ var Chat = class {
3530
3615
  wired = false;
3531
3616
  liveUnsub = null;
3532
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;
3533
3626
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3534
3627
  constructor(args) {
3535
3628
  this.backend = args.backend;
@@ -3607,7 +3700,8 @@ var Chat = class {
3607
3700
  reactions: {},
3608
3701
  replyTo: null,
3609
3702
  edited: false,
3610
- isDeleted: true
3703
+ isDeleted: true,
3704
+ mentions: []
3611
3705
  });
3612
3706
  continue;
3613
3707
  }
@@ -3643,6 +3737,7 @@ var Chat = class {
3643
3737
  this.wired = true;
3644
3738
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3645
3739
  void this.loadSuppressed();
3740
+ void this.loadElevated();
3646
3741
  void this.hydrateHistory();
3647
3742
  void this.refreshMembers();
3648
3743
  }
@@ -3664,6 +3759,17 @@ var Chat = class {
3664
3759
  } catch {
3665
3760
  }
3666
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
+ }
3667
3773
  async hydrateHistory() {
3668
3774
  if (this.historyLoaded || !this._group) return;
3669
3775
  this.historyLoaded = true;
@@ -3693,7 +3799,9 @@ var Chat = class {
3693
3799
  if (m.clientMsgId && !m.isDeleted) {
3694
3800
  this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3695
3801
  }
3696
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3802
+ this.messageList.push(
3803
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3804
+ );
3697
3805
  changed = true;
3698
3806
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3699
3807
  }
@@ -3743,7 +3851,10 @@ var Chat = class {
3743
3851
  newText: incoming.edit.newText,
3744
3852
  epoch: incoming.epoch,
3745
3853
  serverSeq: incoming.serverSeq,
3746
- 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
3747
3858
  },
3748
3859
  this.authorOfTarget
3749
3860
  );
@@ -3771,6 +3882,7 @@ var Chat = class {
3771
3882
  if (incomingReplyRef) {
3772
3883
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3773
3884
  }
3885
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3774
3886
  const msg = {
3775
3887
  id: this.publicId(incoming.serverSeq),
3776
3888
  kind: this.kindOf(incoming),
@@ -3787,8 +3899,10 @@ var Chat = class {
3787
3899
  // Default false; applyEditOverlay below folds any edit that arrived first.
3788
3900
  edited: false,
3789
3901
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3790
- isDeleted: false
3902
+ isDeleted: false,
3903
+ mentions
3791
3904
  };
3905
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3792
3906
  if (incomingClientMsgId && incoming.text !== null) {
3793
3907
  this.byClientMsgId.set(incomingClientMsgId, {
3794
3908
  text: incoming.text,
@@ -3812,6 +3926,75 @@ var Chat = class {
3812
3926
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3813
3927
  * so it can be passed to the pure EditFold. */
3814
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
+ }
3815
3998
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3816
3999
  * (a later own/peer edit must not overwrite the original we render against). The
3817
4000
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3868,9 +4051,10 @@ var Chat = class {
3868
4051
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3869
4052
  const text = editText ?? base;
3870
4053
  const edited = foldEdited || m.edited;
3871
- 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;
3872
4056
  changed = true;
3873
- return { ...m, text, edited };
4057
+ return { ...m, text, edited, mentions };
3874
4058
  });
3875
4059
  if (changed) this.emit();
3876
4060
  }
@@ -3887,8 +4071,9 @@ var Chat = class {
3887
4071
  if (editText === null && !foldEdited) return m;
3888
4072
  const text = editText ?? m.text;
3889
4073
  const edited = foldEdited || m.edited;
3890
- if (m.text === text && m.edited === edited) return m;
3891
- 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 };
3892
4077
  }
3893
4078
  /** @internal — called by the backend's conv subscription. */
3894
4079
  applyConv(event, payload) {
@@ -3946,6 +4131,18 @@ var Chat = class {
3946
4131
  }
3947
4132
  this.editFold.reevaluateHeld(this.authorOfTarget);
3948
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();
3949
4146
  }
3950
4147
  seedMembersFromGroup(group) {
3951
4148
  const seed = [
@@ -4013,11 +4210,12 @@ var Chat = class {
4013
4210
  };
4014
4211
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4015
4212
  }
4016
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
4017
- 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);
4018
4216
  return receipt;
4019
4217
  }
4020
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4218
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4021
4219
  if (receipt.serverSeq <= 0) return;
4022
4220
  const key = this.internalKey(receipt.serverSeq);
4023
4221
  if (this.seenKeys.has(key)) return;
@@ -4042,7 +4240,10 @@ var Chat = class {
4042
4240
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
4043
4241
  edited: false,
4044
4242
  // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4045
- isDeleted: false
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)
4046
4247
  });
4047
4248
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4048
4249
  this.emit();
@@ -4130,15 +4331,18 @@ var Chat = class {
4130
4331
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
4131
4332
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
4132
4333
  * reactions + reply context. Only the original author's edits count — for an own
4133
- * message self IS the author, so the author-gate passes. */
4134
- 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) {
4135
4337
  if (!message.clientMsgId || message.kind !== "text") return;
4136
4338
  const group = await this.materializeIfNeeded();
4137
4339
  const clientMsgId = mintClientMsgId();
4340
+ const bodyRanges = opts?.mentions ?? null;
4138
4341
  const { receipt } = await this.backend.sendEdit(group, {
4139
4342
  clientMsgId,
4140
4343
  targetClientMsgId: message.clientMsgId,
4141
- newText
4344
+ newText,
4345
+ bodyRanges
4142
4346
  });
4143
4347
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4144
4348
  this.editFold.ingest(
@@ -4148,7 +4352,8 @@ var Chat = class {
4148
4352
  newText,
4149
4353
  epoch: receipt.epoch,
4150
4354
  serverSeq: receipt.serverSeq,
4151
- eventClientMsgId: clientMsgId
4355
+ eventClientMsgId: clientMsgId,
4356
+ bodyRanges
4152
4357
  },
4153
4358
  this.authorOfTarget
4154
4359
  );
@@ -4201,6 +4406,18 @@ var Chat = class {
4201
4406
  }
4202
4407
  }
4203
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
+ }
4204
4421
  function sameReactions(a, b) {
4205
4422
  const ak = Object.keys(a);
4206
4423
  const bk = Object.keys(b);
@@ -4412,14 +4629,21 @@ var MessageDeliverySource = class {
4412
4629
  // Thread the edit discriminator + new text through the persisted row so an
4413
4630
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4414
4631
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4415
- // `'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).
4416
4634
  ...isEdit && decoded.edit ? {
4417
4635
  envelopeType: "edit",
4418
4636
  edit: {
4419
4637
  targetClientMsgId: decoded.edit.targetClientMsgId,
4420
- newText: decoded.edit.newText
4638
+ newText: decoded.edit.newText,
4639
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4421
4640
  }
4422
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 } : {},
4423
4647
  // Thread the delete discriminator + target through the persisted row so a
4424
4648
  // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4425
4649
  // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
@@ -4451,7 +4675,10 @@ var MessageDeliverySource = class {
4451
4675
  envelopeType: decoded.type ?? "text",
4452
4676
  reaction: isReaction ? decoded.reaction : null,
4453
4677
  edit: isEdit ? decoded.edit : null,
4454
- delete: isDelete ? decoded.delete : 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
4455
4682
  });
4456
4683
  return true;
4457
4684
  }
@@ -4606,6 +4833,36 @@ var GroupCatalog = class {
4606
4833
  }
4607
4834
  };
4608
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
+
4609
4866
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4610
4867
  var palbe_mls_bg_exports = {};
4611
4868
  __export(palbe_mls_bg_exports, {
@@ -6312,6 +6569,7 @@ var MessagingCoordinator = class {
6312
6569
  this.groupStore = new GroupStateStorage(this.kv);
6313
6570
  this.kpStore = new KeyPackageStorage(this.kv);
6314
6571
  this.suppressionStore = new SuppressionStore(this.kv);
6572
+ this.elevationStore = new MentionElevationStore(this.kv);
6315
6573
  this.registry.attachChatList(
6316
6574
  (chats) => {
6317
6575
  this.chatList = chats;
@@ -6327,6 +6585,7 @@ var MessagingCoordinator = class {
6327
6585
  groupStore;
6328
6586
  kpStore;
6329
6587
  suppressionStore;
6588
+ elevationStore;
6330
6589
  registry = new GroupRegistry();
6331
6590
  resolved = null;
6332
6591
  resolvePromise = null;
@@ -6482,9 +6741,9 @@ var MessagingCoordinator = class {
6482
6741
  });
6483
6742
  return group;
6484
6743
  }
6485
- async sendText(group, text, replyTo) {
6744
+ async sendText(group, text, replyTo, bodyRanges) {
6486
6745
  const r = await this.resolve();
6487
- return r.groups.sendText(group, text, replyTo);
6746
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6488
6747
  }
6489
6748
  async sendReaction(group, args) {
6490
6749
  const r = await this.resolve();
@@ -6506,6 +6765,14 @@ var MessagingCoordinator = class {
6506
6765
  saveSuppressed(group, keys) {
6507
6766
  return this.suppressionStore.save(group.rfcGroupId, keys);
6508
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
+ }
6509
6776
  async history(group, limit, before) {
6510
6777
  const r = await this.resolve();
6511
6778
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6624,7 +6891,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6624
6891
  newText: s.edit.newText,
6625
6892
  epoch: s.epoch,
6626
6893
  serverSeq: s.serverSeq,
6627
- 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
6628
6898
  },
6629
6899
  authorOfTarget
6630
6900
  );
@@ -6674,7 +6944,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6674
6944
  replyTo: null,
6675
6945
  reactions: {},
6676
6946
  edited: false,
6677
- isDeleted: true
6947
+ isDeleted: true,
6948
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6949
+ mentions: []
6678
6950
  });
6679
6951
  continue;
6680
6952
  }
@@ -6694,23 +6966,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6694
6966
  }
6695
6967
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6696
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);
6697
6972
  out.push({
6698
6973
  id: `${displayId}#${s.serverSeq}`,
6699
6974
  kind: s.text != null ? "text" : "system",
6700
6975
  direction: s.direction,
6701
6976
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6702
- text: editText ?? s.text,
6977
+ text,
6703
6978
  serverSeq: s.serverSeq,
6704
6979
  sentAt: new Date(s.at),
6705
6980
  clientMsgId,
6706
6981
  replyTo,
6707
6982
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6708
6983
  edited,
6709
- isDeleted: false
6984
+ isDeleted: false,
6985
+ mentions
6710
6986
  });
6711
6987
  }
6712
6988
  return out;
6713
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
+ }
6714
6999
 
6715
7000
  // src/messaging/facade.ts
6716
7001
  var PalbeMessaging = class {
@@ -7444,7 +7729,7 @@ function defaultSessionStorage(key) {
7444
7729
  }
7445
7730
 
7446
7731
  // src/version.ts
7447
- var VERSION = "1.4.0";
7732
+ var VERSION = "1.5.0";
7448
7733
 
7449
7734
  // src/runtime.ts
7450
7735
  function buildRuntime(config) {
@@ -7804,4 +8089,4 @@ export {
7804
8089
  pb,
7805
8090
  createBoundClient
7806
8091
  };
7807
- //# sourceMappingURL=chunk-3EVGYJ5F.js.map
8092
+ //# sourceMappingURL=chunk-MBA2NAKS.js.map