@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/index.cjs CHANGED
@@ -1715,7 +1715,8 @@ var EditFold = class {
1715
1715
  orderEpoch: e.epoch,
1716
1716
  orderSeq: e.serverSeq,
1717
1717
  lastEventId: e.eventClientMsgId,
1718
- text: e.newText
1718
+ text: e.newText,
1719
+ bodyRanges: e.bodyRanges ?? null
1719
1720
  });
1720
1721
  this.editedTargets.add(e.targetClientMsgId);
1721
1722
  }
@@ -1736,6 +1737,15 @@ var EditFold = class {
1736
1737
  isEdited(targetClientMsgId) {
1737
1738
  return this.editedTargets.has(targetClientMsgId);
1738
1739
  }
1740
+ /**
1741
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
1742
+ * or null when no valid edit applied or the winning edit carried none. The Chat
1743
+ * normalizes these against the edited text to compute the edited message's mentions
1744
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
1745
+ */
1746
+ bodyRanges(targetClientMsgId) {
1747
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
1748
+ }
1739
1749
  /**
1740
1750
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
1741
1751
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -1909,7 +1919,14 @@ function encodeEdit(args) {
1909
1919
  type: "edit",
1910
1920
  client_msg_id: args.clientMsgId,
1911
1921
  target_client_msg_id: args.targetClientMsgId,
1912
- new_text: args.newText
1922
+ new_text: args.newText,
1923
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
1924
+ body_ranges: args.bodyRanges.map((r) => ({
1925
+ start: r.start,
1926
+ length: r.length,
1927
+ mentioned_user_id: r.mentionedUserId
1928
+ }))
1929
+ } : {}
1913
1930
  })
1914
1931
  );
1915
1932
  }
@@ -1931,7 +1948,14 @@ function encodeEnvelope(args) {
1931
1948
  type: "text",
1932
1949
  client_msg_id: args.clientMsgId,
1933
1950
  text: args.text,
1934
- ...args.replyTo ? { reply_to: args.replyTo } : {}
1951
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
1952
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
1953
+ body_ranges: args.bodyRanges.map((r) => ({
1954
+ start: r.start,
1955
+ length: r.length,
1956
+ mentioned_user_id: r.mentionedUserId
1957
+ }))
1958
+ } : {}
1935
1959
  };
1936
1960
  return encodeUtf8(JSON.stringify(env));
1937
1961
  }
@@ -1965,6 +1989,7 @@ function decodeEnvelope(bytes) {
1965
1989
  };
1966
1990
  }
1967
1991
  if (typeof o === "object" && o !== null && o.type === "edit") {
1992
+ const editRanges = decodeBodyRanges(o.body_ranges);
1968
1993
  return {
1969
1994
  type: "edit",
1970
1995
  text: null,
@@ -1973,15 +1998,18 @@ function decodeEnvelope(bytes) {
1973
1998
  edit: {
1974
1999
  targetClientMsgId: o.target_client_msg_id ?? "",
1975
2000
  newText: o.new_text ?? ""
1976
- }
2001
+ },
2002
+ ...editRanges ? { bodyRanges: editRanges } : {}
1977
2003
  };
1978
2004
  }
1979
2005
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2006
+ const textRanges = decodeBodyRanges(o.body_ranges);
1980
2007
  return {
1981
2008
  type: "text",
1982
2009
  text: o.text ?? null,
1983
2010
  clientMsgId: o.client_msg_id ?? "",
1984
- replyTo: o.reply_to ?? null
2011
+ replyTo: o.reply_to ?? null,
2012
+ ...textRanges ? { bodyRanges: textRanges } : {}
1985
2013
  };
1986
2014
  }
1987
2015
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -1993,6 +2021,14 @@ function decodeEnvelope(bytes) {
1993
2021
  }
1994
2022
  return { text: s, clientMsgId: "", replyTo: null };
1995
2023
  }
2024
+ function decodeBodyRanges(raw) {
2025
+ if (!raw || raw.length === 0) return void 0;
2026
+ return raw.map((r) => ({
2027
+ start: r.start,
2028
+ length: r.length,
2029
+ mentionedUserId: r.mentioned_user_id
2030
+ }));
2031
+ }
1996
2032
  function resolveReply(ref, lookup) {
1997
2033
  const parent = lookup(ref.client_msg_id);
1998
2034
  if (parent !== null) {
@@ -2204,9 +2240,9 @@ var GroupMessaging = class {
2204
2240
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
2205
2241
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
2206
2242
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
2207
- async sendText(group, text, replyTo) {
2243
+ async sendText(group, text, replyTo, bodyRanges) {
2208
2244
  const clientMsgId = mintClientMsgId();
2209
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
2245
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
2210
2246
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2211
2247
  const body = {
2212
2248
  ciphertext_b64: toBase64(ct),
@@ -2232,7 +2268,10 @@ var GroupMessaging = class {
2232
2268
  previewBody: replyTo.preview?.body ?? null,
2233
2269
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
2234
2270
  previewKind: replyTo.preview?.kind ?? "text"
2235
- } : null
2271
+ } : null,
2272
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
2273
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
2274
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
2236
2275
  };
2237
2276
  try {
2238
2277
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -2300,7 +2339,8 @@ var GroupMessaging = class {
2300
2339
  const plaintext = encodeEdit({
2301
2340
  clientMsgId: args.clientMsgId,
2302
2341
  targetClientMsgId: args.targetClientMsgId,
2303
- newText: args.newText
2342
+ newText: args.newText,
2343
+ bodyRanges: args.bodyRanges
2304
2344
  });
2305
2345
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2306
2346
  const body = {
@@ -2326,7 +2366,10 @@ var GroupMessaging = class {
2326
2366
  envelopeType: "edit",
2327
2367
  edit: {
2328
2368
  targetClientMsgId: args.targetClientMsgId,
2329
- newText: args.newText
2369
+ newText: args.newText,
2370
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
2371
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
2372
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
2330
2373
  }
2331
2374
  };
2332
2375
  try {
@@ -2448,6 +2491,41 @@ var GroupMessaging = class {
2448
2491
  }
2449
2492
  };
2450
2493
 
2494
+ // src/messaging/mention-ranges.ts
2495
+ function normalizeMentionRangesUtf16(ranges, text) {
2496
+ const n = text.length;
2497
+ function splitsSurrogatePair(index) {
2498
+ if (index <= 0 || index >= n) return false;
2499
+ const before = text.charCodeAt(index - 1);
2500
+ const at = text.charCodeAt(index);
2501
+ const beforeIsHigh = before >= 55296 && before <= 56319;
2502
+ const atIsLow = at >= 56320 && at <= 57343;
2503
+ return beforeIsHigh && atIsLow;
2504
+ }
2505
+ const survivors = [];
2506
+ for (let idx = 0; idx < ranges.length; idx++) {
2507
+ const r = ranges[idx];
2508
+ if (r === void 0) continue;
2509
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
2510
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
2511
+ survivors.push({ idx, range: r });
2512
+ }
2513
+ survivors.sort((lhs, rhs) => {
2514
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
2515
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
2516
+ return lhs.idx - rhs.idx;
2517
+ });
2518
+ const kept = [];
2519
+ let prevEnd = Number.NEGATIVE_INFINITY;
2520
+ for (const s of survivors) {
2521
+ if (s.range.start >= prevEnd) {
2522
+ kept.push(s.range);
2523
+ prevEnd = s.range.start + s.range.length;
2524
+ }
2525
+ }
2526
+ return kept;
2527
+ }
2528
+
2451
2529
  // src/messaging/reaction-fold.ts
2452
2530
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
2453
2531
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2535,6 +2613,13 @@ var Chat = class {
2535
2613
  /** True once the persisted suppression set has been loaded (so the omit applies
2536
2614
  * even on the cold-launch hydrate path before a fresh deleteForMe). */
2537
2615
  suppressedLoaded = false;
2616
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
2617
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
2618
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
2619
+ elevated = /* @__PURE__ */ new Set();
2620
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
2621
+ * on the cold-launch hydrate path dedups against the persisted decision). */
2622
+ elevatedLoaded = false;
2538
2623
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
2539
2624
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
2540
2625
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -2546,6 +2631,14 @@ var Chat = class {
2546
2631
  wired = false;
2547
2632
  liveUnsub = null;
2548
2633
  listeners = /* @__PURE__ */ new Set();
2634
+ /**
2635
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
2636
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
2637
+ * via the persisted elevation set, so this never double-fires for one mention. The
2638
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
2639
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
2640
+ */
2641
+ onMentionElevation;
2549
2642
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
2550
2643
  constructor(args) {
2551
2644
  this.backend = args.backend;
@@ -2623,7 +2716,8 @@ var Chat = class {
2623
2716
  reactions: {},
2624
2717
  replyTo: null,
2625
2718
  edited: false,
2626
- isDeleted: true
2719
+ isDeleted: true,
2720
+ mentions: []
2627
2721
  });
2628
2722
  continue;
2629
2723
  }
