@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.
package/dist/internal.cjs CHANGED
@@ -2720,7 +2720,8 @@ var EditFold = class {
2720
2720
  orderEpoch: e.epoch,
2721
2721
  orderSeq: e.serverSeq,
2722
2722
  lastEventId: e.eventClientMsgId,
2723
- text: e.newText
2723
+ text: e.newText,
2724
+ bodyRanges: e.bodyRanges ?? null
2724
2725
  });
2725
2726
  this.editedTargets.add(e.targetClientMsgId);
2726
2727
  }
@@ -2741,6 +2742,15 @@ var EditFold = class {
2741
2742
  isEdited(targetClientMsgId) {
2742
2743
  return this.editedTargets.has(targetClientMsgId);
2743
2744
  }
2745
+ /**
2746
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2747
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2748
+ * normalizes these against the edited text to compute the edited message's mentions
2749
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2750
+ */
2751
+ bodyRanges(targetClientMsgId) {
2752
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2753
+ }
2744
2754
  /**
2745
2755
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2746
2756
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2914,7 +2924,14 @@ function encodeEdit(args) {
2914
2924
  type: "edit",
2915
2925
  client_msg_id: args.clientMsgId,
2916
2926
  target_client_msg_id: args.targetClientMsgId,
2917
- new_text: args.newText
2927
+ new_text: args.newText,
2928
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2929
+ body_ranges: args.bodyRanges.map((r) => ({
2930
+ start: r.start,
2931
+ length: r.length,
2932
+ mentioned_user_id: r.mentionedUserId
2933
+ }))
2934
+ } : {}
2918
2935
  })
2919
2936
  );
2920
2937
  }
@@ -2936,7 +2953,14 @@ function encodeEnvelope(args) {
2936
2953
  type: "text",
2937
2954
  client_msg_id: args.clientMsgId,
2938
2955
  text: args.text,
2939
- ...args.replyTo ? { reply_to: args.replyTo } : {}
2956
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
2957
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2958
+ body_ranges: args.bodyRanges.map((r) => ({
2959
+ start: r.start,
2960
+ length: r.length,
2961
+ mentioned_user_id: r.mentionedUserId
2962
+ }))
2963
+ } : {}
2940
2964
  };
2941
2965
  return encodeUtf8(JSON.stringify(env));
2942
2966
  }
@@ -2970,6 +2994,7 @@ function decodeEnvelope(bytes) {
2970
2994
  };
2971
2995
  }
2972
2996
  if (typeof o === "object" && o !== null && o.type === "edit") {
2997
+ const editRanges = decodeBodyRanges(o.body_ranges);
2973
2998
  return {
2974
2999
  type: "edit",
2975
3000
  text: null,
@@ -2978,15 +3003,18 @@ function decodeEnvelope(bytes) {
2978
3003
  edit: {
2979
3004
  targetClientMsgId: o.target_client_msg_id ?? "",
2980
3005
  newText: o.new_text ?? ""
2981
- }
3006
+ },
3007
+ ...editRanges ? { bodyRanges: editRanges } : {}
2982
3008
  };
2983
3009
  }
2984
3010
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
3011
+ const textRanges = decodeBodyRanges(o.body_ranges);
2985
3012
  return {
2986
3013
  type: "text",
2987
3014
  text: o.text ?? null,
2988
3015
  clientMsgId: o.client_msg_id ?? "",
2989
- replyTo: o.reply_to ?? null
3016
+ replyTo: o.reply_to ?? null,
3017
+ ...textRanges ? { bodyRanges: textRanges } : {}
2990
3018
  };
2991
3019
  }
2992
3020
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2998,6 +3026,14 @@ function decodeEnvelope(bytes) {
2998
3026
  }
2999
3027
  return { text: s, clientMsgId: "", replyTo: null };
3000
3028
  }
3029
+ function decodeBodyRanges(raw) {
3030
+ if (!raw || raw.length === 0) return void 0;
3031
+ return raw.map((r) => ({
3032
+ start: r.start,
3033
+ length: r.length,
3034
+ mentionedUserId: r.mentioned_user_id
3035
+ }));
3036
+ }
3001
3037
  function resolveReply(ref, lookup) {
3002
3038
  const parent = lookup(ref.client_msg_id);
3003
3039
  if (parent !== null) {
@@ -3209,9 +3245,9 @@ var GroupMessaging = class {
3209
3245
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3210
3246
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3211
3247
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3212
- async sendText(group, text, replyTo) {
3248
+ async sendText(group, text, replyTo, bodyRanges) {
3213
3249
  const clientMsgId = mintClientMsgId();
3214
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3250
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3215
3251
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3216
3252
  const body = {
3217
3253
  ciphertext_b64: toBase64(ct),
@@ -3237,7 +3273,10 @@ var GroupMessaging = class {
3237
3273
  previewBody: replyTo.preview?.body ?? null,
3238
3274
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3239
3275
  previewKind: replyTo.preview?.kind ?? "text"
3240
- } : null
3276
+ } : null,
3277
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3278
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3279
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3241
3280
  };
3242
3281
  try {
3243
3282
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3305,7 +3344,8 @@ var GroupMessaging = class {
3305
3344
  const plaintext = encodeEdit({
3306
3345
  clientMsgId: args.clientMsgId,
3307
3346
  targetClientMsgId: args.targetClientMsgId,
3308
- newText: args.newText
3347
+ newText: args.newText,
3348
+ bodyRanges: args.bodyRanges
3309
3349
  });
3310
3350
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3311
3351
  const body = {
@@ -3331,7 +3371,10 @@ var GroupMessaging = class {
3331
3371
  envelopeType: "edit",
3332
3372
  edit: {
3333
3373
  targetClientMsgId: args.targetClientMsgId,
3334
- newText: args.newText
3374
+ newText: args.newText,
3375
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3376
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3377
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3335
3378
  }
3336
3379
  };
3337
3380
  try {
@@ -3453,6 +3496,41 @@ var GroupMessaging = class {
3453
3496
  }
3454
3497
  };
3455
3498
 
3499
+ // src/messaging/mention-ranges.ts
3500
+ function normalizeMentionRangesUtf16(ranges, text) {
3501
+ const n = text.length;
3502
+ function splitsSurrogatePair(index) {
3503
+ if (index <= 0 || index >= n) return false;
3504
+ const before = text.charCodeAt(index - 1);
3505
+ const at = text.charCodeAt(index);
3506
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3507
+ const atIsLow = at >= 56320 && at <= 57343;
3508
+ return beforeIsHigh && atIsLow;
3509
+ }
3510
+ const survivors = [];
3511
+ for (let idx = 0; idx < ranges.length; idx++) {
3512
+ const r = ranges[idx];
3513
+ if (r === void 0) continue;
3514
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3515
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3516
+ survivors.push({ idx, range: r });
3517
+ }
3518
+ survivors.sort((lhs, rhs) => {
3519
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3520
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3521
+ return lhs.idx - rhs.idx;
3522
+ });
3523
+ const kept = [];
3524
+ let prevEnd = Number.NEGATIVE_INFINITY;
3525
+ for (const s of survivors) {
3526
+ if (s.range.start >= prevEnd) {
3527
+ kept.push(s.range);
3528
+ prevEnd = s.range.start + s.range.length;
3529
+ }
3530
+ }
3531
+ return kept;
3532
+ }
3533
+
3456
3534
  // src/messaging/reaction-fold.ts
3457
3535
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3458
3536
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3540,6 +3618,13 @@ var Chat = class {
3540
3618
  /** True once the persisted suppression set has been loaded (so the omit applies
3541
3619
  * even on the cold-launch hydrate path before a fresh deleteForMe). */
3542
3620
  suppressedLoaded = false;
3621
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3622
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3623
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3624
+ elevated = /* @__PURE__ */ new Set();
3625
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3626
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3627
+ elevatedLoaded = false;
3543
3628
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3544
3629
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3545
3630
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3551,6 +3636,14 @@ var Chat = class {
3551
3636
  wired = false;
3552
3637
  liveUnsub = null;
3553
3638
  listeners = /* @__PURE__ */ new Set();
3639
+ /**
3640
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3641
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3642
+ * via the persisted elevation set, so this never double-fires for one mention. The
3643
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3644
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3645
+ */
3646
+ onMentionElevation;
3554
3647
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3555
3648
  constructor(args) {
3556
3649
  this.backend = args.backend;
@@ -3628,7 +3721,8 @@ var Chat = class {
3628
3721
  reactions: {},
3629
3722
  replyTo: null,
3630
3723
  edited: false,
3631
- isDeleted: true
3724
+ isDeleted: true,
3725
+ mentions: []
3632
3726
  });
3633
3727
  continue;
3634
3728
  }
@@ -3664,6 +3758,7 @@ var Chat = class {
3664
3758
  this.wired = true;
3665
3759
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3666
3760
  void this.loadSuppressed();
3761
+ void this.loadElevated();
3667
3762
  void this.hydrateHistory();
3668
3763
  void this.refreshMembers();
3669
3764
  }
@@ -3685,6 +3780,17 @@ var Chat = class {
3685
3780
  } catch {
3686
3781
  }
3687
3782
  }
