@palbase/web 1.4.0 → 1.6.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
@@ -2612,6 +2612,47 @@ var PalbeFlags = class {
2612
2612
  }
2613
2613
  };
2614
2614
 
2615
+ // src/messaging/deadline-calculator.ts
2616
+ function remainingSeconds(args) {
2617
+ const ttl = args.ttlSeconds;
2618
+ const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
2619
+ let elapsed;
2620
+ if (args.nowBootToken === args.anchor.bAnchorToken) {
2621
+ const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
2622
+ elapsed = Math.max(monoDeltaSec, wallDeltaSec);
2623
+ } else {
2624
+ elapsed = wallDeltaSec;
2625
+ }
2626
+ const remaining = Math.min(ttl, ttl - elapsed);
2627
+ return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
2628
+ }
2629
+ var cachedBootToken = null;
2630
+ var MonotonicClock = {
2631
+ nowMs() {
2632
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
2633
+ },
2634
+ nowWallEpochMs() {
2635
+ return Date.now();
2636
+ },
2637
+ bootToken() {
2638
+ if (cachedBootToken !== null) return cachedBootToken;
2639
+ try {
2640
+ const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
2641
+ if (existing) {
2642
+ cachedBootToken = existing;
2643
+ return existing;
2644
+ }
2645
+ const fresh = crypto.randomUUID();
2646
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
2647
+ cachedBootToken = fresh;
2648
+ return fresh;
2649
+ } catch {
2650
+ cachedBootToken = crypto.randomUUID();
2651
+ return cachedBootToken;
2652
+ }
2653
+ }
2654
+ };
2655
+
2615
2656
  // src/messaging/delete-fold.ts
