@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.
@@ -7,7 +7,7 @@ import {
7
7
  __configure,
8
8
  endpointRefFromApiKey,
9
9
  getRuntime
10
- } from "../chunk-3EVGYJ5F.js";
10
+ } from "../chunk-MBA2NAKS.js";
11
11
 
12
12
  // src/next/client.ts
13
13
  var SESSION_MAX_AGE_S = 2592e3;
@@ -2736,7 +2736,8 @@ var EditFold = class {
2736
2736
  orderEpoch: e.epoch,
2737
2737
  orderSeq: e.serverSeq,
2738
2738
  lastEventId: e.eventClientMsgId,
2739
- text: e.newText
2739
+ text: e.newText,
2740
+ bodyRanges: e.bodyRanges ?? null
2740
2741
  });
2741
2742
  this.editedTargets.add(e.targetClientMsgId);
2742
2743
  }
@@ -2757,6 +2758,15 @@ var EditFold = class {
2757
2758
  isEdited(targetClientMsgId) {
2758
2759
  return this.editedTargets.has(targetClientMsgId);
2759
2760
  }
2761
+ /**
2762
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2763
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2764
+ * normalizes these against the edited text to compute the edited message's mentions
2765
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2766
+ */
2767
+ bodyRanges(targetClientMsgId) {
2768
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2769
+ }
2760
2770
  /**
2761
2771
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2762
2772
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2930,7 +2940,14 @@ function encodeEdit(args) {
2930
2940
  type: "edit",
2931
2941
  client_msg_id: args.clientMsgId,
2932
2942
  target_client_msg_id: args.targetClientMsgId,
2933
- new_text: args.newText
2943
+ new_text: args.newText,
2944
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2945
+ body_ranges: args.bodyRanges.map((r) => ({
2946
+ start: r.start,
2947
+ length: r.length,
2948
+ mentioned_user_id: r.mentionedUserId
2949
+ }))
2950
+ } : {}
2934
2951
  })
2935
2952
  );
2936
2953
  }
@@ -2952,7 +2969,14 @@ function encodeEnvelope(args) {
2952
2969
  type: "text",
2953
2970
  client_msg_id: args.clientMsgId,
2954
2971
  text: args.text,
2955
- ...args.replyTo ? { reply_to: args.replyTo } : {}
2972
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
2973
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2974
+ body_ranges: args.bodyRanges.map((r) => ({
2975
+ start: r.start,
2976
+ length: r.length,
2977
+ mentioned_user_id: r.mentionedUserId
2978
+ }))
2979
+ } : {}
2956
2980
  };
2957
2981
  return encodeUtf8(JSON.stringify(env));
2958
2982
  }
@@ -2986,6 +3010,7 @@ function decodeEnvelope(bytes) {
2986
3010
  };
2987
3011
  }
2988
3012
  if (typeof o === "object" && o !== null && o.type === "edit") {
3013
+ const editRanges = decodeBodyRanges(o.body_ranges);
2989
3014
  return {
2990
3015
  type: "edit",
2991
3016
  text: null,
@@ -2994,15 +3019,18 @@ function decodeEnvelope(bytes) {
2994
3019
  edit: {
2995
3020
  targetClientMsgId: o.target_client_msg_id ?? "",
2996
3021
  newText: o.new_text ?? ""
2997
- }
3022
+ },
3023
+ ...editRanges ? { bodyRanges: editRanges } : {}
2998
3024
  };
2999
3025
  }
3000
3026
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
3027
+ const textRanges = decodeBodyRanges(o.body_ranges);
3001
3028
  return {
3002
3029
  type: "text",
3003
3030
  text: o.text ?? null,
3004
3031
  clientMsgId: o.client_msg_id ?? "",
3005
- replyTo: o.reply_to ?? null
3032
+ replyTo: o.reply_to ?? null,
3033
+ ...textRanges ? { bodyRanges: textRanges } : {}
3006
3034
  };
3007
3035
  }
3008
3036
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -3014,6 +3042,14 @@ function decodeEnvelope(bytes) {
3014
3042
  }
3015
3043
  return { text: s, clientMsgId: "", replyTo: null };
3016
3044
  }
3045
+ function decodeBodyRanges(raw) {
3046
+ if (!raw || raw.length === 0) return void 0;
3047
+ return raw.map((r) => ({
3048
+ start: r.start,
3049
+ length: r.length,
3050
+ mentionedUserId: r.mentioned_user_id
3051
+ }));
3052
+ }
3017
3053
  function resolveReply(ref, lookup) {
3018
3054
  const parent = lookup(ref.client_msg_id);
3019
3055
  if (parent !== null) {
@@ -3225,9 +3261,9 @@ var GroupMessaging = class {
3225
3261
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3226
3262
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3227
3263
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3228
- async sendText(group, text, replyTo) {
3264
+ async sendText(group, text, replyTo, bodyRanges) {
3229
3265
  const clientMsgId = mintClientMsgId();
3230
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3266
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3231
3267
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3232
3268
  const body = {
3233
3269
  ciphertext_b64: toBase64(ct),
@@ -3253,7 +3289,10 @@ var GroupMessaging = class {
3253
3289
  previewBody: replyTo.preview?.body ?? null,
3254
3290
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3255
3291
  previewKind: replyTo.preview?.kind ?? "text"
3256
- } : null
3292
+ } : null,
3293
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3294
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3295
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3257
3296
  };
3258
3297
  try {
3259
3298
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3321,7 +3360,8 @@ var GroupMessaging = class {
3321
3360
  const plaintext = encodeEdit({
3322
3361
  clientMsgId: args.clientMsgId,
3323
3362
  targetClientMsgId: args.targetClientMsgId,
3324
- newText: args.newText
3363
+ newText: args.newText,
3364
+ bodyRanges: args.bodyRanges
3325
3365
  });
3326
3366
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3327
3367
  const body = {
@@ -3347,7 +3387,10 @@ var GroupMessaging = class {
3347
3387
  envelopeType: "edit",
3348
3388
  edit: {
3349
3389
  targetClientMsgId: args.targetClientMsgId,
3350
- newText: args.newText
3390
+ newText: args.newText,
3391
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3392
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3393
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3351
3394
  }
3352
3395
  };
3353
3396
  try {
@@ -3469,6 +3512,41 @@ var GroupMessaging = class {
3469
3512
  }
3470
3513
  };
3471
3514
 
3515
+ // src/messaging/mention-ranges.ts
3516
+ function normalizeMentionRangesUtf16(ranges, text) {
3517
+ const n = text.length;
3518
+ function splitsSurrogatePair(index) {
3519
+ if (index <= 0 || index >= n) return false;
3520
+ const before = text.charCodeAt(index - 1);
3521
+ const at = text.charCodeAt(index);
3522
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3523
+ const atIsLow = at >= 56320 && at <= 57343;
3524
+ return beforeIsHigh && atIsLow;
3525
+ }
3526
+ const survivors = [];
3527
+ for (let idx = 0; idx < ranges.length; idx++) {
3528
+ const r = ranges[idx];
3529
+ if (r === void 0) continue;
3530
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3531
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3532
+ survivors.push({ idx, range: r });
3533
+ }
3534
+ survivors.sort((lhs, rhs) => {
3535
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3536
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3537
+ return lhs.idx - rhs.idx;
3538
+ });
3539
+ const kept = [];
3540
+ let prevEnd = Number.NEGATIVE_INFINITY;
3541
+ for (const s of survivors) {
3542
+ if (s.range.start >= prevEnd) {
3543
+ kept.push(s.range);
3544
+ prevEnd = s.range.start + s.range.length;
3545
+ }
3546
+ }
3547
+ return kept;
3548
+ }
3549
+
3472
3550
  // src/messaging/reaction-fold.ts
3473
3551
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3474
3552
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3556,6 +3634,13 @@ var Chat = class {
3556
3634
  /** True once the persisted suppression set has been loaded (so the omit applies
3557
3635
  * even on the cold-launch hydrate path before a fresh deleteForMe). */
3558
3636
  suppressedLoaded = false;
3637
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3638
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3639
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3640
+ elevated = /* @__PURE__ */ new Set();
3641
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3642
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3643
+ elevatedLoaded = false;
3559
3644
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3560
3645
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3561
3646
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3567,6 +3652,14 @@ var Chat = class {
3567
3652
  wired = false;
3568
3653
  liveUnsub = null;
3569
3654
  listeners = /* @__PURE__ */ new Set();
3655
+ /**
3656
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3657
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3658
+ * via the persisted elevation set, so this never double-fires for one mention. The
3659
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3660
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3661
+ */
3662
+ onMentionElevation;
3570
3663
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3571
3664
  constructor(args) {
3572
3665
  this.backend = args.backend;
@@ -3644,7 +3737,8 @@ var Chat = class {
3644
3737
  reactions: {},
3645
3738
  replyTo: null,
3646
3739
  edited: false,
3647
- isDeleted: true
3740
+ isDeleted: true,
3741
+ mentions: []
3648
3742
  });
3649
3743
  continue;
3650
3744
  }
@@ -3680,6 +3774,7 @@ var Chat = class {
3680
3774
  this.wired = true;
3681
3775
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3682
3776
  void this.loadSuppressed();
3777
+ void this.loadElevated();
3683
3778
  void this.hydrateHistory();
3684
3779
  void this.refreshMembers();
3685
3780
  }
@@ -3701,6 +3796,17 @@ var Chat = class {
3701
3796
  } catch {
3702
3797
  }
3703
3798
  }