3783
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
3784
+ * gates the elevation DECISION, it does not change what renders. */
3785
+ async loadElevated() {
3786
+ if (this.elevatedLoaded || !this._group) return;
3787
+ this.elevatedLoaded = true;
3788
+ try {
3789
+ const keys = await this.backend.loadElevated(this._group);
3790
+ for (const k of keys) this.elevated.add(k);
3791
+ } catch {
3792
+ }
3793
+ }
3688
3794
  async hydrateHistory() {
3689
3795
  if (this.historyLoaded || !this._group) return;
3690
3796
  this.historyLoaded = true;
@@ -3714,7 +3820,9 @@ var Chat = class {
3714
3820
  if (m.clientMsgId && !m.isDeleted) {
3715
3821
  this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3716
3822
  }
3717
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3823
+ this.messageList.push(
3824
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3825
+ );
3718
3826
  changed = true;
3719
3827
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3720
3828
  }
@@ -3764,7 +3872,10 @@ var Chat = class {
3764
3872
  newText: incoming.edit.newText,
3765
3873
  epoch: incoming.epoch,
3766
3874
  serverSeq: incoming.serverSeq,
3767
- eventClientMsgId: incoming.clientMsgId
3875
+ eventClientMsgId: incoming.clientMsgId,
3876
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
3877
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
3878
+ bodyRanges: incoming.bodyRanges
3768
3879
  },
3769
3880
  this.authorOfTarget
3770
3881
  );