@@ -2659,6 +2753,7 @@ var Chat = class {
2659
2753
  this.wired = true;
2660
2754
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
2661
2755
  void this.loadSuppressed();
2756
+ void this.loadElevated();
2662
2757
  void this.hydrateHistory();
2663
2758
  void this.refreshMembers();
2664
2759
  }
@@ -2680,6 +2775,17 @@ var Chat = class {
2680
2775
  } catch {
2681
2776
  }
2682
2777
  }
2778
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
2779
+ * gates the elevation DECISION, it does not change what renders. */
2780
+ async loadElevated() {
2781
+ if (this.elevatedLoaded || !this._group) return;
2782
+ this.elevatedLoaded = true;
2783
+ try {
2784
+ const keys = await this.backend.loadElevated(this._group);
2785
+ for (const k of keys) this.elevated.add(k);
2786
+ } catch {
2787
+ }
2788
+ }
2683
2789
  async hydrateHistory() {
2684
2790
  if (this.historyLoaded || !this._group) return;
2685
2791
  this.historyLoaded = true;
@@ -2709,7 +2815,9 @@ var Chat = class {
2709
2815
  if (m.clientMsgId && !m.isDeleted) {
2710
2816
  this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
2711
2817
  }
2712
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
2818
+ this.messageList.push(
2819
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
2820
+ );
2713
2821
  changed = true;
2714
2822
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
2715
2823
  }
@@ -2759,7 +2867,10 @@ var Chat = class {
2759
2867
  newText: incoming.edit.newText,
2760
2868
  epoch: incoming.epoch,
2761
2869
  serverSeq: incoming.serverSeq,
2762
- eventClientMsgId: incoming.clientMsgId
2870
+ eventClientMsgId: incoming.clientMsgId,
2871
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
2872
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
2873
+ bodyRanges: incoming.bodyRanges
2763
2874
  },
2764
2875
  this.authorOfTarget
2765
2876
  );