3799
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
3800
+ * gates the elevation DECISION, it does not change what renders. */
3801
+ async loadElevated() {
3802
+ if (this.elevatedLoaded || !this._group) return;
3803
+ this.elevatedLoaded = true;
3804
+ try {
3805
+ const keys = await this.backend.loadElevated(this._group);
3806
+ for (const k of keys) this.elevated.add(k);
3807
+ } catch {
3808
+ }
3809
+ }
3704
3810
  async hydrateHistory() {
3705
3811
  if (this.historyLoaded || !this._group) return;
3706
3812
  this.historyLoaded = true;
@@ -3730,7 +3836,9 @@ var Chat = class {
3730
3836
  if (m.clientMsgId && !m.isDeleted) {
3731
3837
  this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3732
3838
  }
3733
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3839
+ this.messageList.push(
3840
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3841
+ );
3734
3842
  changed = true;
3735
3843
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3736
3844
  }
@@ -3780,7 +3888,10 @@ var Chat = class {
3780
3888
  newText: incoming.edit.newText,
3781
3889
  epoch: incoming.epoch,
3782
3890
  serverSeq: incoming.serverSeq,
3783
- eventClientMsgId: incoming.clientMsgId
3891
+ eventClientMsgId: incoming.clientMsgId,
3892
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
3893
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
3894
+ bodyRanges: incoming.bodyRanges
3784
3895
  },
3785
3896
  this.authorOfTarget
3786
3897
  );