@@ -3792,6 +3903,7 @@ var Chat = class {
3792
3903
  if (incomingReplyRef) {
3793
3904
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3794
3905
  }
3906
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3795
3907
  const msg = {
3796
3908
  id: this.publicId(incoming.serverSeq),
3797
3909
  kind: this.kindOf(incoming),
@@ -3808,8 +3920,10 @@ var Chat = class {
3808
3920
  // Default false; applyEditOverlay below folds any edit that arrived first.
3809
3921
  edited: false,
3810
3922
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3811
- isDeleted: false
3923
+ isDeleted: false,
3924
+ mentions
3812
3925
  };
3926
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3813
3927
  if (incomingClientMsgId && incoming.text !== null) {
3814
3928
  this.byClientMsgId.set(incomingClientMsgId, {
3815
3929
  text: incoming.text,
@@ -3833,6 +3947,75 @@ var Chat = class {
3833
3947
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3834
3948
  * so it can be passed to the pure EditFold. */
3835
3949
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3950
+ // ── Mentions (mentions T6) ──
3951
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3952
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
3953
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
3954
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
3955
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
3956
+ resolveMentions(text, bodyRanges) {
3957
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
3958
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
3959
+ if (normalized.length === 0) return [];
3960
+ return normalized.map((r) => ({
3961
+ start: r.start,
3962
+ length: r.length,
3963
+ mentionedUserId: r.mentionedUserId,
3964
+ displayName: this.displayNameOf(r.mentionedUserId)
3965
+ }));
3966
+ }
3967
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
3968
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
3969
+ * A member rename then reflects on old messages. Returns the message unchanged when
3970
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
3971
+ resolveMentionNames(m) {
3972
+ if (!m.mentions || m.mentions.length === 0) {
3973
+ return m.mentions ? m : { ...m, mentions: [] };
3974
+ }
3975
+ let changed = false;
3976
+ const reresolved = m.mentions.map((span) => {
3977
+ const name = this.displayNameOf(span.mentionedUserId);
3978
+ if (name === span.displayName) return span;
3979
+ changed = true;
3980
+ return { ...span, displayName: name };
3981
+ });
3982
+ if (!changed) return m;
3983
+ return { ...m, mentions: reresolved };
3984
+ }
3985
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
3986
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
3987
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
3988
+ editMentions(targetClientMsgId, newText) {
3989
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
3990
+ if (!ranges) return [];
3991
+ return this.resolveMentions(newText, ranges);
3992
+ }
3993
+ /** Resolve a userId → its roster display name (null if not a known member). */
3994
+ displayNameOf(userId) {
3995
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
3996
+ }
3997
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
3998
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
3999
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
4000
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
4001
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
4002
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
4003
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
4004
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
4005
+ const me = this.backend.selfUserId;
4006
+ if (envelopeType === "edit") return;
4007
+ if (senderUserId === me) return;
4008
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
4009
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4010
+ const key = `${me}|${idPart}`;
4011
+ if (this.elevated.has(key)) return;
4012
+ this.elevated.add(key);
4013
+ if (this._group) {
4014
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
4015
+ });
4016
+ }
4017
+ this.onMentionElevation?.(message);
4018
+ }
3836
4019
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3837
4020
  * (a later own/peer edit must not overwrite the original we render against). The
3838
4021
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3889,9 +4072,10 @@ var Chat = class {
3889
4072
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3890
4073
  const text = editText ?? base;
3891
4074
  const edited = foldEdited || m.edited;
3892
- if (m.text === text && m.edited === edited) return m;
4075
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
4076
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3893
4077
  changed = true;
3894
- return { ...m, text, edited };
4078
+ return { ...m, text, edited, mentions };
3895
4079
  });
3896
4080
  if (changed) this.emit();
3897
4081
  }
@@ -3908,8 +4092,9 @@ var Chat = class {
3908
4092
  if (editText === null && !foldEdited) return m;
3909
4093
  const text = editText ?? m.text;
3910
4094
  const edited = foldEdited || m.edited;
3911
- if (m.text === text && m.edited === edited) return m;
3912
- return { ...m, text, edited };
4095
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
4096
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
4097
+ return { ...m, text, edited, mentions };
3913
4098
  }
3914
4099
  /** @internal — called by the backend's conv subscription. */
3915
4100
  applyConv(event, payload) {
@@ -3967,6 +4152,18 @@ var Chat = class {
3967
4152
  }
3968
4153
  this.editFold.reevaluateHeld(this.authorOfTarget);
3969
4154
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
4155
+ this.reresolveAllMentionNames();
4156
+ }
4157
+ /** Re-resolve roster display names across the whole transcript (called on a roster
4158
+ * change). Re-emits only if any name actually changed. */
4159
+ reresolveAllMentionNames() {
4160
+ let changed = false;
4161
+ this.messageList = this.messageList.map((m) => {
4162
+ const reresolved = this.resolveMentionNames(m);
4163
+ if (reresolved !== m) changed = true;
4164
+ return reresolved;
4165
+ });
4166
+ if (changed) this.emit();
3970
4167
  }
3971
4168
  seedMembersFromGroup(group) {
3972
4169
  const seed = [
@@ -4034,11 +4231,12 @@ var Chat = class {
4034
4231
  };
4035
4232
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4036
4233
  }
4037
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
4038
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4234
+ const bodyRanges = opts?.mentions ?? null;
4235
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4236
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
4039
4237
  return receipt;
4040
4238
  }
4041
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4239
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4042
4240
  if (receipt.serverSeq <= 0) return;
4043
4241
  const key = this.internalKey(receipt.serverSeq);
4044
4242
  if (this.seenKeys.has(key)) return;
@@ -4063,7 +4261,10 @@ var Chat = class {
4063
4261
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
4064
4262
  edited: false,
4065
4263
  // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4066
- isDeleted: false
4264
+ isDeleted: false,
4265
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4266
+ // sender never gets a wire echo of its own message — this is the only local copy).
4267
+ mentions: this.resolveMentions(text, bodyRanges)
4067
4268
  });