@@ -2787,6 +2898,7 @@ var Chat = class {
2787
2898
  if (incomingReplyRef) {
2788
2899
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
2789
2900
  }
2901
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
2790
2902
  const msg = {
2791
2903
  id: this.publicId(incoming.serverSeq),
2792
2904
  kind: this.kindOf(incoming),
@@ -2803,8 +2915,10 @@ var Chat = class {
2803
2915
  // Default false; applyEditOverlay below folds any edit that arrived first.
2804
2916
  edited: false,
2805
2917
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
2806
- isDeleted: false
2918
+ isDeleted: false,
2919
+ mentions
2807
2920
  };
2921
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
2808
2922
  if (incomingClientMsgId && incoming.text !== null) {
2809
2923
  this.byClientMsgId.set(incomingClientMsgId, {
2810
2924
  text: incoming.text,
@@ -2828,6 +2942,75 @@ var Chat = class {
2828
2942
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
2829
2943
  * so it can be passed to the pure EditFold. */
2830
2944
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
2945
+ // ── Mentions (mentions T6) ──
2946
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
2947
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
2948
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
2949
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
2950
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
2951
+ resolveMentions(text, bodyRanges) {
2952
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
2953
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
2954
+ if (normalized.length === 0) return [];
2955
+ return normalized.map((r) => ({
2956
+ start: r.start,
2957
+ length: r.length,
2958
+ mentionedUserId: r.mentionedUserId,
2959
+ displayName: this.displayNameOf(r.mentionedUserId)
2960
+ }));
2961
+ }
2962
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
2963
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
2964
+ * A member rename then reflects on old messages. Returns the message unchanged when
2965
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
2966
+ resolveMentionNames(m) {
2967
+ if (!m.mentions || m.mentions.length === 0) {
2968
+ return m.mentions ? m : { ...m, mentions: [] };
2969
+ }
2970
+ let changed = false;
2971
+ const reresolved = m.mentions.map((span) => {
2972
+ const name = this.displayNameOf(span.mentionedUserId);
2973
+ if (name === span.displayName) return span;
2974
+ changed = true;
2975
+ return { ...span, displayName: name };
2976
+ });
2977
+ if (!changed) return m;
2978
+ return { ...m, mentions: reresolved };
2979
+ }
2980
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
2981
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
2982
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
2983
+ editMentions(targetClientMsgId, newText) {
2984
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
2985
+ if (!ranges) return [];
2986
+ return this.resolveMentions(newText, ranges);
2987
+ }
2988
+ /** Resolve a userId → its roster display name (null if not a known member). */
2989
+ displayNameOf(userId) {
2990
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
2991
+ }
2992
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
2993
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
2994
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
2995
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
2996
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
2997
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
2998
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
2999
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
3000
+ const me = this.backend.selfUserId;
3001
+ if (envelopeType === "edit") return;
3002
+ if (senderUserId === me) return;
3003
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
3004
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3005
+ const key = `${me}|${idPart}`;
3006
+ if (this.elevated.has(key)) return;
3007
+ this.elevated.add(key);
3008
+ if (this._group) {
3009
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
3010
+ });
3011
+ }
3012
+ this.onMentionElevation?.(message);
3013
+ }
2831
3014
  /** Seed the per-target base text + author for the edit fold. Base is write-once
2832
3015
  * (a later own/peer edit must not overwrite the original we render against). The
2833
3016
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -2884,9 +3067,10 @@ var Chat = class {
2884
3067
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
2885
3068
  const text = editText ?? base;
2886
3069
  const edited = foldEdited || m.edited;
2887
- if (m.text === text && m.edited === edited) return m;
3070
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
3071
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
2888
3072
  changed = true;
2889
- return { ...m, text, edited };
3073
+ return { ...m, text, edited, mentions };
2890
3074
  });
2891
3075
  if (changed) this.emit();
2892
3076
  }
@@ -2903,8 +3087,9 @@ var Chat = class {
2903
3087
  if (editText === null && !foldEdited) return m;
2904
3088
  const text = editText ?? m.text;
2905
3089
  const edited = foldEdited || m.edited;
2906
- if (m.text === text && m.edited === edited) return m;
2907
- return { ...m, text, edited };
3090
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
3091
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3092
+ return { ...m, text, edited, mentions };
2908
3093
  }
2909
3094
  /** @internal — called by the backend's conv subscription. */
2910
3095
  applyConv(event, payload) {
@@ -2962,6 +3147,18 @@ var Chat = class {
2962
3147
  }
2963
3148
  this.editFold.reevaluateHeld(this.authorOfTarget);
2964
3149
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3150
+ this.reresolveAllMentionNames();
3151
+ }
3152
+ /** Re-resolve roster display names across the whole transcript (called on a roster
3153
+ * change). Re-emits only if any name actually changed. */
3154
+ reresolveAllMentionNames() {
3155
+ let changed = false;
3156
+ this.messageList = this.messageList.map((m) => {
3157
+ const reresolved = this.resolveMentionNames(m);
3158
+ if (reresolved !== m) changed = true;
3159
+ return reresolved;
3160
+ });
3161
+ if (changed) this.emit();
2965
3162
  }
2966
3163
  seedMembersFromGroup(group) {
2967
3164
  const seed = [
@@ -3029,11 +3226,12 @@ var Chat = class {
3029
3226
  };
3030
3227
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
3031
3228
  }
3032
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
3033
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
3229
+ const bodyRanges = opts?.mentions ?? null;
3230
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
3231
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
3034
3232
  return receipt;
3035
3233
  }
3036
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
3234
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
3037
3235
  if (receipt.serverSeq <= 0) return;
3038
3236
  const key = this.internalKey(receipt.serverSeq);
3039
3237
  if (this.seenKeys.has(key)) return;
@@ -3058,7 +3256,10 @@ var Chat = class {
3058
3256
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3059
3257
  edited: false,
3060
3258
  // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
3061
- isDeleted: false
3259
+ isDeleted: false,
3260
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
3261
+ // sender never gets a wire echo of its own message — this is the only local copy).
3262
+ mentions: this.resolveMentions(text, bodyRanges)
3062
3263
  });
3063
3264
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3064
3265
  this.emit();
@@ -3146,15 +3347,18 @@ var Chat = class {
3146
3347
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3147
3348
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3148
3349
  * reactions + reply context. Only the original author's edits count — for an own
3149
- * message self IS the author, so the author-gate passes. */
3150
- async edit(message, newText) {
3350
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
3351
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
3352
+ async edit(message, newText, opts) {
3151
3353
  if (!message.clientMsgId || message.kind !== "text") return;
3152
3354
  const group = await this.materializeIfNeeded();
3153
3355
  const clientMsgId = mintClientMsgId();
3356
+ const bodyRanges = opts?.mentions ?? null;
3154
3357
  const { receipt } = await this.backend.sendEdit(group, {
3155
3358
  clientMsgId,
3156
3359
  targetClientMsgId: message.clientMsgId,
3157
- newText
3360
+ newText,
3361
+ bodyRanges
3158
3362
  });
3159
3363
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3160
3364
  this.editFold.ingest(
@@ -3164,7 +3368,8 @@ var Chat = class {
3164
3368
  newText,
3165
3369
  epoch: receipt.epoch,
3166
3370
  serverSeq: receipt.serverSeq,
3167
- eventClientMsgId: clientMsgId
3371
+ eventClientMsgId: clientMsgId,
3372
+ bodyRanges
3168
3373
  },
3169
3374
  this.authorOfTarget
3170
3375
  );
@@ -3217,6 +3422,18 @@ var Chat = class {
3217
3422
  }
3218
3423
  }
3219
3424
  };
3425
+ function sameMentions(a, b) {
3426
+ if (a.length !== b.length) return false;
3427
+ for (let i = 0; i < a.length; i++) {
3428
+ const x = a[i];
3429
+ const y = b[i];
3430
+ if (!x || !y) return false;
3431
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
3432
+ return false;
3433
+ }
3434
+ }
3435
+ return true;
3436
+ }
3220
3437
  function sameReactions(a, b) {
3221
3438
  const ak = Object.keys(a);
3222
3439
  const bk = Object.keys(b);
@@ -3428,14 +3645,21 @@ var MessageDeliverySource = class {
3428
3645
  // Thread the edit discriminator + new text through the persisted row so an
3429
3646
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
3430
3647
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
3431
- // `'text'`/no-edit (backward-compat).
3648
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
3649
+ // along so the edited message's mentions re-resolve on cold launch (T6).
3432
3650
  ...isEdit && decoded.edit ? {
3433
3651
  envelopeType: "edit",
3434
3652
  edit: {
3435
3653
  targetClientMsgId: decoded.edit.targetClientMsgId,
3436
- newText: decoded.edit.newText
3654
+ newText: decoded.edit.newText,
3655
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
3437
3656
  }
3438
3657
  } : {},
3658
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
3659
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
3660
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
3661
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
3662
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
3439
3663
  // Thread the delete discriminator + target through the persisted row so a
3440
3664
  // delete-for-everyone tombstone folded LIVE re-folds onto its target after
3441
3665
  // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
@@ -3467,7 +3691,10 @@ var MessageDeliverySource = class {
3467
3691
  envelopeType: decoded.type ?? "text",
3468
3692
  reaction: isReaction ? decoded.reaction : null,
3469
3693
  edit: isEdit ? decoded.edit : null,
3470
- delete: isDelete ? decoded.delete : null
3694
+ delete: isDelete ? decoded.delete : null,
3695
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
3696
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
3697
+ bodyRanges: decoded.bodyRanges ?? null
3471
3698
  });
3472
3699
  return true;
3473
3700
  }
@@ -3622,6 +3849,36 @@ var GroupCatalog = class {
3622
3849
  }
3623
3850
  };
3624
3851
 
3852
+ // src/messaging/mention-elevation.ts
3853
+ var MentionElevationStore = class {
3854
+ constructor(kv) {
3855
+ this.kv = kv;
3856
+ }
3857
+ kv;
3858
+ key(rfcGroupId) {
3859
+ return `elev:${rfcGroupId}`;
3860
+ }
3861
+ /** Load the persisted elevation keys for a chat (empty array if none). */
3862
+ async load(rfcGroupId) {
3863
+ const raw = await this.kv.get(this.key(rfcGroupId));
3864
+ if (!raw) return [];
3865
+ try {
3866
+ const parsed = JSON.parse(decodeUtf8(raw));
3867
+ return Array.isArray(parsed) ? parsed : [];
3868
+ } catch {
3869
+ return [];
3870
+ }
3871
+ }
3872
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
3873
+ async save(rfcGroupId, keys) {
3874
+ const sorted = [...new Set(keys)].sort();
3875
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
3876
+ }
3877
+ async wipe() {
3878
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
3879
+ }
3880
+ };
3881
+
3625
3882
  // src/messaging/wasm/pkg/palbe_mls_bg.js
3626
3883
  var palbe_mls_bg_exports = {};
3627
3884
  __export(palbe_mls_bg_exports, {
@@ -5329,6 +5586,7 @@ var MessagingCoordinator = class {
5329
5586
  this.groupStore = new GroupStateStorage(this.kv);
5330
5587
  this.kpStore = new KeyPackageStorage(this.kv);
5331
5588
  this.suppressionStore = new SuppressionStore(this.kv);
5589
+ this.elevationStore = new MentionElevationStore(this.kv);
5332
5590
  this.registry.attachChatList(
5333
5591
  (chats) => {
5334
5592
  this.chatList = chats;
@@ -5344,6 +5602,7 @@ var MessagingCoordinator = class {
5344
5602
  groupStore;
5345
5603
  kpStore;
5346
5604
  suppressionStore;
5605
+ elevationStore;
5347
5606
  registry = new GroupRegistry();
5348
5607
  resolved = null;
5349
5608
  resolvePromise = null;
@@ -5499,9 +5758,9 @@ var MessagingCoordinator = class {
5499
5758
  });
5500
5759
  return group;
5501
5760
  }
5502
- async sendText(group, text, replyTo) {
5761
+ async sendText(group, text, replyTo, bodyRanges) {
5503
5762
  const r = await this.resolve();
5504
- return r.groups.sendText(group, text, replyTo);
5763
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
5505
5764
  }
5506
5765
  async sendReaction(group, args) {
5507
5766
  const r = await this.resolve();
@@ -5523,6 +5782,14 @@ var MessagingCoordinator = class {
5523
5782
  saveSuppressed(group, keys) {
5524
5783
  return this.suppressionStore.save(group.rfcGroupId, keys);
5525
5784
  }
5785
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
5786
+ loadElevated(group) {
5787
+ return this.elevationStore.load(group.rfcGroupId);
5788
+ }
5789
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
5790
+ saveElevated(group, keys) {
5791
+ return this.elevationStore.save(group.rfcGroupId, keys);
5792
+ }
5526
5793
  async history(group, limit, before) {
5527
5794
  const r = await this.resolve();
5528
5795
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -5641,7 +5908,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5641
5908
  newText: s.edit.newText,
5642
5909
  epoch: s.epoch,
5643
5910
  serverSeq: s.serverSeq,
5644
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
5911
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
5912
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
5913
+ // edit's ranges drive the edited message's mentions on cold launch.
5914
+ bodyRanges: s.edit.bodyRanges ?? null
5645
5915
  },
5646
5916
  authorOfTarget
5647
5917
  );
@@ -5691,7 +5961,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5691
5961
  replyTo: null,
5692
5962
  reactions: {},
5693
5963
  edited: false,
5694
- isDeleted: true
5964
+ isDeleted: true,
5965
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
5966
+ mentions: []
5695
5967
  });
5696
5968
  continue;
5697
5969
  }
@@ -5711,23 +5983,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5711
5983
  }
5712
5984
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
5713
5985
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
5986
+ const text = editText ?? s.text;
5987
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
5988
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
5714
5989
  out.push({
5715
5990
  id: `${displayId}#${s.serverSeq}`,
5716
5991
  kind: s.text != null ? "text" : "system",
5717
5992
  direction: s.direction,
5718
5993
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
5719
- text: editText ?? s.text,
5994
+ text,
5720
5995
  serverSeq: s.serverSeq,
5721
5996
  sentAt: new Date(s.at),
5722
5997
  clientMsgId,
5723
5998
  replyTo,
5724
5999
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
5725
6000
  edited,
5726
- isDeleted: false
6001
+ isDeleted: false,
6002
+ mentions
5727
6003
  });
5728
6004
  }
5729
6005
  return out;
5730
6006
  }
6007
+ function normalizeMentionsNullNames(raw, text) {
6008
+ if (text === null || !raw || raw.length === 0) return [];
6009
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
6010
+ start: r.start,
6011
+ length: r.length,
6012
+ mentionedUserId: r.mentionedUserId,
6013
+ displayName: null
6014
+ }));
6015
+ }
5731
6016
 
5732
6017
  // src/messaging/facade.ts
5733
6018
  var PalbeMessaging = class {
@@ -6580,7 +6865,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
6580
6865
  }
6581
6866
 
6582
6867
  // src/version.ts
6583
- var VERSION = "1.4.0";
6868
+ var VERSION = "1.5.0";
6584
6869
 
6585
6870
  // src/internal.ts
6586
6871
  function getRuntime() {