@@ -3808,6 +3919,7 @@ var Chat = class {
3808
3919
  if (incomingReplyRef) {
3809
3920
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3810
3921
  }
3922
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3811
3923
  const msg = {
3812
3924
  id: this.publicId(incoming.serverSeq),
3813
3925
  kind: this.kindOf(incoming),
@@ -3824,8 +3936,10 @@ var Chat = class {
3824
3936
  // Default false; applyEditOverlay below folds any edit that arrived first.
3825
3937
  edited: false,
3826
3938
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3827
- isDeleted: false
3939
+ isDeleted: false,
3940
+ mentions
3828
3941
  };
3942
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3829
3943
  if (incomingClientMsgId && incoming.text !== null) {
3830
3944
  this.byClientMsgId.set(incomingClientMsgId, {
3831
3945
  text: incoming.text,
@@ -3849,6 +3963,75 @@ var Chat = class {
3849
3963
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3850
3964
  * so it can be passed to the pure EditFold. */
3851
3965
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3966
+ // ── Mentions (mentions T6) ──
3967
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3968
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
3969
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
3970
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
3971
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
3972
+ resolveMentions(text, bodyRanges) {
3973
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
3974
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
3975
+ if (normalized.length === 0) return [];
3976
+ return normalized.map((r) => ({
3977
+ start: r.start,
3978
+ length: r.length,
3979
+ mentionedUserId: r.mentionedUserId,
3980
+ displayName: this.displayNameOf(r.mentionedUserId)
3981
+ }));
3982
+ }
3983
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
3984
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
3985
+ * A member rename then reflects on old messages. Returns the message unchanged when
3986
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
3987
+ resolveMentionNames(m) {
3988
+ if (!m.mentions || m.mentions.length === 0) {
3989
+ return m.mentions ? m : { ...m, mentions: [] };
3990
+ }
3991
+ let changed = false;
3992
+ const reresolved = m.mentions.map((span) => {
3993
+ const name = this.displayNameOf(span.mentionedUserId);
3994
+ if (name === span.displayName) return span;
3995
+ changed = true;
3996
+ return { ...span, displayName: name };
3997
+ });
3998
+ if (!changed) return m;
3999
+ return { ...m, mentions: reresolved };
4000
+ }
4001
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
4002
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
4003
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
4004
+ editMentions(targetClientMsgId, newText) {
4005
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
4006
+ if (!ranges) return [];
4007
+ return this.resolveMentions(newText, ranges);
4008
+ }
4009
+ /** Resolve a userId → its roster display name (null if not a known member). */
4010
+ displayNameOf(userId) {
4011
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
4012
+ }
4013
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
4014
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
4015
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
4016
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
4017
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
4018
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
4019
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
4020
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
4021
+ const me = this.backend.selfUserId;
4022
+ if (envelopeType === "edit") return;
4023
+ if (senderUserId === me) return;
4024
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
4025
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4026
+ const key = `${me}|${idPart}`;
4027
+ if (this.elevated.has(key)) return;
4028
+ this.elevated.add(key);
4029
+ if (this._group) {
4030
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
4031
+ });
4032
+ }
4033
+ this.onMentionElevation?.(message);
4034
+ }
3852
4035
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3853
4036
  * (a later own/peer edit must not overwrite the original we render against). The
3854
4037
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3905,9 +4088,10 @@ var Chat = class {
3905
4088
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3906
4089
  const text = editText ?? base;
3907
4090
  const edited = foldEdited || m.edited;
3908
- if (m.text === text && m.edited === edited) return m;
4091
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
4092
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3909
4093
  changed = true;
3910
- return { ...m, text, edited };
4094
+ return { ...m, text, edited, mentions };
3911
4095
  });
3912
4096
  if (changed) this.emit();
3913
4097
  }
@@ -3924,8 +4108,9 @@ var Chat = class {
3924
4108
  if (editText === null && !foldEdited) return m;
3925
4109
  const text = editText ?? m.text;
3926
4110
  const edited = foldEdited || m.edited;
3927
- if (m.text === text && m.edited === edited) return m;
3928
- return { ...m, text, edited };
4111
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
4112
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
4113
+ return { ...m, text, edited, mentions };
3929
4114
  }
3930
4115
  /** @internal — called by the backend's conv subscription. */
3931
4116
  applyConv(event, payload) {
@@ -3983,6 +4168,18 @@ var Chat = class {
3983
4168
  }
3984
4169
  this.editFold.reevaluateHeld(this.authorOfTarget);
3985
4170
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
4171
+ this.reresolveAllMentionNames();
4172
+ }
4173
+ /** Re-resolve roster display names across the whole transcript (called on a roster
4174
+ * change). Re-emits only if any name actually changed. */
4175
+ reresolveAllMentionNames() {
4176
+ let changed = false;
4177
+ this.messageList = this.messageList.map((m) => {
4178
+ const reresolved = this.resolveMentionNames(m);
4179
+ if (reresolved !== m) changed = true;
4180
+ return reresolved;
4181
+ });
4182
+ if (changed) this.emit();
3986
4183
  }
3987
4184
  seedMembersFromGroup(group) {
3988
4185
  const seed = [
@@ -4050,11 +4247,12 @@ var Chat = class {
4050
4247
  };
4051
4248
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4052
4249
  }
4053
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
4054
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4250
+ const bodyRanges = opts?.mentions ?? null;
4251
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4252
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
4055
4253
  return receipt;
4056
4254
  }
4057
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4255
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4058
4256
  if (receipt.serverSeq <= 0) return;
4059
4257
  const key = this.internalKey(receipt.serverSeq);
4060
4258
  if (this.seenKeys.has(key)) return;
@@ -4079,7 +4277,10 @@ var Chat = class {
4079
4277
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
4080
4278
  edited: false,
4081
4279
  // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4082
- isDeleted: false
4280
+ isDeleted: false,
4281
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4282
+ // sender never gets a wire echo of its own message — this is the only local copy).
4283
+ mentions: this.resolveMentions(text, bodyRanges)
4083
4284
  });