4068
4269
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4069
4270
  this.emit();
@@ -4151,15 +4352,18 @@ var Chat = class {
4151
4352
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
4152
4353
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
4153
4354
  * reactions + reply context. Only the original author's edits count — for an own
4154
- * message self IS the author, so the author-gate passes. */
4155
- async edit(message, newText) {
4355
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4356
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4357
+ async edit(message, newText, opts) {
4156
4358
  if (!message.clientMsgId || message.kind !== "text") return;
4157
4359
  const group = await this.materializeIfNeeded();
4158
4360
  const clientMsgId = mintClientMsgId();
4361
+ const bodyRanges = opts?.mentions ?? null;
4159
4362
  const { receipt } = await this.backend.sendEdit(group, {
4160
4363
  clientMsgId,
4161
4364
  targetClientMsgId: message.clientMsgId,
4162
- newText
4365
+ newText,
4366
+ bodyRanges
4163
4367
  });
4164
4368
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4165
4369
  this.editFold.ingest(
@@ -4169,7 +4373,8 @@ var Chat = class {
4169
4373
  newText,
4170
4374
  epoch: receipt.epoch,
4171
4375
  serverSeq: receipt.serverSeq,
4172
- eventClientMsgId: clientMsgId
4376
+ eventClientMsgId: clientMsgId,
4377
+ bodyRanges
4173
4378
  },
4174
4379
  this.authorOfTarget
4175
4380
  );
@@ -4222,6 +4427,18 @@ var Chat = class {
4222
4427
  }
4223
4428
  }
4224
4429
  };
4430
+ function sameMentions(a, b) {
4431
+ if (a.length !== b.length) return false;
4432
+ for (let i = 0; i < a.length; i++) {
4433
+ const x = a[i];
4434
+ const y = b[i];
4435
+ if (!x || !y) return false;
4436
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4437
+ return false;
4438
+ }
4439
+ }
4440
+ return true;
4441
+ }
4225
4442
  function sameReactions(a, b) {
4226
4443
  const ak = Object.keys(a);
4227
4444
  const bk = Object.keys(b);
@@ -4433,14 +4650,21 @@ var MessageDeliverySource = class {
4433
4650
  // Thread the edit discriminator + new text through the persisted row so an
4434
4651
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4435
4652
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4436
- // `'text'`/no-edit (backward-compat).
4653
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
4654
+ // along so the edited message's mentions re-resolve on cold launch (T6).
4437
4655
  ...isEdit && decoded.edit ? {
4438
4656
  envelopeType: "edit",
4439
4657
  edit: {
4440
4658
  targetClientMsgId: decoded.edit.targetClientMsgId,
4441
- newText: decoded.edit.newText
4659
+ newText: decoded.edit.newText,
4660
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4442
4661
  }
4443
4662
  } : {},
4663
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
4664
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
4665
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
4666
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
4667
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4444
4668
  // Thread the delete discriminator + target through the persisted row so a
4445
4669
  // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4446
4670
  // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
@@ -4472,7 +4696,10 @@ var MessageDeliverySource = class {
4472
4696
  envelopeType: decoded.type ?? "text",
4473
4697
  reaction: isReaction ? decoded.reaction : null,
4474
4698
  edit: isEdit ? decoded.edit : null,
4475
- delete: isDelete ? decoded.delete : null
4699
+ delete: isDelete ? decoded.delete : null,
4700
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
4701
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4702
+ bodyRanges: decoded.bodyRanges ?? null
4476
4703
  });
4477
4704
  return true;
4478
4705
  }