2616
2657
  var DeleteFold = class {
2617
2658
  // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
@@ -2624,17 +2665,24 @@ var DeleteFold = class {
2624
2665
  // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2625
2666
  held = [];
2626
2667
  /**
2627
- * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2628
- * userId (null = target absent locally → defer).
2668
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's author via
2669
+ * {@link AuthorResolution}: `'author'` run the author-gate; `'unknown'` → defer
2670
+ * (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
2671
+ * was TTL-purged — a delete of an already-gone message is already satisfied; never
2672
+ * park in pending, never re-attempt).
2629
2673
  */
2630
2674
  ingest(e, authorOfTarget) {
2631
2675
  if (this.tombstoned.has(e.targetClientMsgId)) return;
2632
2676
  if (this.seen.has(e.eventClientMsgId)) return;
2633
2677
  if (this.heldContains(e.eventClientMsgId)) return;
2634
- const author = authorOfTarget(e.targetClientMsgId);
2635
- if (author !== null) {
2678
+ const res = authorOfTarget(e.targetClientMsgId);
2679
+ if (res.kind === "purged") {
2636
2680
  this.seen.add(e.eventClientMsgId);
2637
- if (e.actorUserId === null || e.actorUserId !== author) return;
2681
+ return;
2682
+ }
2683
+ if (res.kind === "author") {
2684
+ this.seen.add(e.eventClientMsgId);
2685
+ if (e.actorUserId === null || e.actorUserId !== res.userId) return;
2638
2686
  this.tombstoned.add(e.targetClientMsgId);
2639
2687
  } else if (e.actorUserId !== null) {
2640
2688
  this.seen.add(e.eventClientMsgId);
@@ -2654,12 +2702,13 @@ var DeleteFold = class {
2654
2702
  * the in-order path.
2655
2703
  */
2656
2704
  reevaluatePending(target, author) {
2705
+ const res = author;
2657
2706
  const actor = this.pending.get(target);
2658
2707
  if (actor !== void 0) {
2659
- if (author !== null && actor === author) {
2660
- this.tombstoned.add(target);
2708
+ if (res.kind === "author") {
2709
+ if (actor === res.userId) this.tombstoned.add(target);
2661
2710
  this.pending.delete(target);
2662
- } else if (author !== null) {
2711
+ } else if (res.kind === "purged") {
2663
2712
  this.pending.delete(target);
2664
2713
  }
2665
2714
  }
@@ -2667,7 +2716,7 @@ var DeleteFold = class {
2667
2716
  const pendingHeld = this.held;
2668
2717
  this.held = [];
2669
2718
  for (const e of pendingHeld) {
2670
- this.ingest(e, (t) => t === target ? author : null);
2719
+ this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
2671
2720
  }
2672
2721
  }
2673
2722
  heldContains(eventClientMsgId) {
@@ -2693,15 +2742,23 @@ var EditFold = class {
2693
2742
  // targets that have had ≥1 valid edit applied (write-once)
2694
2743
  editedTargets = /* @__PURE__ */ new Set();
2695
2744
  /**
2696
- * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2697
- * (null = target unknown/dangling → HOLD).
2745
+ * Ingest one edit. `authorOfTarget` resolves the target message's author via
2746
+ * {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
2747
+ * (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
2748
+ * editing a disappeared message is a no-op; mark the event seen so it never re-holds
2749
+ * and a later author "resolution" cannot resurrect it).
2698
2750
  */
2699
2751
  ingest(e, authorOfTarget) {
2700
- const author = authorOfTarget(e.targetClientMsgId);
2701
- if (author === null) {
2752
+ const res = authorOfTarget(e.targetClientMsgId);
2753
+ if (res.kind === "unknown") {
2702
2754
  this.holdIfNew(e);
2703
2755
  return;
2704
2756
  }
2757
+ if (res.kind === "purged") {
2758
+ this.seenEvents.add(e.eventClientMsgId);
2759
+ return;
2760
+ }
2761
+ const author = res.userId;
2705
2762
  if (e.editorUserId === null) {
2706
2763
  this.holdIfNew(e);
2707
2764
  return;
@@ -2720,7 +2777,8 @@ var EditFold = class {
2720
2777
  orderEpoch: e.epoch,
2721
2778
  orderSeq: e.serverSeq,
2722
2779
  lastEventId: e.eventClientMsgId,
2723
- text: e.newText
2780
+ text: e.newText,
2781
+ bodyRanges: e.bodyRanges ?? null
2724
2782
  });
2725
2783
  this.editedTargets.add(e.targetClientMsgId);
2726
2784
  }
@@ -2741,6 +2799,15 @@ var EditFold = class {
2741
2799
  isEdited(targetClientMsgId) {
2742
2800
  return this.editedTargets.has(targetClientMsgId);
2743
2801
  }
2802
+ /**
2803
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2804
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2805
+ * normalizes these against the edited text to compute the edited message's mentions
2806
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2807
+ */
2808
+ bodyRanges(targetClientMsgId) {
2809
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2810
+ }
2744
2811
  /**
2745
2812
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2746
2813
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2914,7 +2981,14 @@ function encodeEdit(args) {
2914
2981
  type: "edit",
2915
2982
  client_msg_id: args.clientMsgId,
2916
2983
  target_client_msg_id: args.targetClientMsgId,
2917
- new_text: args.newText
2984
+ new_text: args.newText,
2985
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2986
+ body_ranges: args.bodyRanges.map((r) => ({
2987
+ start: r.start,
2988
+ length: r.length,
2989
+ mentioned_user_id: r.mentionedUserId
2990
+ }))
2991
+ } : {}
2918
2992
  })
2919
2993
  );
2920
2994
  }
@@ -2936,14 +3010,53 @@ function encodeEnvelope(args) {
2936
3010
  type: "text",
2937
3011
  client_msg_id: args.clientMsgId,
2938
3012
  text: args.text,
2939
- ...args.replyTo ? { reply_to: args.replyTo } : {}
3013
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
3014
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
3015
+ body_ranges: args.bodyRanges.map((r) => ({
3016
+ start: r.start,
3017
+ length: r.length,
3018
+ mentioned_user_id: r.mentionedUserId
3019
+ }))
3020
+ } : {},
3021
+ ...args.expiry ? {
3022
+ expiry: {
3023
+ v: args.expiry.v,
3024
+ ttl_seconds: args.expiry.ttlSeconds,
3025
+ start: args.expiry.start,
3026
+ // present IFF send (drop a stray senderSendTs on a read anchor)
3027
+ ...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
3028
+ }
3029
+ } : {}
2940
3030
  };
2941
3031
  return encodeUtf8(JSON.stringify(env));
2942
3032
  }
3033
+ function encodeTimerSet(args) {
3034
+ return encodeUtf8(
3035
+ JSON.stringify({
3036
+ v: 1,
3037
+ type: "timer_set",
3038
+ client_msg_id: args.clientMsgId,
3039
+ ...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
3040
+ start: args.start
3041
+ })
3042
+ );
3043
+ }
2943
3044
  function decodeEnvelope(bytes) {
2944
3045
  const s = decodeUtf8(bytes);
2945
3046
  try {
2946
3047
  const o = JSON.parse(s);
3048
+ if (typeof o === "object" && o !== null && o.type === "timer_set") {
3049
+ return {
3050
+ type: "timer_set",
3051
+ text: null,
3052
+ clientMsgId: o.client_msg_id ?? "",
3053
+ replyTo: null,
3054
+ timer: {
3055
+ ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
3056
+ start: o.start === "read" ? "read" : "send"
3057
+ }
3058
+ };
3059
+ }
2947
3060
  if (typeof o === "object" && o !== null && o.type === "delete") {
2948
3061
  return {
2949
3062
  type: "delete",
@@ -2970,6 +3083,7 @@ function decodeEnvelope(bytes) {
2970
3083
  };
2971
3084
  }
2972
3085
  if (typeof o === "object" && o !== null && o.type === "edit") {
3086
+ const editRanges = decodeBodyRanges(o.body_ranges);
2973
3087
  return {
2974
3088
  type: "edit",
2975
3089
  text: null,
@@ -2978,15 +3092,20 @@ function decodeEnvelope(bytes) {
2978
3092
  edit: {
2979
3093
  targetClientMsgId: o.target_client_msg_id ?? "",
2980
3094
  newText: o.new_text ?? ""
2981
- }
3095
+ },
3096
+ ...editRanges ? { bodyRanges: editRanges } : {}
2982
3097
  };
2983
3098
  }
2984
3099
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
3100
+ const textRanges = decodeBodyRanges(o.body_ranges);
3101
+ const expiry = decodeExpiry(o.expiry);
2985
3102
  return {
2986
3103
  type: "text",
2987
3104
  text: o.text ?? null,
2988
3105
  clientMsgId: o.client_msg_id ?? "",
2989
- replyTo: o.reply_to ?? null
3106
+ replyTo: o.reply_to ?? null,
3107
+ ...textRanges ? { bodyRanges: textRanges } : {},
3108
+ ...expiry ? { expiry } : {}
2990
3109
  };
2991
3110
  }
2992
3111
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2998,6 +3117,27 @@ function decodeEnvelope(bytes) {
2998
3117
  }
2999
3118
  return { text: s, clientMsgId: "", replyTo: null };
3000
3119
  }
3120
+ function decodeExpiry(raw) {
3121
+ if (typeof raw !== "object" || raw === null) return void 0;
3122
+ const o = raw;
3123
+ if (typeof o.ttl_seconds !== "number") return void 0;
3124
+ const start = o.start === "read" ? "read" : "send";
3125
+ return {
3126
+ v: typeof o.v === "number" ? o.v : 1,
3127
+ ttlSeconds: o.ttl_seconds,
3128
+ start,
3129
+ // tolerant: only honor sender_send_ts on a send anchor; null otherwise.
3130
+ senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
3131
+ };
3132
+ }
3133
+ function decodeBodyRanges(raw) {
3134
+ if (!raw || raw.length === 0) return void 0;
3135
+ return raw.map((r) => ({
3136
+ start: r.start,
3137
+ length: r.length,
3138
+ mentionedUserId: r.mentioned_user_id
3139
+ }));
3140
+ }
3001
3141
  function resolveReply(ref, lookup) {
3002
3142
  const parent = lookup(ref.client_msg_id);
3003
3143
  if (parent !== null) {
@@ -3209,9 +3349,9 @@ var GroupMessaging = class {
3209
3349
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3210
3350
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3211
3351
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3212
- async sendText(group, text, replyTo) {
3352
+ async sendText(group, text, replyTo, bodyRanges, expiry) {
3213
3353
  const clientMsgId = mintClientMsgId();
3214
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3354
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
3215
3355
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3216
3356
  const body = {
3217
3357
  ciphertext_b64: toBase64(ct),
@@ -3237,7 +3377,13 @@ var GroupMessaging = class {
3237
3377
  previewBody: replyTo.preview?.body ?? null,
3238
3378
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3239
3379
  previewKind: replyTo.preview?.kind ?? "text"
3240
- } : null
3380
+ } : null,
3381
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3382
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3383
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
3384
+ // Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
3385
+ // after a cold launch (the projection derives the deadline from this row's expiry).
3386
+ ...expiry ? { expiry } : {}
3241
3387
  };
3242
3388
  try {
3243
3389
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3245,6 +3391,51 @@ var GroupMessaging = class {
3245
3391
  }
3246
3392
  return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
3247
3393
  }
3394
+ /** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
3395
+ * a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
3396
+ * application path as `sendText` (the server stays blind — it's an opaque app message,
3397
+ * NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
3398
+ * Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
3399
+ * (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
3400
+ async sendTimerSet(group, args) {
3401
+ const plaintext = encodeTimerSet({
3402
+ clientMsgId: args.clientMsgId,
3403
+ ttlSeconds: args.ttlSeconds,
3404
+ start: args.start
3405
+ });
3406
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3407
+ const body = {
3408
+ ciphertext_b64: toBase64(ct),
3409
+ client_idem_key: randomId()
3410
+ };
3411
+ const wire = await palbeRequest(
3412
+ this.rt,
3413
+ "POST",
3414
+ MessagingPaths.groupMessages(group.displayId),
3415
+ { body }
3416
+ );
3417
+ const stored = {
3418
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3419
+ direction: "outgoing",
3420
+ text: null,
3421
+ senderDeviceId: this.selfDeviceId,
3422
+ epoch: wire.epoch,
3423
+ serverSeq: wire.server_seq,
3424
+ at: Date.now(),
3425
+ clientMsgId: args.clientMsgId,
3426
+ replyTo: null,
3427
+ envelopeType: "timer_set",
3428
+ timer: { ttlSeconds: args.ttlSeconds, start: args.start }
3429
+ };
3430
+ try {
3431
+ await this.messageStore.append(group.rfcGroupId, stored);
3432
+ } catch {
3433
+ }
3434
+ return {
3435
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3436
+ clientMsgId: args.clientMsgId
3437
+ };
3438
+ }
3248
3439
  /** Send a reaction (add/remove of an emoji on a target message). Encrypts a
3249
3440
  * `type:'reaction'` envelope at the current epoch and sends through the SAME
3250
3441
  * MLS application path as `sendText` (the server stays blind — a reaction is
@@ -3305,7 +3496,8 @@ var GroupMessaging = class {
3305
3496
  const plaintext = encodeEdit({
3306
3497
  clientMsgId: args.clientMsgId,
3307
3498
  targetClientMsgId: args.targetClientMsgId,
3308
- newText: args.newText
3499
+ newText: args.newText,
3500
+ bodyRanges: args.bodyRanges
3309
3501
  });
3310
3502
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3311
3503
  const body = {
@@ -3331,7 +3523,10 @@ var GroupMessaging = class {
3331
3523
  envelopeType: "edit",
3332
3524
  edit: {
3333
3525
  targetClientMsgId: args.targetClientMsgId,
3334
- newText: args.newText
3526
+ newText: args.newText,
3527
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3528
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3529
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3335
3530
  }
3336
3531
  };
3337
3532
  try {
@@ -3453,6 +3648,41 @@ var GroupMessaging = class {
3453
3648
  }
3454
3649
  };
3455
3650
 
3651
+ // src/messaging/mention-ranges.ts
3652
+ function normalizeMentionRangesUtf16(ranges, text) {
3653
+ const n = text.length;
3654
+ function splitsSurrogatePair(index) {
3655
+ if (index <= 0 || index >= n) return false;
3656
+ const before = text.charCodeAt(index - 1);
3657
+ const at = text.charCodeAt(index);
3658
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3659
+ const atIsLow = at >= 56320 && at <= 57343;
3660
+ return beforeIsHigh && atIsLow;
3661
+ }
3662
+ const survivors = [];
3663
+ for (let idx = 0; idx < ranges.length; idx++) {
3664
+ const r = ranges[idx];
3665
+ if (r === void 0) continue;
3666
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3667
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3668
+ survivors.push({ idx, range: r });
3669
+ }
3670
+ survivors.sort((lhs, rhs) => {
3671
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3672
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3673
+ return lhs.idx - rhs.idx;
3674
+ });
3675
+ const kept = [];
3676
+ let prevEnd = Number.NEGATIVE_INFINITY;
3677
+ for (const s of survivors) {
3678
+ if (s.range.start >= prevEnd) {
3679
+ kept.push(s.range);
3680
+ prevEnd = s.range.start + s.range.length;
3681
+ }
3682
+ }
3683
+ return kept;
3684
+ }
3685
+
3456
3686
  // src/messaging/reaction-fold.ts
3457
3687
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3458
3688
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3508,6 +3738,43 @@ var ReactionFold = class {
3508
3738
  }
3509
3739
  };
3510
3740
 
3741
+ // src/messaging/timer-fold.ts
3742
+ function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
3743
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
3744
+ return aSeq <= bSeq;
3745
+ }
3746
+ var TimerFold = class {
3747
+ cell = null;
3748
+ seenEvents = /* @__PURE__ */ new Set();
3749
+ ingest(e) {
3750
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
3751
+ this.seenEvents.add(e.eventClientMsgId);
3752
+ if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
3753
+ return;
3754
+ }
3755
+ this.cell = {
3756
+ orderEpoch: e.epoch,
3757
+ orderSeq: e.serverSeq,
3758
+ ttlSeconds: e.ttlSeconds,
3759
+ start: e.start,
3760
+ actor: e.actorUserId
3761
+ };
3762
+ }
3763
+ /**
3764
+ * The active chat default, or null if no timer_set has applied.
3765
+ * `ttlSeconds === null` means DISABLED (still applied — distinct from "never
3766
+ * set"). `start` is meaningful only when ttlSeconds !== null.
3767
+ */
3768
+ active() {
3769
+ if (this.cell === null) return null;
3770
+ return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
3771
+ }
3772
+ /** The userId behind the winning timer_set (for the optional system line). null if unset. */
3773
+ lastActor() {
3774
+ return this.cell?.actor ?? null;
3775
+ }
3776
+ };
3777
+
3511
3778
  // src/messaging/chat.ts
3512
3779
  var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3513
3780
  var Chat = class {
@@ -3534,12 +3801,35 @@ var Chat = class {
3534
3801
  /** The single authoritative delete-for-everyone fold (live + own-send + history).
3535
3802
  * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3536
3803
  deleteFold = new DeleteFold();
3804
+ /** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
3805
+ * on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
3806
+ * bubble; it routes here. The active default governs a subsequent bubble that carries
3807
+ * no per-message expiry (disappearing T10). */
3808
+ timerFold = new TimerFold();
3809
+ /** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
3810
+ * persisted anchor + a re-check on every load; this just drives live eviction while
3811
+ * the tab is open. Cancelled when the message purges (disappearing T10). */
3812
+ purgeTimers = /* @__PURE__ */ new Map();
3813
+ /** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
3814
+ * hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
3815
+ * by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
3816
+ * `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
3817
+ * tombstone (disappearing T10). */
3818
+ purgedCids = /* @__PURE__ */ new Set();
3819
+ purgedLoaded = false;
3537
3820
  /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3538
3821
  * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3539
3822
  suppressed = /* @__PURE__ */ new Set();
3540
3823
  /** True once the persisted suppression set has been loaded (so the omit applies
3541
3824
  * even on the cold-launch hydrate path before a fresh deleteForMe). */
3542
3825
  suppressedLoaded = false;
3826
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3827
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3828
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3829
+ elevated = /* @__PURE__ */ new Set();
3830
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3831
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3832
+ elevatedLoaded = false;
3543
3833
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3544
3834
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3545
3835
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3551,6 +3841,14 @@ var Chat = class {
3551
3841
  wired = false;
3552
3842
  liveUnsub = null;
3553
3843
  listeners = /* @__PURE__ */ new Set();
3844
+ /**
3845
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3846
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3847
+ * via the persisted elevation set, so this never double-fires for one mention. The
3848
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3849
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3850
+ */
3851
+ onMentionElevation;
3554
3852
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3555
3853
  constructor(args) {
3556
3854
  this.backend = args.backend;
@@ -3628,7 +3926,10 @@ var Chat = class {
3628
3926
  reactions: {},
3629
3927
  replyTo: null,
3630
3928
  edited: false,
3631
- isDeleted: true
3929
+ isDeleted: true,
3930
+ mentions: [],
3931
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
3932
+ expiresAt: null
3632
3933
  });
3633
3934
  continue;
3634
3935
  }
@@ -3664,9 +3965,22 @@ var Chat = class {
3664
3965
  this.wired = true;
3665
3966
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3666
3967
  void this.loadSuppressed();
3667
- void this.hydrateHistory();
3968
+ void this.loadElevated();
3969
+ void this.loadPurged().then(() => this.hydrateHistory());
3668
3970
  void this.refreshMembers();
3669
3971
  }
3972
+ /** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
3973
+ * live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
3974
+ * (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
3975
+ async loadPurged() {
3976
+ if (this.purgedLoaded || !this._group) return;
3977
+ this.purgedLoaded = true;
3978
+ try {
3979
+ const ids = await this.backend.purgedClientMsgIds(this._group);
3980
+ for (const id of ids) this.purgedCids.add(id);
3981
+ } catch {
3982
+ }
3983
+ }
3670
3984
  /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3671
3985
  * any already-surfaced suppressed message is omitted (cold-launch parity). */
3672
3986
  async loadSuppressed() {
@@ -3685,6 +3999,17 @@ var Chat = class {
3685
3999
  } catch {
3686
4000
  }
3687
4001
  }
4002
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
4003
+ * gates the elevation DECISION, it does not change what renders. */
4004
+ async loadElevated() {
4005
+ if (this.elevatedLoaded || !this._group) return;
4006
+ this.elevatedLoaded = true;
4007
+ try {
4008
+ const keys = await this.backend.loadElevated(this._group);
4009
+ for (const k of keys) this.elevated.add(k);
4010
+ } catch {
4011
+ }
4012
+ }
3688
4013
  async hydrateHistory() {
3689
4014
  if (this.historyLoaded || !this._group) return;
3690
4015
  this.historyLoaded = true;
@@ -3712,11 +4037,16 @@ var Chat = class {
3712
4037
  if (this.seenKeys.has(key)) continue;
3713
4038
  this.seenKeys.add(key);
3714
4039
  if (m.clientMsgId && !m.isDeleted) {
3715
- this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
4040
+ this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
3716
4041
  }
3717
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
4042
+ this.messageList.push(
4043
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
4044
+ );
3718
4045
  changed = true;
3719
4046
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
4047
+ if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
4048
+ void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
4049
+ }
3720
4050
  }
3721
4051
  if (changed) {
3722
4052
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3731,6 +4061,7 @@ var Chat = class {
3731
4061
  return;
3732
4062
  }
3733
4063
  if (incoming.serverSeq <= 0) return;
4064
+ if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
3734
4065
  const key = this.internalKey(incoming.serverSeq);
3735
4066
  if (this.seenKeys.has(key)) return;
3736
4067
  this.seenKeys.add(key);
@@ -3739,6 +4070,20 @@ var Chat = class {
3739
4070
  senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
3740
4071
  }
3741
4072
  const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
4073
+ if (incoming.envelopeType === "timer_set" && incoming.timer) {
4074
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
4075
+ if (actorUserId !== null) {
4076
+ this.timerFold.ingest({
4077
+ ttlSeconds: incoming.timer.ttlSeconds,
4078
+ start: incoming.timer.start,
4079
+ actorUserId,
4080
+ epoch: incoming.epoch,
4081
+ serverSeq: incoming.serverSeq,
4082
+ eventClientMsgId: incoming.clientMsgId
4083
+ });
4084
+ }
4085
+ return;
4086
+ }
3742
4087
  if (incoming.envelopeType === "reaction" && incoming.reaction) {
3743
4088
  const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3744
4089
  if (actorUserId !== null) {
@@ -3764,7 +4109,10 @@ var Chat = class {
3764
4109
  newText: incoming.edit.newText,
3765
4110
  epoch: incoming.epoch,
3766
4111
  serverSeq: incoming.serverSeq,
3767
- eventClientMsgId: incoming.clientMsgId
4112
+ eventClientMsgId: incoming.clientMsgId,
4113
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
4114
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
4115
+ bodyRanges: incoming.bodyRanges
3768
4116
  },
3769
4117
  this.authorOfTarget
3770
4118
  );
@@ -3792,6 +4140,7 @@ var Chat = class {
3792
4140
  if (incomingReplyRef) {
3793
4141
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3794
4142
  }
4143
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3795
4144
  const msg = {
3796
4145
  id: this.publicId(incoming.serverSeq),
3797
4146
  kind: this.kindOf(incoming),
@@ -3808,8 +4157,13 @@ var Chat = class {
3808
4157
  // Default false; applyEditOverlay below folds any edit that arrived first.
3809
4158
  edited: false,
3810
4159
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3811
- isDeleted: false
4160
+ isDeleted: false,
4161
+ mentions,
4162
+ // Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
4163
+ // active AS OF arrival). null when this message is non-disappearing.
4164
+ expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
3812
4165
  };
4166
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3813
4167
  if (incomingClientMsgId && incoming.text !== null) {
3814
4168
  this.byClientMsgId.set(incomingClientMsgId, {
3815
4169
  text: incoming.text,
@@ -3819,7 +4173,10 @@ var Chat = class {
3819
4173
  if (incomingClientMsgId) {
3820
4174
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3821
4175
  this.editFold.reevaluateHeld(this.authorOfTarget);
3822
- this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
4176
+ this.deleteFold.reevaluatePending(
4177
+ incomingClientMsgId,
4178
+ this.authorOfTarget(incomingClientMsgId)
4179
+ );
3823
4180
  }
3824
4181
  this.messageList.push(this.applyEditOverlay(msg));
3825
4182
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3828,11 +4185,200 @@ var Chat = class {
3828
4185
  incoming.serverSeq
3829
4186
  );
3830
4187
  this.emit();
4188
+ void this.armPurge(
4189
+ this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
4190
+ incoming.serverSeq,
4191
+ incomingClientMsgId
4192
+ );
4193
+ }
4194
+ // ── Disappearing (TTL — T10) ──
4195
+ /** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
4196
+ * `ExpirySpec` the arm path consumes (or null when absent). */
4197
+ toExpirySpec(e) {
4198
+ return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
4199
+ }
4200
+ /** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
4201
+ * so a bubble with no per-message expiry inherits it. null when no default is active or
4202
+ * the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
4203
+ * the default has no per-message sender clock; the arrival anchor drives the deadline
4204
+ * (mirrors iOS `defaultExpiry()`). */
4205
+ defaultExpiry() {
4206
+ const active = this.timerFold.active();
4207
+ if (!active || active.ttlSeconds === null) return null;
4208
+ return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
4209
+ }
4210
+ /** The surfaced display deadline for an effective expiry (a local, monotonic-derived
4211
+ * value — the wall-clock projection of the TTL from now). null for a non-disappearing
4212
+ * message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
4213
+ * UI countdown baseline. */
4214
+ deadlineFor(expiry) {
4215
+ if (!expiry) return null;
4216
+ return new Date(Date.now() + expiry.ttlSeconds * 1e3);
4217
+ }
4218
+ /** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
4219
+ * monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
4220
+ * reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
4221
+ * computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
4222
+ * `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
4223
+ * capture), then either purges immediately or schedules an advisory `setTimeout`. A null
4224
+ * expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
4225
+ async armPurge(expiry, serverSeq, clientMsgId) {
4226
+ if (!expiry || !clientMsgId || !this._group) return;
4227
+ const group = this._group;
4228
+ const fresh = {
4229
+ mAnchorMs: MonotonicClock.nowMs(),
4230
+ wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
4231
+ bAnchorToken: MonotonicClock.bootToken()
4232
+ };
4233
+ await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
4234
+ const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
4235
+ const result = remainingSeconds({
4236
+ ttlSeconds: expiry.ttlSeconds,
4237
+ anchor: effective,
4238
+ nowMonotonicMs: MonotonicClock.nowMs(),
4239
+ nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
4240
+ nowBootToken: MonotonicClock.bootToken()
4241
+ });
4242
+ let purgeInSeconds;
4243
+ if (result.kind === "purgeNow") {
4244
+ purgeInSeconds = 0;
4245
+ } else if (expiry.start === "send" && expiry.senderSendTs !== null) {
4246
+ const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
4247
+ purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
4248
+ } else {
4249
+ purgeInSeconds = result.seconds;
4250
+ }
4251
+ const prior = this.purgeTimers.get(serverSeq);
4252
+ if (prior) clearTimeout(prior);
4253
+ this.purgeTimers.delete(serverSeq);
4254
+ if (purgeInSeconds <= 0) {
4255
+ await this.purge(serverSeq, clientMsgId);
4256
+ return;
4257
+ }
4258
+ const handle = setTimeout(() => {
4259
+ void this.purge(serverSeq, clientMsgId);
4260
+ }, purgeInSeconds * 1e3);
4261
+ this.purgeTimers.set(serverSeq, handle);
4262
+ }
4263
+ /** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
4264
+ * the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
4265
+ * remaining time (purge immediately if the deadline has already passed). The durable
4266
+ * tombstone is written by `purge` when it fires (the crash-safe commit point). */
4267
+ async armFromDeadline(deadline, serverSeq, clientMsgId) {
4268
+ if (!this._group) return;
4269
+ const remainingMs = deadline.getTime() - Date.now();
4270
+ const prior = this.purgeTimers.get(serverSeq);
4271
+ if (prior) clearTimeout(prior);
4272
+ this.purgeTimers.delete(serverSeq);
4273
+ if (remainingMs <= 0) {
4274
+ await this.purge(serverSeq, clientMsgId);
4275
+ return;
4276
+ }
4277
+ const handle = setTimeout(() => {
4278
+ void this.purge(serverSeq, clientMsgId);
4279
+ }, remainingMs);
4280
+ this.purgeTimers.set(serverSeq, handle);
4281
+ }
4282
+ /** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
4283
+ * persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
4284
+ * record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
4285
+ * edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
4286
+ * (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
4287
+ async purge(serverSeq, clientMsgId) {
4288
+ if (!this._group) return;
4289
+ const prior = this.purgeTimers.get(serverSeq);
4290
+ if (prior) clearTimeout(prior);
4291
+ this.purgeTimers.delete(serverSeq);
4292
+ await this.backend.tombstone(this._group, serverSeq, clientMsgId);
4293
+ if (clientMsgId) this.purgedCids.add(clientMsgId);
4294
+ this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
4295
+ this.seenKeys.delete(this.internalKey(serverSeq));
4296
+ this.emit();
4297
+ this.editFold.reevaluateHeld(this.authorOfTarget);
4298
+ if (clientMsgId) {
4299
+ this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
4300
+ }
4301
+ }
4302
+ /** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
4303
+ * T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
4304
+ * durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
4305
+ * disappeared message); `'author'` when its author is locally known → run the
4306
+ * author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
4307
+ * resolver. Captured as a bound arrow so it can be passed to the pure folds. */
4308
+ authorOfTarget = (targetClientMsgId) => {
4309
+ if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
4310
+ const a = this.authorByClientMsgId.get(targetClientMsgId);
4311
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
4312
+ };
4313
+ // ── Mentions (mentions T6) ──
4314
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
4315
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
4316
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
4317
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
4318
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
4319
+ resolveMentions(text, bodyRanges) {
4320
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
4321
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
4322
+ if (normalized.length === 0) return [];
4323
+ return normalized.map((r) => ({
4324
+ start: r.start,
4325
+ length: r.length,
4326
+ mentionedUserId: r.mentionedUserId,
4327
+ displayName: this.displayNameOf(r.mentionedUserId)
4328
+ }));
4329
+ }
4330
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
4331
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
4332
+ * A member rename then reflects on old messages. Returns the message unchanged when
4333
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
4334
+ resolveMentionNames(m) {
4335
+ if (!m.mentions || m.mentions.length === 0) {
4336
+ return m.mentions ? m : { ...m, mentions: [] };
4337
+ }
4338
+ let changed = false;
4339
+ const reresolved = m.mentions.map((span) => {
4340
+ const name = this.displayNameOf(span.mentionedUserId);
4341
+ if (name === span.displayName) return span;
4342
+ changed = true;
4343
+ return { ...span, displayName: name };
4344
+ });
4345
+ if (!changed) return m;
4346
+ return { ...m, mentions: reresolved };
4347
+ }
4348
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
4349
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
4350
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
4351
+ editMentions(targetClientMsgId, newText) {
4352
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
4353
+ if (!ranges) return [];
4354
+ return this.resolveMentions(newText, ranges);
4355
+ }
4356
+ /** Resolve a userId → its roster display name (null if not a known member). */
4357
+ displayNameOf(userId) {
4358
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
4359
+ }
4360
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
4361
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
4362
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
4363
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
4364
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
4365
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
4366
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
4367
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
4368
+ const me = this.backend.selfUserId;
4369
+ if (envelopeType === "edit") return;
4370
+ if (senderUserId === me) return;
4371
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
4372
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4373
+ const key = `${me}|${idPart}`;
4374
+ if (this.elevated.has(key)) return;
4375
+ this.elevated.add(key);
4376
+ if (this._group) {
4377
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
4378
+ });
4379
+ }
4380
+ this.onMentionElevation?.(message);
3831
4381
  }
3832
- /** The EditFold author-gate input: the target message's resolved author userId
3833
- * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3834
- * so it can be passed to the pure EditFold. */
3835
- authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3836
4382
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3837
4383
  * (a later own/peer edit must not overwrite the original we render against). The
3838
4384
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3889,9 +4435,10 @@ var Chat = class {
3889
4435
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3890
4436
  const text = editText ?? base;
3891
4437
  const edited = foldEdited || m.edited;
3892
- if (m.text === text && m.edited === edited) return m;
4438
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
4439
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3893
4440
  changed = true;
3894
- return { ...m, text, edited };
4441
+ return { ...m, text, edited, mentions };
3895
4442
  });
3896
4443
  if (changed) this.emit();
3897
4444
  }
@@ -3908,8 +4455,9 @@ var Chat = class {
3908
4455
  if (editText === null && !foldEdited) return m;
3909
4456
  const text = editText ?? m.text;
3910
4457
  const edited = foldEdited || m.edited;
3911
- if (m.text === text && m.edited === edited) return m;
3912
- return { ...m, text, edited };
4458
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
4459
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
4460
+ return { ...m, text, edited, mentions };
3913
4461
  }
3914
4462
  /** @internal — called by the backend's conv subscription. */
3915
4463
  applyConv(event, payload) {
@@ -3967,6 +4515,18 @@ var Chat = class {
3967
4515
  }
3968
4516
  this.editFold.reevaluateHeld(this.authorOfTarget);
3969
4517
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
4518
+ this.reresolveAllMentionNames();
4519
+ }
4520
+ /** Re-resolve roster display names across the whole transcript (called on a roster
4521
+ * change). Re-emits only if any name actually changed. */
4522
+ reresolveAllMentionNames() {
4523
+ let changed = false;
4524
+ this.messageList = this.messageList.map((m) => {
4525
+ const reresolved = this.resolveMentionNames(m);
4526
+ if (reresolved !== m) changed = true;
4527
+ return reresolved;
4528
+ });
4529
+ if (changed) this.emit();
3970
4530
  }
3971
4531
  seedMembersFromGroup(group) {
3972
4532
  const seed = [
@@ -4034,11 +4594,50 @@ var Chat = class {
4034
4594
  };
4035
4595
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4036
4596
  }
4037
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
4038
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4597
+ const bodyRanges = opts?.mentions ?? null;
4598
+ const start = opts?.expiresIn?.start ?? "send";
4599
+ const expiry = opts?.expiresIn ? {
4600
+ v: 1,
4601
+ ttlSeconds: opts.expiresIn.ttlSeconds,
4602
+ start,
4603
+ senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
4604
+ } : null;
4605
+ const { receipt, clientMsgId } = await this.backend.sendText(
4606
+ group,
4607
+ text,
4608
+ replyRef,
4609
+ bodyRanges,
4610
+ expiry
4611
+ );
4612
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
4613
+ if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
4039
4614
  return receipt;
4040
4615
  }
4041
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4616
+ /** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
4617
+ * envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
4618
+ * own-set locally so the default applies immediately to subsequent sends that carry no
4619
+ * per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
4620
+ * ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
4621
+ * Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
4622
+ async setDisappearing(opts) {
4623
+ const group = await this.materializeIfNeeded();
4624
+ const clientMsgId = mintClientMsgId();
4625
+ const start = opts.start ?? "send";
4626
+ const { receipt } = await this.backend.sendTimerSet(group, {
4627
+ clientMsgId,
4628
+ ttlSeconds: opts.ttlSeconds,
4629
+ start
4630
+ });
4631
+ this.timerFold.ingest({
4632
+ ttlSeconds: opts.ttlSeconds,
4633
+ start,
4634
+ actorUserId: this.backend.selfUserId,
4635
+ epoch: receipt.epoch,
4636
+ serverSeq: receipt.serverSeq,
4637
+ eventClientMsgId: clientMsgId
4638
+ });
4639
+ }
4640
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4042
4641
  if (receipt.serverSeq <= 0) return;
4043
4642
  const key = this.internalKey(receipt.serverSeq);
4044
4643
  if (this.seenKeys.has(key)) return;
@@ -4063,7 +4662,13 @@ var Chat = class {
4063
4662
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
4064
4663
  edited: false,
4065
4664
  // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4066
- isDeleted: false
4665
+ isDeleted: false,
4666
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4667
+ // sender never gets a wire echo of its own message — this is the only local copy).
4668
+ mentions: this.resolveMentions(text, bodyRanges),
4669
+ // Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
4670
+ // via the messageList overlay; default null here (a plain own-send has no deadline).
4671
+ expiresAt: null
4067
4672
  });
4068
4673
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4069
4674
  this.emit();
@@ -4151,15 +4756,18 @@ var Chat = class {
4151
4756
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
4152
4757
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
4153
4758
  * 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) {
4759
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4760
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4761
+ async edit(message, newText, opts) {
4156
4762
  if (!message.clientMsgId || message.kind !== "text") return;
4157
4763
  const group = await this.materializeIfNeeded();
4158
4764
  const clientMsgId = mintClientMsgId();
4765
+ const bodyRanges = opts?.mentions ?? null;
4159
4766
  const { receipt } = await this.backend.sendEdit(group, {
4160
4767
  clientMsgId,
4161
4768
  targetClientMsgId: message.clientMsgId,
4162
- newText
4769
+ newText,
4770
+ bodyRanges
4163
4771
  });
4164
4772
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4165
4773
  this.editFold.ingest(
@@ -4169,7 +4777,8 @@ var Chat = class {
4169
4777
  newText,
4170
4778
  epoch: receipt.epoch,
4171
4779
  serverSeq: receipt.serverSeq,
4172
- eventClientMsgId: clientMsgId
4780
+ eventClientMsgId: clientMsgId,
4781
+ bodyRanges
4173
4782
  },
4174
4783
  this.authorOfTarget
4175
4784
  );
@@ -4222,6 +4831,18 @@ var Chat = class {
4222
4831
  }
4223
4832
  }
4224
4833
  };
4834
+ function sameMentions(a, b) {
4835
+ if (a.length !== b.length) return false;
4836
+ for (let i = 0; i < a.length; i++) {
4837
+ const x = a[i];
4838
+ const y = b[i];
4839
+ if (!x || !y) return false;
4840
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4841
+ return false;
4842
+ }
4843
+ }
4844
+ return true;
4845
+ }
4225
4846
  function sameReactions(a, b) {
4226
4847
  const ak = Object.keys(a);
4227
4848
  const bk = Object.keys(b);
@@ -4402,6 +5023,7 @@ var MessageDeliverySource = class {
4402
5023
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4403
5024
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4404
5025
  const isDelete = decoded.type === "delete" && decoded.delete != null;
5026
+ const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
4405
5027
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4406
5028
  const stored = {
4407
5029
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4433,14 +5055,21 @@ var MessageDeliverySource = class {
4433
5055
  // Thread the edit discriminator + new text through the persisted row so an
4434
5056
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
4435
5057
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
4436
- // `'text'`/no-edit (backward-compat).
5058
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
5059
+ // along so the edited message's mentions re-resolve on cold launch (T6).
4437
5060
  ...isEdit && decoded.edit ? {
4438
5061
  envelopeType: "edit",
4439
5062
  edit: {
4440
5063
  targetClientMsgId: decoded.edit.targetClientMsgId,
4441
- newText: decoded.edit.newText
5064
+ newText: decoded.edit.newText,
5065
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4442
5066
  }
4443
5067
  } : {},
5068
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
5069
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
5070
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
5071
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
5072
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4444
5073
  // Thread the delete discriminator + target through the persisted row so a
4445
5074
  // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4446
5075
  // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
@@ -4452,7 +5081,18 @@ var MessageDeliverySource = class {
4452
5081
  targetClientMsgId: decoded.delete.targetClientMsgId,
4453
5082
  scope: decoded.delete.scope
4454
5083
  }
4455
- } : {}
5084
+ } : {},
5085
+ // Disappearing T10: thread the timer_set discriminator + payload through the
5086
+ // persisted row so the chat default re-folds on cold launch (the page-local
5087
+ // TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
5088
+ ...isTimerSet && decoded.timer ? {
5089
+ envelopeType: "timer_set",
5090
+ timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
5091
+ } : {},
5092
+ // Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
5093
+ // row so the message re-arms its purge on cold launch (the projection derives the
5094
+ // deadline from this). Only on a text bubble; omitted when absent (backward-compat).
5095
+ ...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
4456
5096
  };
4457
5097
  try {
4458
5098
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -4472,7 +5112,15 @@ var MessageDeliverySource = class {
4472
5112
  envelopeType: decoded.type ?? "text",
4473
5113
  reaction: isReaction ? decoded.reaction : null,
4474
5114
  edit: isEdit ? decoded.edit : null,
4475
- delete: isDelete ? decoded.delete : null
5115
+ delete: isDelete ? decoded.delete : null,
5116
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
5117
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
5118
+ bodyRanges: decoded.bodyRanges ?? null,
5119
+ // Disappearing T10: the decoded timer_set payload (chat default control) + a text
5120
+ // bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
5121
+ // arms a bubble's purge from the expiry (or the active default).
5122
+ timer: isTimerSet ? decoded.timer : null,
5123
+ expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
4476
5124
  });
4477
5125
  return true;
4478
5126
  }
@@ -4551,6 +5199,67 @@ function isOwnEchoOrConsumed(e) {
4551
5199
  return msg.includes("message from self") || msg.includes("key not available, invalid generation");
4552
5200
  }
4553
5201
 
5202
+ // src/messaging/disappearing.ts
5203
+ var DisappearingStore = class {
5204
+ constructor(kv) {
5205
+ this.kv = kv;
5206
+ }
5207
+ kv;
5208
+ key(rfc) {
5209
+ return `disappear:${rfc}`;
5210
+ }
5211
+ async load(rfc) {
5212
+ const raw = await this.kv.get(this.key(rfc));
5213
+ if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5214
+ try {
5215
+ const r = JSON.parse(decodeUtf8(raw));
5216
+ return {
5217
+ tombstonedSeqs: r.tombstonedSeqs ?? [],
5218
+ purgedClientMsgIds: r.purgedClientMsgIds ?? [],
5219
+ anchors: r.anchors ?? {}
5220
+ };
5221
+ } catch {
5222
+ return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5223
+ }
5224
+ }
5225
+ async save(rfc, rec) {
5226
+ await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
5227
+ }
5228
+ /** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
5229
+ async tombstonedSeqs(rfc) {
5230
+ return new Set((await this.load(rfc)).tombstonedSeqs);
5231
+ }
5232
+ /** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
5233
+ async purgedClientMsgIds(rfc) {
5234
+ return new Set((await this.load(rfc)).purgedClientMsgIds);
5235
+ }
5236
+ /**
5237
+ * Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
5238
+ * written together in ONE durable record. Idempotent (re-tombstoning the same seq /
5239
+ * id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
5240
+ * from the store excludes the seq and a redelivery is dropped, even across a crash.
5241
+ */
5242
+ async tombstone(rfc, serverSeq, clientMsgId) {
5243
+ const rec = await this.load(rfc);
5244
+ if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
5245
+ if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
5246
+ rec.purgedClientMsgIds.push(clientMsgId);
5247
+ }
5248
+ await this.save(rfc, rec);
5249
+ }
5250
+ /** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
5251
+ async anchor(rfc, clientMsgId) {
5252
+ return (await this.load(rfc)).anchors[clientMsgId] ?? null;
5253
+ }
5254
+ /** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
5255
+ async writeAnchorOnce(rfc, clientMsgId, a) {
5256
+ const rec = await this.load(rfc);
5257
+ if (rec.anchors[clientMsgId]) return;
5258
+ rec.anchors[clientMsgId] = a;
5259
+ await this.save(rfc, rec);
5260
+ }
5261
+ };
5262
+
4554
5263
  // src/messaging/history.ts
4555
5264
  var MessageStore = class {
4556
5265
  constructor(kv) {
@@ -4627,6 +5336,36 @@ var GroupCatalog = class {
4627
5336
  }
4628
5337
  };
4629
5338
 
5339
+ // src/messaging/mention-elevation.ts
5340
+ var MentionElevationStore = class {
5341
+ constructor(kv) {
5342
+ this.kv = kv;
5343
+ }
5344
+ kv;
5345
+ key(rfcGroupId) {
5346
+ return `elev:${rfcGroupId}`;
5347
+ }
5348
+ /** Load the persisted elevation keys for a chat (empty array if none). */
5349
+ async load(rfcGroupId) {
5350
+ const raw = await this.kv.get(this.key(rfcGroupId));
5351
+ if (!raw) return [];
5352
+ try {
5353
+ const parsed = JSON.parse(decodeUtf8(raw));
5354
+ return Array.isArray(parsed) ? parsed : [];
5355
+ } catch {
5356
+ return [];
5357
+ }
5358
+ }
5359
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
5360
+ async save(rfcGroupId, keys) {
5361
+ const sorted = [...new Set(keys)].sort();
5362
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
5363
+ }
5364
+ async wipe() {
5365
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
5366
+ }
5367
+ };
5368
+
4630
5369
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4631
5370
  var palbe_mls_bg_exports = {};
4632
5371
  __export(palbe_mls_bg_exports, {
@@ -6334,6 +7073,8 @@ var MessagingCoordinator = class {
6334
7073
  this.groupStore = new GroupStateStorage(this.kv);
6335
7074
  this.kpStore = new KeyPackageStorage(this.kv);
6336
7075
  this.suppressionStore = new SuppressionStore(this.kv);
7076
+ this.elevationStore = new MentionElevationStore(this.kv);
7077
+ this.disappearingStore = new DisappearingStore(this.kv);
6337
7078
  this.registry.attachChatList(
6338
7079
  (chats) => {
6339
7080
  this.chatList = chats;
@@ -6349,6 +7090,8 @@ var MessagingCoordinator = class {
6349
7090
  groupStore;
6350
7091
  kpStore;
6351
7092
  suppressionStore;
7093
+ elevationStore;
7094
+ disappearingStore;
6352
7095
  registry = new GroupRegistry();
6353
7096
  resolved = null;
6354
7097
  resolvePromise = null;
@@ -6504,9 +7247,9 @@ var MessagingCoordinator = class {
6504
7247
  });
6505
7248
  return group;
6506
7249
  }
6507
- async sendText(group, text, replyTo) {
7250
+ async sendText(group, text, replyTo, bodyRanges) {
6508
7251
  const r = await this.resolve();
6509
- return r.groups.sendText(group, text, replyTo);
7252
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
6510
7253
  }
6511
7254
  async sendReaction(group, args) {
6512
7255
  const r = await this.resolve();
@@ -6520,6 +7263,10 @@ var MessagingCoordinator = class {
6520
7263
  const r = await this.resolve();
6521
7264
  return r.groups.sendDelete(group, args);
6522
7265
  }
7266
+ async sendTimerSet(group, args) {
7267
+ const r = await this.resolve();
7268
+ return r.groups.sendTimerSet(group, args);
7269
+ }
6523
7270
  /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6524
7271
  loadSuppressed(group) {
6525
7272
  return this.suppressionStore.load(group.rfcGroupId);
@@ -6528,10 +7275,36 @@ var MessagingCoordinator = class {
6528
7275
  saveSuppressed(group, keys) {
6529
7276
  return this.suppressionStore.save(group.rfcGroupId, keys);
6530
7277
  }
7278
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
7279
+ loadElevated(group) {
7280
+ return this.elevationStore.load(group.rfcGroupId);
7281
+ }
7282
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
7283
+ saveElevated(group, keys) {
7284
+ return this.elevationStore.save(group.rfcGroupId, keys);
7285
+ }
7286
+ // ── Disappearing / TTL seam (durable-only, no wire) ──
7287
+ tombstonedSeqs(group) {
7288
+ return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7289
+ }
7290
+ purgedClientMsgIds(group) {
7291
+ return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7292
+ }
7293
+ anchor(group, clientMsgId) {
7294
+ return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
7295
+ }
7296
+ writeAnchorOnce(group, clientMsgId, a) {
7297
+ return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
7298
+ }
7299
+ tombstone(group, serverSeq, clientMsgId) {
7300
+ return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
7301
+ }
6531
7302
  async history(group, limit, before) {
6532
7303
  const r = await this.resolve();
6533
7304
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
6534
- return projectHistory(group.displayId, rows, this.selfUserId);
7305
+ const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7306
+ const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7307
+ return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
6535
7308
  }
6536
7309
  async members(group) {
6537
7310
  const r = await this.resolve();
@@ -6608,9 +7381,10 @@ var MessagingCoordinator = class {
6608
7381
  return res.devices.map((d) => d.device_id);
6609
7382
  }
6610
7383
  };
6611
- function projectHistory(displayId, rows, selfUserId, resolveActor) {
7384
+ function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
7385
+ const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
6612
7386
  const fold = new ReactionFold();
6613
- for (const s of rows) {
7387
+ for (const s of visible) {
6614
7388
  if (s.envelopeType !== "reaction" || !s.reaction) continue;
6615
7389
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6616
7390
  if (actor === null) continue;
@@ -6626,17 +7400,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6626
7400
  }
6627
7401
  const editFold = new EditFold();
6628
7402
  const deleteFold = new DeleteFold();
7403
+ const pageTimerFold = new TimerFold();
6629
7404
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6630
- for (const s of rows) {
6631
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7405
+ for (const s of visible) {
7406
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6632
7407
  continue;
6633
7408
  const cid = s.clientMsgId ?? "";
6634
7409
  if (!cid) continue;
6635
7410
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6636
7411
  if (author != null) authorByClientMsgId.set(cid, author);
6637
7412
  }
6638
- const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6639
- for (const s of rows) {
7413
+ const authorOfTarget = (cid) => {
7414
+ if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
7415
+ const a = authorByClientMsgId.get(cid);
7416
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
7417
+ };
7418
+ for (const s of visible) {
6640
7419
  if (s.envelopeType !== "edit" || !s.edit) continue;
6641
7420
  const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6642
7421
  editFold.ingest(
@@ -6646,13 +7425,16 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6646
7425
  newText: s.edit.newText,
6647
7426
  epoch: s.epoch,
6648
7427
  serverSeq: s.serverSeq,
6649
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
7428
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
7429
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
7430
+ // edit's ranges drive the edited message's mentions on cold launch.
7431
+ bodyRanges: s.edit.bodyRanges ?? null
6650
7432
  },
6651
7433
  authorOfTarget
6652
7434
  );
6653
7435
  }
6654
7436
  editFold.reevaluateHeld(authorOfTarget);
6655
- for (const s of rows) {
7437
+ for (const s of visible) {
6656
7438
  if (s.envelopeType !== "delete" || !s.delete) continue;
6657
7439
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6658
7440
  deleteFold.ingest(
@@ -6666,10 +7448,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6666
7448
  authorOfTarget
6667
7449
  );
6668
7450
  }
6669
- for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
7451
+ for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
6670
7452
  const lookup = /* @__PURE__ */ new Map();
6671
- for (const s of rows) {
6672
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7453
+ for (const s of visible) {
7454
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6673
7455
  continue;
6674
7456
  const cid = s.clientMsgId ?? "";
6675
7457
  if (cid && s.text !== null) {
@@ -6678,7 +7460,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6678
7460
  }
6679
7461
  }
6680
7462
  const out = [];
6681
- for (const s of rows) {
7463
+ for (const s of visible) {
7464
+ if (s.envelopeType === "timer_set") {
7465
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
7466
+ if (actor !== null && s.timer) {
7467
+ pageTimerFold.ingest({
7468
+ ttlSeconds: s.timer.ttlSeconds,
7469
+ start: s.timer.start,
7470
+ actorUserId: actor,
7471
+ epoch: s.epoch,
7472
+ serverSeq: s.serverSeq,
7473
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
7474
+ });
7475
+ }
7476
+ continue;
7477
+ }
6682
7478
  if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6683
7479
  continue;
6684
7480
  const clientMsgId = s.clientMsgId ?? "";
@@ -6696,10 +7492,23 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6696
7492
  replyTo: null,
6697
7493
  reactions: {},
6698
7494
  edited: false,
6699
- isDeleted: true
7495
+ isDeleted: true,
7496
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
7497
+ mentions: [],
7498
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
7499
+ expiresAt: null
6700
7500
  });
6701
7501
  continue;
6702
7502
  }
7503
+ let expiresAt = null;
7504
+ if (s.expiry) {
7505
+ expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
7506
+ } else {
7507
+ const active = pageTimerFold.active();
7508
+ if (active && active.ttlSeconds !== null) {
7509
+ expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
7510
+ }
7511
+ }
6703
7512
  let replyTo = null;
6704
7513
  if (s.replyTo) {
6705
7514
  const ref = {
@@ -6716,23 +7525,37 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6716
7525
  }
6717
7526
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6718
7527
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
7528
+ const text = editText ?? s.text;
7529
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
7530
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6719
7531
  out.push({
6720
7532
  id: `${displayId}#${s.serverSeq}`,
6721
7533
  kind: s.text != null ? "text" : "system",
6722
7534
  direction: s.direction,
6723
7535
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6724
- text: editText ?? s.text,
7536
+ text,
6725
7537
  serverSeq: s.serverSeq,
6726
7538
  sentAt: new Date(s.at),
6727
7539
  clientMsgId,
6728
7540
  replyTo,
6729
7541
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6730
7542
  edited,
6731
- isDeleted: false
7543
+ isDeleted: false,
7544
+ mentions,
7545
+ expiresAt
6732
7546
  });
6733
7547
  }
6734
7548
  return out;
6735
7549
  }
7550
+ function normalizeMentionsNullNames(raw, text) {
7551
+ if (text === null || !raw || raw.length === 0) return [];
7552
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
7553
+ start: r.start,
7554
+ length: r.length,
7555
+ mentionedUserId: r.mentionedUserId,
7556
+ displayName: null
7557
+ }));
7558
+ }
6736
7559
 
6737
7560
  // src/messaging/facade.ts
6738
7561
  var PalbeMessaging = class {
@@ -7466,7 +8289,7 @@ function defaultSessionStorage(key) {
7466
8289
  }
7467
8290
 
7468
8291
  // src/version.ts
7469
- var VERSION = "1.4.0";
8292
+ var VERSION = "1.6.0";
7470
8293
 
7471
8294
  // src/runtime.ts
7472
8295
  function buildRuntime(config) {