4084
4285
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4085
4286
  this.emit();
@@ -4167,15 +4368,18 @@ var Chat = class {
4167
4368
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
4168
4369
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
4169
4370
  * reactions + reply context. Only the original author's edits count — for an own
4170
- * message self IS the author, so the author-gate passes. */
4171
- async edit(message, newText) {
4371
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4372
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4373
+ async edit(message, newText, opts) {
4172
4374
  if (!message.clientMsgId || message.kind !== "text") return;
4173
4375
  const group = await this.materializeIfNeeded();
4174
4376
  const clientMsgId = mintClientMsgId();
4377
+ const bodyRanges = opts?.mentions ?? null;
4175
4378
  const { receipt } = await this.backend.sendEdit(group, {
4176
4379
  clientMsgId,
4177
4380
  targetClientMsgId: message.clientMsgId,
4178
- newText
4381
+ newText,
4382
+ bodyRanges
4179
4383
  });
4180
4384
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4181
4385
  this.editFold.ingest(
@@ -4185,7 +4389,8 @@ var Chat = class {
4185
4389
  newText,
4186
4390
  epoch: receipt.epoch,
4187
4391
  serverSeq: receipt.serverSeq,
4188
- eventClientMsgId: clientMsgId
4392
+ eventClientMsgId: clientMsgId,
4393
+ bodyRanges
4189
4394
  },
4190
4395
  this.authorOfTarget
4191
4396
  );
@@ -4238,6 +4443,18 @@ var Chat = class {
4238
4443
  }
4239
4444
  }
4240
4445
  };
4446
+ function sameMentions(a, b) {
4447
+ if (a.length !== b.length) return false;
4448
+ for (let i = 0; i < a.length; i++) {
4449
+ const x = a[i];
4450
+ const y = b[i];
4451
+ if (!x || !y) return false;
4452
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4453
+ return false;
4454
+ }
4455
+ }
4456
+ return true;
4457
+ }
4241
4458
  function sameReactions(a, b) {
4242
4459
  const ak = Object.keys(a);
4243
4460
  const bk = Object.keys(b);
@@ -4449,14 +4666,21 @@ var MessageDeliverySource = class {
4449
4666
  // Thread the edit discriminator + new text through the persisted row so an
4450
4667
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4451
4668
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4452
- // `'text'`/no-edit (backward-compat).
4669
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
4670
+ // along so the edited message's mentions re-resolve on cold launch (T6).
4453
4671
  ...isEdit && decoded.edit ? {
4454
4672
  envelopeType: "edit",
4455
4673
  edit: {
4456
4674
  targetClientMsgId: decoded.edit.targetClientMsgId,
4457
- newText: decoded.edit.newText
4675
+ newText: decoded.edit.newText,
4676
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4458
4677
  }
4459
4678
  } : {},
4679
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
4680
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
4681
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
4682
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
4683
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4460
4684
  // Thread the delete discriminator + target through the persisted row so a
4461
4685
  // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4462
4686
  // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
@@ -4488,7 +4712,10 @@ var MessageDeliverySource = class {
4488
4712
  envelopeType: decoded.type ?? "text",
4489
4713
  reaction: isReaction ? decoded.reaction : null,
4490
4714
  edit: isEdit ? decoded.edit : null,
4491
- delete: isDelete ? decoded.delete : null
4715
+ delete: isDelete ? decoded.delete : null,
4716
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
4717
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4718
+ bodyRanges: decoded.bodyRanges ?? null
4492
4719
  });
4493
4720
  return true;
4494
4721
  }