@@ -4627,6 +4854,36 @@ var GroupCatalog = class {
4627
4854
  }
4628
4855
  };
4629
4856
 
4857
+ // src/messaging/mention-elevation.ts
4858
+ var MentionElevationStore = class {
4859
+ constructor(kv) {
4860
+ this.kv = kv;
4861
+ }
4862
+ kv;
4863
+ key(rfcGroupId) {
4864
+ return `elev:${rfcGroupId}`;
4865
+ }
4866
+ /** Load the persisted elevation keys for a chat (empty array if none). */
4867
+ async load(rfcGroupId) {
4868
+ const raw = await this.kv.get(this.key(rfcGroupId));
4869
+ if (!raw) return [];
4870
+ try {
4871
+ const parsed = JSON.parse(decodeUtf8(raw));
4872
+ return Array.isArray(parsed) ? parsed : [];
4873
+ } catch {
4874
+ return [];
4875
+ }
4876
+ }
4877
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
4878
+ async save(rfcGroupId, keys) {
4879
+ const sorted = [...new Set(keys)].sort();
4880
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
4881
+ }
4882
+ async wipe() {
4883
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
4884
+ }
4885
+ };
4886
+
4630
4887
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4631
4888
  var palbe_mls_bg_exports = {};
4632
4889
  __export(palbe_mls_bg_exports, {
@@ -6334,6 +6591,7 @@ var MessagingCoordinator = class {
6334
6591
  this.groupStore = new GroupStateStorage(this.kv);
6335
6592
  this.kpStore = new KeyPackageStorage(this.kv);
6336
6593
  this.suppressionStore = new SuppressionStore(this.kv);
6594
+ this.elevationStore = new MentionElevationStore(this.kv);
6337
6595
  this.registry.attachChatList(
6338
6596
  (chats) => {
6339
6597
  this.chatList = chats;
@@ -6349,6 +6607,7 @@ var MessagingCoordinator = class {
6349
6607
  groupStore;
6350
6608
  kpStore;
6351
6609
  suppressionStore;
6610
+ elevationStore;
6352
6611
  registry = new GroupRegistry();
6353
6612
  resolved = null;
6354
6613
  resolvePromise = null;
@@ -6504,9 +6763,9 @@ var MessagingCoordinator = class {
6504
6763
  });
6505
6764
  return group;
6506
6765
  }
6507
- async sendText(group, text, replyTo) {
6766
+ async sendText(group, text, replyTo, bodyRanges) {
6508
6767
  const r = await this.resolve();
6509
- return r.groups.sendText(group, text, replyTo);
6768
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6510
6769
  }
6511
6770
  async sendReaction(group, args) {
6512
6771
  const r = await this.resolve();
@@ -6528,6 +6787,14 @@ var MessagingCoordinator = class {
6528
6787
  saveSuppressed(group, keys) {
6529
6788
  return this.suppressionStore.save(group.rfcGroupId, keys);
6530
6789
  }
6790
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
6791
+ loadElevated(group) {
6792
+ return this.elevationStore.load(group.rfcGroupId);
6793
+ }
6794
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
6795
+ saveElevated(group, keys) {
6796
+ return this.elevationStore.save(group.rfcGroupId, keys);
6797
+ }
6531
6798
  async history(group, limit, before) {
6532
6799
  const r = await this.resolve();
6533
6800
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6646,7 +6913,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6646
6913
  newText: s.edit.newText,
6647
6914
  epoch: s.epoch,
6648
6915
  serverSeq: s.serverSeq,
6649
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
6916
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
6917
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
6918
+ // edit's ranges drive the edited message's mentions on cold launch.
6919
+ bodyRanges: s.edit.bodyRanges ?? null
6650
6920
  },
6651
6921
  authorOfTarget
6652
6922
  );
@@ -6696,7 +6966,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6696
6966
  replyTo: null,
6697
6967
  reactions: {},
6698
6968
  edited: false,
6699
- isDeleted: true
6969
+ isDeleted: true,
6970
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6971
+ mentions: []
6700
6972
  });
6701
6973
  continue;
6702
6974
  }
@@ -6716,23 +6988,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6716
6988
  }
6717
6989
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6718
6990
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6991
+ const text = editText ?? s.text;
6992
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
6993
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6719
6994
  out.push({
6720
6995
  id: `${displayId}#${s.serverSeq}`,
6721
6996
  kind: s.text != null ? "text" : "system",
6722
6997
  direction: s.direction,
6723
6998
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6724
- text: editText ?? s.text,
6999
+ text,
6725
7000
  serverSeq: s.serverSeq,
6726
7001
  sentAt: new Date(s.at),
6727
7002
  clientMsgId,
6728
7003
  replyTo,
6729
7004
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6730
7005
  edited,
6731
- isDeleted: false
7006
+ isDeleted: false,
7007
+ mentions
6732
7008
  });
6733
7009
  }
6734
7010
  return out;
6735
7011
  }
7012
+ function normalizeMentionsNullNames(raw, text) {
7013
+ if (text === null || !raw || raw.length === 0) return [];
7014
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
7015
+ start: r.start,
7016
+ length: r.length,
7017
+ mentionedUserId: r.mentionedUserId,
7018
+ displayName: null
7019
+ }));
7020
+ }
6736
7021
 
6737
7022
  // src/messaging/facade.ts
6738
7023
  var PalbeMessaging = class {
@@ -7466,7 +7751,7 @@ function defaultSessionStorage(key) {
7466
7751
  }
7467
7752
 
7468
7753
  // src/version.ts
7469
- var VERSION = "1.4.0";
7754
+ var VERSION = "1.5.0";
7470
7755
 
7471
7756
  // src/runtime.ts
7472
7757
  function buildRuntime(config) {