@@ -4643,6 +4870,36 @@ var GroupCatalog = class {
4643
4870
  }
4644
4871
  };
4645
4872
 
4873
+ // src/messaging/mention-elevation.ts
4874
+ var MentionElevationStore = class {
4875
+ constructor(kv) {
4876
+ this.kv = kv;
4877
+ }
4878
+ kv;
4879
+ key(rfcGroupId) {
4880
+ return `elev:${rfcGroupId}`;
4881
+ }
4882
+ /** Load the persisted elevation keys for a chat (empty array if none). */
4883
+ async load(rfcGroupId) {
4884
+ const raw = await this.kv.get(this.key(rfcGroupId));
4885
+ if (!raw) return [];
4886
+ try {
4887
+ const parsed = JSON.parse(decodeUtf8(raw));
4888
+ return Array.isArray(parsed) ? parsed : [];
4889
+ } catch {
4890
+ return [];
4891
+ }
4892
+ }
4893
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
4894
+ async save(rfcGroupId, keys) {
4895
+ const sorted = [...new Set(keys)].sort();
4896
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
4897
+ }
4898
+ async wipe() {
4899
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
4900
+ }
4901
+ };
4902
+
4646
4903
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4647
4904
  var palbe_mls_bg_exports = {};
4648
4905
  __export(palbe_mls_bg_exports, {
@@ -6350,6 +6607,7 @@ var MessagingCoordinator = class {
6350
6607
  this.groupStore = new GroupStateStorage(this.kv);
6351
6608
  this.kpStore = new KeyPackageStorage(this.kv);
6352
6609
  this.suppressionStore = new SuppressionStore(this.kv);
6610
+ this.elevationStore = new MentionElevationStore(this.kv);
6353
6611
  this.registry.attachChatList(
6354
6612
  (chats) => {
6355
6613
  this.chatList = chats;
@@ -6365,6 +6623,7 @@ var MessagingCoordinator = class {
6365
6623
  groupStore;
6366
6624
  kpStore;
6367
6625
  suppressionStore;
6626
+ elevationStore;
6368
6627
  registry = new GroupRegistry();
6369
6628
  resolved = null;
6370
6629
  resolvePromise = null;
@@ -6520,9 +6779,9 @@ var MessagingCoordinator = class {
6520
6779
  });
6521
6780
  return group;
6522
6781
  }
6523
- async sendText(group, text, replyTo) {
6782
+ async sendText(group, text, replyTo, bodyRanges) {
6524
6783
  const r = await this.resolve();
6525
- return r.groups.sendText(group, text, replyTo);
6784
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6526
6785
  }
6527
6786
  async sendReaction(group, args) {
6528
6787
  const r = await this.resolve();
@@ -6544,6 +6803,14 @@ var MessagingCoordinator = class {
6544
6803
  saveSuppressed(group, keys) {
6545
6804
  return this.suppressionStore.save(group.rfcGroupId, keys);
6546
6805
  }
6806
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
6807
+ loadElevated(group) {
6808
+ return this.elevationStore.load(group.rfcGroupId);
6809
+ }
6810
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
6811
+ saveElevated(group, keys) {
6812
+ return this.elevationStore.save(group.rfcGroupId, keys);
6813
+ }
6547
6814
  async history(group, limit, before) {
6548
6815
  const r = await this.resolve();
6549
6816
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6662,7 +6929,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6662
6929
  newText: s.edit.newText,
6663
6930
  epoch: s.epoch,
6664
6931
  serverSeq: s.serverSeq,
6665
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
6932
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
6933
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
6934
+ // edit's ranges drive the edited message's mentions on cold launch.
6935
+ bodyRanges: s.edit.bodyRanges ?? null
6666
6936
  },
6667
6937
  authorOfTarget
6668
6938
  );
@@ -6712,7 +6982,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6712
6982
  replyTo: null,
6713
6983
  reactions: {},
6714
6984
  edited: false,
6715
- isDeleted: true
6985
+ isDeleted: true,
6986
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6987
+ mentions: []
6716
6988
  });
6717
6989
  continue;
6718
6990
  }
@@ -6732,23 +7004,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6732
7004
  }
6733
7005
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6734
7006
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
7007
+ const text = editText ?? s.text;
7008
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
7009
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6735
7010
  out.push({
6736
7011
  id: `${displayId}#${s.serverSeq}`,
6737
7012
  kind: s.text != null ? "text" : "system",
6738
7013
  direction: s.direction,
6739
7014
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6740
- text: editText ?? s.text,
7015
+ text,
6741
7016
  serverSeq: s.serverSeq,
6742
7017
  sentAt: new Date(s.at),
6743
7018
  clientMsgId,
6744
7019
  replyTo,
6745
7020
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6746
7021
  edited,
6747
- isDeleted: false
7022
+ isDeleted: false,
7023
+ mentions
6748
7024
  });
6749
7025
  }
6750
7026
  return out;
6751
7027
  }
7028
+ function normalizeMentionsNullNames(raw, text) {
7029
+ if (text === null || !raw || raw.length === 0) return [];
7030
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
7031
+ start: r.start,
7032
+ length: r.length,
7033
+ mentionedUserId: r.mentionedUserId,
7034
+ displayName: null
7035
+ }));
7036
+ }
6752
7037
 
6753
7038
  // src/messaging/facade.ts
6754
7039
  var PalbeMessaging = class {
@@ -7482,7 +7767,7 @@ function defaultSessionStorage(key) {
7482
7767
  }
7483
7768
 
7484
7769
  // src/version.ts
7485
- var VERSION = "1.4.0";
7770
+ var VERSION = "1.5.0";
7486
7771
 
7487
7772
  // src/runtime.ts
7488
7773
  function buildRuntime(config) {