@palbase/web 1.3.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.
@@ -2413,6 +2413,69 @@ var PalbeFlags = class {
2413
2413
  }
2414
2414
  };
2415
2415
 
2416
+ // src/messaging/delete-fold.ts
2417
+ var DeleteFold = class {
2418
+ // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
2419
+ tombstoned = /* @__PURE__ */ new Set();
2420
+ // target → the tombstone's authenticated actor userId, awaiting the target's arrival.
2421
+ pending = /* @__PURE__ */ new Map();
2422
+ // dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
2423
+ seen = /* @__PURE__ */ new Set();
2424
+ // events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
2425
+ // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2426
+ held = [];
2427
+ /**
2428
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2429
+ * userId (null = target absent locally → defer).
2430
+ */
2431
+ ingest(e, authorOfTarget) {
2432
+ if (this.tombstoned.has(e.targetClientMsgId)) return;
2433
+ if (this.seen.has(e.eventClientMsgId)) return;
2434
+ if (this.heldContains(e.eventClientMsgId)) return;
2435
+ const author = authorOfTarget(e.targetClientMsgId);
2436
+ if (author !== null) {
2437
+ this.seen.add(e.eventClientMsgId);
2438
+ if (e.actorUserId === null || e.actorUserId !== author) return;
2439
+ this.tombstoned.add(e.targetClientMsgId);
2440
+ } else if (e.actorUserId !== null) {
2441
+ this.seen.add(e.eventClientMsgId);
2442
+ this.pending.set(e.targetClientMsgId, e.actorUserId);
2443
+ } else {
2444
+ this.held.push(e);
2445
+ }
2446
+ }
2447
+ /** True once a valid tombstone has absorbed this target. */
2448
+ isTombstoned(targetClientMsgId) {
2449
+ return this.tombstoned.has(targetClientMsgId);
2450
+ }
2451
+ /**
2452
+ * When a target message newly arrives with a resolved `author`, re-check any
2453
+ * pending tombstone for it AND re-attempt any held (unverifiable) tombstones
2454
+ * whose target is now resolvable. The deferred gate is the SAME comparison as
2455
+ * the in-order path.
2456
+ */
2457
+ reevaluatePending(target, author) {
2458
+ const actor = this.pending.get(target);
2459
+ if (actor !== void 0) {
2460
+ if (author !== null && actor === author) {
2461
+ this.tombstoned.add(target);
2462
+ this.pending.delete(target);
2463
+ } else if (author !== null) {
2464
+ this.pending.delete(target);
2465
+ }
2466
+ }
2467
+ if (this.held.length === 0) return;
2468
+ const pendingHeld = this.held;
2469
+ this.held = [];
2470
+ for (const e of pendingHeld) {
2471
+ this.ingest(e, (t) => t === target ? author : null);
2472
+ }
2473
+ }
2474
+ heldContains(eventClientMsgId) {
2475
+ return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
2476
+ }
2477
+ };
2478
+
2416
2479
  // src/messaging/edit-fold.ts
2417
2480
  function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
2418
2481
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -2458,7 +2521,8 @@ var EditFold = class {
2458
2521
  orderEpoch: e.epoch,
2459
2522
  orderSeq: e.serverSeq,
2460
2523
  lastEventId: e.eventClientMsgId,
2461
- text: e.newText
2524
+ text: e.newText,
2525
+ bodyRanges: e.bodyRanges ?? null
2462
2526
  });
2463
2527
  this.editedTargets.add(e.targetClientMsgId);
2464
2528
  }
@@ -2479,6 +2543,15 @@ var EditFold = class {
2479
2543
  isEdited(targetClientMsgId) {
2480
2544
  return this.editedTargets.has(targetClientMsgId);
2481
2545
  }
2546
+ /**
2547
+ * The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
2548
+ * or null when no valid edit applied or the winning edit carried none. The Chat
2549
+ * normalizes these against the edited text to compute the edited message's mentions
2550
+ * (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
2551
+ */
2552
+ bodyRanges(targetClientMsgId) {
2553
+ return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
2554
+ }
2482
2555
  /**
2483
2556
  * Re-run HELD edits when the roster/target newly resolves (call on member/roster
2484
2557
  * change and when a target message arrives). Clears `held` and re-ingests each
@@ -2634,6 +2707,17 @@ async function listDevices(rt, userId) {
2634
2707
  }
2635
2708
 
2636
2709
  // src/messaging/group-messaging.ts
2710
+ function encodeDelete(args) {
2711
+ return encodeUtf8(
2712
+ JSON.stringify({
2713
+ v: 1,
2714
+ type: "delete",
2715
+ client_msg_id: args.clientMsgId,
2716
+ target_client_msg_id: args.targetClientMsgId,
2717
+ scope: "everyone"
2718
+ })
2719
+ );
2720
+ }
2637
2721
  function encodeEdit(args) {
2638
2722
  return encodeUtf8(
2639
2723
  JSON.stringify({
@@ -2641,7 +2725,14 @@ function encodeEdit(args) {
2641
2725
  type: "edit",
2642
2726
  client_msg_id: args.clientMsgId,
2643
2727
  target_client_msg_id: args.targetClientMsgId,
2644
- new_text: args.newText
2728
+ new_text: args.newText,
2729
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2730
+ body_ranges: args.bodyRanges.map((r) => ({
2731
+ start: r.start,
2732
+ length: r.length,
2733
+ mentioned_user_id: r.mentionedUserId
2734
+ }))
2735
+ } : {}
2645
2736
  })
2646
2737
  );
2647
2738
  }
@@ -2663,7 +2754,14 @@ function encodeEnvelope(args) {
2663
2754
  type: "text",
2664
2755
  client_msg_id: args.clientMsgId,
2665
2756
  text: args.text,
2666
- ...args.replyTo ? { reply_to: args.replyTo } : {}
2757
+ ...args.replyTo ? { reply_to: args.replyTo } : {},
2758
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? {
2759
+ body_ranges: args.bodyRanges.map((r) => ({
2760
+ start: r.start,
2761
+ length: r.length,
2762
+ mentioned_user_id: r.mentionedUserId
2763
+ }))
2764
+ } : {}
2667
2765
  };
2668
2766
  return encodeUtf8(JSON.stringify(env));
2669
2767
  }
@@ -2671,6 +2769,18 @@ function decodeEnvelope(bytes) {
2671
2769
  const s = decodeUtf8(bytes);
2672
2770
  try {
2673
2771
  const o = JSON.parse(s);
2772
+ if (typeof o === "object" && o !== null && o.type === "delete") {
2773
+ return {
2774
+ type: "delete",
2775
+ text: null,
2776
+ clientMsgId: o.client_msg_id ?? "",
2777
+ replyTo: null,
2778
+ delete: {
2779
+ targetClientMsgId: o.target_client_msg_id ?? "",
2780
+ scope: o.scope ?? "everyone"
2781
+ }
2782
+ };
2783
+ }
2674
2784
  if (typeof o === "object" && o !== null && o.type === "reaction") {
2675
2785
  return {
2676
2786
  type: "reaction",
@@ -2685,6 +2795,7 @@ function decodeEnvelope(bytes) {
2685
2795
  };
2686
2796
  }
2687
2797
  if (typeof o === "object" && o !== null && o.type === "edit") {
2798
+ const editRanges = decodeBodyRanges(o.body_ranges);
2688
2799
  return {
2689
2800
  type: "edit",
2690
2801
  text: null,
@@ -2693,15 +2804,18 @@ function decodeEnvelope(bytes) {
2693
2804
  edit: {
2694
2805
  targetClientMsgId: o.target_client_msg_id ?? "",
2695
2806
  newText: o.new_text ?? ""
2696
- }
2807
+ },
2808
+ ...editRanges ? { bodyRanges: editRanges } : {}
2697
2809
  };
2698
2810
  }
2699
2811
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2812
+ const textRanges = decodeBodyRanges(o.body_ranges);
2700
2813
  return {
2701
2814
  type: "text",
2702
2815
  text: o.text ?? null,
2703
2816
  clientMsgId: o.client_msg_id ?? "",
2704
- replyTo: o.reply_to ?? null
2817
+ replyTo: o.reply_to ?? null,
2818
+ ...textRanges ? { bodyRanges: textRanges } : {}
2705
2819
  };
2706
2820
  }
2707
2821
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2713,6 +2827,14 @@ function decodeEnvelope(bytes) {
2713
2827
  }
2714
2828
  return { text: s, clientMsgId: "", replyTo: null };
2715
2829
  }
2830
+ function decodeBodyRanges(raw) {
2831
+ if (!raw || raw.length === 0) return void 0;
2832
+ return raw.map((r) => ({
2833
+ start: r.start,
2834
+ length: r.length,
2835
+ mentionedUserId: r.mentioned_user_id
2836
+ }));
2837
+ }
2716
2838
  function resolveReply(ref, lookup) {
2717
2839
  const parent = lookup(ref.client_msg_id);
2718
2840
  if (parent !== null) {
@@ -2924,9 +3046,9 @@ var GroupMessaging = class {
2924
3046
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
2925
3047
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
2926
3048
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
2927
- async sendText(group, text, replyTo) {
3049
+ async sendText(group, text, replyTo, bodyRanges) {
2928
3050
  const clientMsgId = mintClientMsgId();
2929
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
3051
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
2930
3052
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2931
3053
  const body = {
2932
3054
  ciphertext_b64: toBase64(ct),
@@ -2952,7 +3074,10 @@ var GroupMessaging = class {
2952
3074
  previewBody: replyTo.preview?.body ?? null,
2953
3075
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
2954
3076
  previewKind: replyTo.preview?.kind ?? "text"
2955
- } : null
3077
+ } : null,
3078
+ // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3079
+ // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3080
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
2956
3081
  };
2957
3082
  try {
2958
3083
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3020,7 +3145,8 @@ var GroupMessaging = class {
3020
3145
  const plaintext = encodeEdit({
3021
3146
  clientMsgId: args.clientMsgId,
3022
3147
  targetClientMsgId: args.targetClientMsgId,
3023
- newText: args.newText
3148
+ newText: args.newText,
3149
+ bodyRanges: args.bodyRanges
3024
3150
  });
3025
3151
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3026
3152
  const body = {
@@ -3046,7 +3172,59 @@ var GroupMessaging = class {
3046
3172
  envelopeType: "edit",
3047
3173
  edit: {
3048
3174
  targetClientMsgId: args.targetClientMsgId,
3049
- newText: args.newText
3175
+ newText: args.newText,
3176
+ // Persist the edit's REPLACEMENT ranges so the edited message's mentions
3177
+ // re-resolve from this edit after a reload (own-send reload parity — T6).
3178
+ ...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
3179
+ }
3180
+ };
3181
+ try {
3182
+ await this.messageStore.append(group.rfcGroupId, stored);
3183
+ } catch {
3184
+ }
3185
+ return {
3186
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3187
+ clientMsgId: args.clientMsgId
3188
+ };
3189
+ }
3190
+ /** Send a delete-for-everyone tombstone on a target message. Encrypts a
3191
+ * `type:'delete'` envelope at the current epoch and sends through the SAME MLS
3192
+ * application path as `sendText` (the server stays blind — a delete is just
3193
+ * another opaque application message; the original ciphertext row is NOT
3194
+ * removed). Persists the outgoing delete row so the tombstone re-folds onto its
3195
+ * target after a reload (the own-send half of the reload parity — the iOS-review
3196
+ * CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
3197
+ * rebases (epoch-bound like any application message). */
3198
+ async sendDelete(group, args) {
3199
+ const plaintext = encodeDelete({
3200
+ clientMsgId: args.clientMsgId,
3201
+ targetClientMsgId: args.targetClientMsgId
3202
+ });
3203
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3204
+ const body = {
3205
+ ciphertext_b64: toBase64(ct),
3206
+ client_idem_key: randomId()
3207
+ };
3208
+ const wire = await palbeRequest(
3209
+ this.rt,
3210
+ "POST",
3211
+ MessagingPaths.groupMessages(group.displayId),
3212
+ { body }
3213
+ );
3214
+ const stored = {
3215
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3216
+ direction: "outgoing",
3217
+ text: null,
3218
+ senderDeviceId: this.selfDeviceId,
3219
+ epoch: wire.epoch,
3220
+ serverSeq: wire.server_seq,
3221
+ at: Date.now(),
3222
+ clientMsgId: args.clientMsgId,
3223
+ replyTo: null,
3224
+ envelopeType: "delete",
3225
+ delete: {
3226
+ targetClientMsgId: args.targetClientMsgId,
3227
+ scope: "everyone"
3050
3228
  }
3051
3229
  };
3052
3230
  try {
@@ -3119,6 +3297,41 @@ var GroupMessaging = class {
3119
3297
  }
3120
3298
  };
3121
3299
 
3300
+ // src/messaging/mention-ranges.ts
3301
+ function normalizeMentionRangesUtf16(ranges, text) {
3302
+ const n = text.length;
3303
+ function splitsSurrogatePair(index) {
3304
+ if (index <= 0 || index >= n) return false;
3305
+ const before = text.charCodeAt(index - 1);
3306
+ const at = text.charCodeAt(index);
3307
+ const beforeIsHigh = before >= 55296 && before <= 56319;
3308
+ const atIsLow = at >= 56320 && at <= 57343;
3309
+ return beforeIsHigh && atIsLow;
3310
+ }
3311
+ const survivors = [];
3312
+ for (let idx = 0; idx < ranges.length; idx++) {
3313
+ const r = ranges[idx];
3314
+ if (r === void 0) continue;
3315
+ if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
3316
+ if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
3317
+ survivors.push({ idx, range: r });
3318
+ }
3319
+ survivors.sort((lhs, rhs) => {
3320
+ if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
3321
+ if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
3322
+ return lhs.idx - rhs.idx;
3323
+ });
3324
+ const kept = [];
3325
+ let prevEnd = Number.NEGATIVE_INFINITY;
3326
+ for (const s of survivors) {
3327
+ if (s.range.start >= prevEnd) {
3328
+ kept.push(s.range);
3329
+ prevEnd = s.range.start + s.range.length;
3330
+ }
3331
+ }
3332
+ return kept;
3333
+ }
3334
+
3122
3335
  // src/messaging/reaction-fold.ts
3123
3336
  function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3124
3337
  if (aEpoch !== bEpoch) return aEpoch < bEpoch;
@@ -3175,6 +3388,7 @@ var ReactionFold = class {
3175
3388
  };
3176
3389
 
3177
3390
  // src/messaging/chat.ts
3391
+ var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3178
3392
  var Chat = class {
3179
3393
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
3180
3394
  id;
@@ -3196,6 +3410,22 @@ var Chat = class {
3196
3410
  reactionFold = new ReactionFold();
3197
3411
  /** The single authoritative edit fold for this chat (live + own-send + history). */
3198
3412
  editFold = new EditFold();
3413
+ /** The single authoritative delete-for-everyone fold (live + own-send + history).
3414
+ * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3415
+ deleteFold = new DeleteFold();
3416
+ /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3417
+ * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3418
+ suppressed = /* @__PURE__ */ new Set();
3419
+ /** True once the persisted suppression set has been loaded (so the omit applies
3420
+ * even on the cold-launch hydrate path before a fresh deleteForMe). */
3421
+ suppressedLoaded = false;
3422
+ /** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
3423
+ * mention of me from another sender fires `onMentionElevation`, its key lands here
3424
+ * + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
3425
+ elevated = /* @__PURE__ */ new Set();
3426
+ /** True once the persisted elevation set has been loaded (so a re-delivered mention
3427
+ * on the cold-launch hydrate path dedups against the persisted decision). */
3428
+ elevatedLoaded = false;
3199
3429
  /** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
3200
3430
  * Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
3201
3431
  originalTextByClientMsgId = /* @__PURE__ */ new Map();
@@ -3207,6 +3437,14 @@ var Chat = class {
3207
3437
  wired = false;
3208
3438
  liveUnsub = null;
3209
3439
  listeners = /* @__PURE__ */ new Set();
3440
+ /**
3441
+ * Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
3442
+ * user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
3443
+ * via the persisted elevation set, so this never double-fires for one mention. The
3444
+ * app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
3445
+ * the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
3446
+ */
3447
+ onMentionElevation;
3210
3448
  /** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
3211
3449
  constructor(args) {
3212
3450
  this.backend = args.backend;
@@ -3244,7 +3482,7 @@ var Chat = class {
3244
3482
  return this.kind === "direct";
3245
3483
  }
3246
3484
  get messages() {
3247
- return this.messageList;
3485
+ return this.surfaced();
3248
3486
  }
3249
3487
  get members() {
3250
3488
  return this.memberCache;
@@ -3253,13 +3491,50 @@ var Chat = class {
3253
3491
  return this.typingList;
3254
3492
  }
3255
3493
  get lastMessage() {
3256
- return this.messageList.at(-1) ?? null;
3494
+ return this.surfaced().at(-1) ?? null;
3257
3495
  }
3258
3496
  get unreadCount() {
3259
- return this.messageList.filter(
3260
- (m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
3497
+ return this.surfaced().filter(
3498
+ (m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
3261
3499
  ).length;
3262
3500
  }
3501
+ /**
3502
+ * The RENDER PRECEDENCE — the single composition point (live AND history project
3503
+ * through it identically). Over the raw `messageList` (which already carries the
3504
+ * folded edit text + reactions + reply):
3505
+ * (1) in the delete-for-me suppression set → OMIT the message entirely;
3506
+ * (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
3507
+ * with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
3508
+ * (3) else the row as-is (edit overlay + reactions + reply already applied).
3509
+ * Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
3510
+ * just-folded delete / just-suppressed key takes effect without rewriting rows.
3511
+ */
3512
+ surfaced() {
3513
+ const out = [];
3514
+ for (const m of this.messageList) {
3515
+ const key = this.suppressionKey(m);
3516
+ if (this.suppressed.has(key)) continue;
3517
+ const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
3518
+ if (tombstoned) {
3519
+ out.push({
3520
+ ...m,
3521
+ text: DELETED_DESCRIPTOR,
3522
+ reactions: {},
3523
+ replyTo: null,
3524
+ edited: false,
3525
+ isDeleted: true,
3526
+ mentions: []
3527
+ });
3528
+ continue;
3529
+ }
3530
+ out.push(m);
3531
+ }
3532
+ return out;
3533
+ }
3534
+ /** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
3535
+ suppressionKey(m) {
3536
+ return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
3537
+ }
3263
3538
  get title() {
3264
3539
  if (this.titleOverride) return this.titleOverride;
3265
3540
  if (this._group?.name) return this._group.name;
@@ -3283,9 +3558,40 @@ var Chat = class {
3283
3558
  if (this.wired || this._state !== "active" || !this._group) return;
3284
3559
  this.wired = true;
3285
3560
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3561
+ void this.loadSuppressed();
3562
+ void this.loadElevated();
3286
3563
  void this.hydrateHistory();
3287
3564
  void this.refreshMembers();
3288
3565
  }
3566
+ /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3567
+ * any already-surfaced suppressed message is omitted (cold-launch parity). */
3568
+ async loadSuppressed() {
3569
+ if (this.suppressedLoaded || !this._group) return;
3570
+ this.suppressedLoaded = true;
3571
+ try {
3572
+ const keys = await this.backend.loadSuppressed(this._group);
3573
+ let changed = false;
3574
+ for (const k of keys) {
3575
+ if (!this.suppressed.has(k)) {
3576
+ this.suppressed.add(k);
3577
+ changed = true;
3578
+ }
3579
+ }
3580
+ if (changed) this.emit();
3581
+ } catch {
3582
+ }
3583
+ }
3584
+ /** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
3585
+ * gates the elevation DECISION, it does not change what renders. */
3586
+ async loadElevated() {
3587
+ if (this.elevatedLoaded || !this._group) return;
3588
+ this.elevatedLoaded = true;
3589
+ try {
3590
+ const keys = await this.backend.loadElevated(this._group);
3591
+ for (const k of keys) this.elevated.add(k);
3592
+ } catch {
3593
+ }
3594
+ }
3289
3595
  async hydrateHistory() {
3290
3596
  if (this.historyLoaded || !this._group) return;
3291
3597
  this.historyLoaded = true;
@@ -3296,13 +3602,13 @@ var Chat = class {
3296
3602
  let changed = false;
3297
3603
  for (const m of incoming) {
3298
3604
  if (m.serverSeq <= 0) continue;
3299
- if (m.clientMsgId && m.text !== null) {
3605
+ if (m.clientMsgId && m.text !== null && !m.isDeleted) {
3300
3606
  this.byClientMsgId.set(m.clientMsgId, {
3301
3607
  text: m.text,
3302
3608
  senderUserId: m.senderUserId ?? ""
3303
3609
  });
3304
3610
  }
3305
- if (m.clientMsgId) {
3611
+ if (m.clientMsgId && !m.isDeleted) {
3306
3612
  this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
3307
3613
  }
3308
3614
  }
@@ -3312,7 +3618,12 @@ var Chat = class {
3312
3618
  const key = this.internalKey(m.serverSeq);
3313
3619
  if (this.seenKeys.has(key)) continue;
3314
3620
  this.seenKeys.add(key);
3315
- this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
3621
+ if (m.clientMsgId && !m.isDeleted) {
3622
+ this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3623
+ }
3624
+ this.messageList.push(
3625
+ this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3626
+ );
3316
3627
  changed = true;
3317
3628
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3318
3629
  }
@@ -3362,19 +3673,38 @@ var Chat = class {
3362
3673
  newText: incoming.edit.newText,
3363
3674
  epoch: incoming.epoch,
3364
3675
  serverSeq: incoming.serverSeq,
3365
- eventClientMsgId: incoming.clientMsgId
3676
+ eventClientMsgId: incoming.clientMsgId,
3677
+ // Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
3678
+ // mentions reflect them (recomputed against the new text on recomputeEdit).
3679
+ bodyRanges: incoming.bodyRanges
3366
3680
  },
3367
3681
  this.authorOfTarget
3368
3682
  );
3369
3683
  this.recomputeEdit(incoming.edit.targetClientMsgId);
3370
3684
  return;
3371
3685
  }
3686
+ if (incoming.envelopeType === "delete" && incoming.delete) {
3687
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3688
+ this.deleteFold.ingest(
3689
+ {
3690
+ targetClientMsgId: incoming.delete.targetClientMsgId,
3691
+ actorUserId,
3692
+ epoch: incoming.epoch,
3693
+ serverSeq: incoming.serverSeq,
3694
+ eventClientMsgId: incoming.clientMsgId
3695
+ },
3696
+ this.authorOfTarget
3697
+ );
3698
+ this.emit();
3699
+ return;
3700
+ }
3372
3701
  const incomingClientMsgId = incoming.clientMsgId;
3373
3702
  const incomingReplyRef = incoming.replyRef;
3374
3703
  let resolvedReplyTo = null;
3375
3704
  if (incomingReplyRef) {
3376
3705
  resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
3377
3706
  }
3707
+ const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
3378
3708
  const msg = {
3379
3709
  id: this.publicId(incoming.serverSeq),
3380
3710
  kind: this.kindOf(incoming),
@@ -3389,8 +3719,12 @@ var Chat = class {
3389
3719
  // BEFORE its target — the dangling case — renders the moment the target lands).
3390
3720
  reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
3391
3721
  // Default false; applyEditOverlay below folds any edit that arrived first.
3392
- edited: false
3722
+ edited: false,
3723
+ // Default false; surfaced() applies the tombstone scrub if a delete folded.
3724
+ isDeleted: false,
3725
+ mentions
3393
3726
  };
3727
+ this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3394
3728
  if (incomingClientMsgId && incoming.text !== null) {
3395
3729
  this.byClientMsgId.set(incomingClientMsgId, {
3396
3730
  text: incoming.text,
@@ -3400,6 +3734,7 @@ var Chat = class {
3400
3734
  if (incomingClientMsgId) {
3401
3735
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3402
3736
  this.editFold.reevaluateHeld(this.authorOfTarget);
3737
+ this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3403
3738
  }
3404
3739
  this.messageList.push(this.applyEditOverlay(msg));
3405
3740
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3413,6 +3748,75 @@ var Chat = class {
3413
3748
  * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3414
3749
  * so it can be passed to the pure EditFold. */
3415
3750
  authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3751
+ // ── Mentions (mentions T6) ──
3752
+ /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3753
+ * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
3754
+ * resolve each surviving range's `mentionedUserId` to a roster display name. An id
3755
+ * not in the roster resolves to `null` (the renderer falls back to the `text` slice).
3756
+ * Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
3757
+ resolveMentions(text, bodyRanges) {
3758
+ if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
3759
+ const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
3760
+ if (normalized.length === 0) return [];
3761
+ return normalized.map((r) => ({
3762
+ start: r.start,
3763
+ length: r.length,
3764
+ mentionedUserId: r.mentionedUserId,
3765
+ displayName: this.displayNameOf(r.mentionedUserId)
3766
+ }));
3767
+ }
3768
+ /** Re-resolve the roster display name on already-NORMALIZED spans (the history
3769
+ * projection produces them with null names — resolution is LIVE, not snapshotted).
3770
+ * A member rename then reflects on old messages. Returns the message unchanged when
3771
+ * it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
3772
+ resolveMentionNames(m) {
3773
+ if (!m.mentions || m.mentions.length === 0) {
3774
+ return m.mentions ? m : { ...m, mentions: [] };
3775
+ }
3776
+ let changed = false;
3777
+ const reresolved = m.mentions.map((span) => {
3778
+ const name = this.displayNameOf(span.mentionedUserId);
3779
+ if (name === span.displayName) return span;
3780
+ changed = true;
3781
+ return { ...span, displayName: name };
3782
+ });
3783
+ if (!changed) return m;
3784
+ return { ...m, mentions: reresolved };
3785
+ }
3786
+ /** The WINNING edit's resolved mentions for a target (normalize its replacement
3787
+ * ranges against the new text + roster names), or `[]` if no winning edit / no
3788
+ * ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
3789
+ editMentions(targetClientMsgId, newText) {
3790
+ const ranges = this.editFold.bodyRanges(targetClientMsgId);
3791
+ if (!ranges) return [];
3792
+ return this.resolveMentions(newText, ranges);
3793
+ }
3794
+ /** Resolve a userId → its roster display name (null if not a known member). */
3795
+ displayNameOf(userId) {
3796
+ return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
3797
+ }
3798
+ /** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
3799
+ * when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
3800
+ * Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
3801
+ * me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
3802
+ * elevated. Dedup-once: the in-memory set gates the session, the persisted set
3803
+ * survives reload. An EDIT never reaches here (it folds, not a bubble) — the
3804
+ * `envelopeType !== 'edit'` guard is belt-and-braces. */
3805
+ elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
3806
+ const me = this.backend.selfUserId;
3807
+ if (envelopeType === "edit") return;
3808
+ if (senderUserId === me) return;
3809
+ if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
3810
+ const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
3811
+ const key = `${me}|${idPart}`;
3812
+ if (this.elevated.has(key)) return;
3813
+ this.elevated.add(key);
3814
+ if (this._group) {
3815
+ void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
3816
+ });
3817
+ }
3818
+ this.onMentionElevation?.(message);
3819
+ }
3416
3820
  /** Seed the per-target base text + author for the edit fold. Base is write-once
3417
3821
  * (a later own/peer edit must not overwrite the original we render against). The
3418
3822
  * author is (re)recorded whenever a non-empty resolution is available. */
@@ -3469,9 +3873,10 @@ var Chat = class {
3469
3873
  const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
3470
3874
  const text = editText ?? base;
3471
3875
  const edited = foldEdited || m.edited;
3472
- if (m.text === text && m.edited === edited) return m;
3876
+ const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
3877
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3473
3878
  changed = true;
3474
- return { ...m, text, edited };
3879
+ return { ...m, text, edited, mentions };
3475
3880
  });
3476
3881
  if (changed) this.emit();
3477
3882
  }
@@ -3488,8 +3893,9 @@ var Chat = class {
3488
3893
  if (editText === null && !foldEdited) return m;
3489
3894
  const text = editText ?? m.text;
3490
3895
  const edited = foldEdited || m.edited;
3491
- if (m.text === text && m.edited === edited) return m;
3492
- return { ...m, text, edited };
3896
+ const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
3897
+ if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
3898
+ return { ...m, text, edited, mentions };
3493
3899
  }
3494
3900
  /** @internal — called by the backend's conv subscription. */
3495
3901
  applyConv(event, payload) {
@@ -3547,6 +3953,18 @@ var Chat = class {
3547
3953
  }
3548
3954
  this.editFold.reevaluateHeld(this.authorOfTarget);
3549
3955
  for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
3956
+ this.reresolveAllMentionNames();
3957
+ }
3958
+ /** Re-resolve roster display names across the whole transcript (called on a roster
3959
+ * change). Re-emits only if any name actually changed. */
3960
+ reresolveAllMentionNames() {
3961
+ let changed = false;
3962
+ this.messageList = this.messageList.map((m) => {
3963
+ const reresolved = this.resolveMentionNames(m);
3964
+ if (reresolved !== m) changed = true;
3965
+ return reresolved;
3966
+ });
3967
+ if (changed) this.emit();
3550
3968
  }
3551
3969
  seedMembersFromGroup(group) {
3552
3970
  const seed = [
@@ -3614,11 +4032,12 @@ var Chat = class {
3614
4032
  };
3615
4033
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
3616
4034
  }
3617
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef);
3618
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo);
4035
+ const bodyRanges = opts?.mentions ?? null;
4036
+ const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4037
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
3619
4038
  return receipt;
3620
4039
  }
3621
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
4040
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
3622
4041
  if (receipt.serverSeq <= 0) return;
3623
4042
  const key = this.internalKey(receipt.serverSeq);
3624
4043
  if (this.seenKeys.has(key)) return;
@@ -3641,7 +4060,12 @@ var Chat = class {
3641
4060
  // the dangling-target invariant uniform across every append path).
3642
4061
  reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
3643
4062
  // Own-sent edits fold via edit() after the fact; new sends start unedited.
3644
- edited: false
4063
+ edited: false,
4064
+ // Own-sent deletes fold via deleteForEveryone() after the fact; start live.
4065
+ isDeleted: false,
4066
+ // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4067
+ // sender never gets a wire echo of its own message — this is the only local copy).
4068
+ mentions: this.resolveMentions(text, bodyRanges)
3645
4069
  });
3646
4070
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3647
4071
  this.emit();
@@ -3729,15 +4153,18 @@ var Chat = class {
3729
4153
  * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3730
4154
  * SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
3731
4155
  * reactions + reply context. Only the original author's edits count — for an own
3732
- * message self IS the author, so the author-gate passes. */
3733
- async edit(message, newText) {
4156
+ * message self IS the author, so the author-gate passes. `opts.mentions` carries the
4157
+ * edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
4158
+ async edit(message, newText, opts) {
3734
4159
  if (!message.clientMsgId || message.kind !== "text") return;
3735
4160
  const group = await this.materializeIfNeeded();
3736
4161
  const clientMsgId = mintClientMsgId();
4162
+ const bodyRanges = opts?.mentions ?? null;
3737
4163
  const { receipt } = await this.backend.sendEdit(group, {
3738
4164
  clientMsgId,
3739
4165
  targetClientMsgId: message.clientMsgId,
3740
- newText
4166
+ newText,
4167
+ bodyRanges
3741
4168
  });
3742
4169
  this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
3743
4170
  this.editFold.ingest(
@@ -3747,13 +4174,72 @@ var Chat = class {
3747
4174
  newText,
3748
4175
  epoch: receipt.epoch,
3749
4176
  serverSeq: receipt.serverSeq,
3750
- eventClientMsgId: clientMsgId
4177
+ eventClientMsgId: clientMsgId,
4178
+ bodyRanges
3751
4179
  },
3752
4180
  this.authorOfTarget
3753
4181
  );
3754
4182
  this.recomputeEdit(message.clientMsgId);
3755
4183
  }
4184
+ // ── Delete ──
4185
+ /** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
4186
+ * ORIGINAL SENDER can do this — for an own message self IS the author, so the
4187
+ * author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
4188
+ * tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
4189
+ * `type:'delete'` envelope through the SAME MLS path as a text message (the
4190
+ * server stays blind), folds the own delete locally so the target scrubs in
4191
+ * place instantly (the durable echo dedups on the SAME wire clientMsgId), and
4192
+ * re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
4193
+ async deleteForEveryone(message) {
4194
+ if (!message.clientMsgId) return;
4195
+ const group = await this.materializeIfNeeded();
4196
+ const clientMsgId = mintClientMsgId();
4197
+ const { receipt } = await this.backend.sendDelete(group, {
4198
+ clientMsgId,
4199
+ targetClientMsgId: message.clientMsgId
4200
+ });
4201
+ this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
4202
+ this.deleteFold.ingest(
4203
+ {
4204
+ targetClientMsgId: message.clientMsgId,
4205
+ actorUserId: this.backend.selfUserId,
4206
+ epoch: receipt.epoch,
4207
+ serverSeq: receipt.serverSeq,
4208
+ eventClientMsgId: clientMsgId
4209
+ },
4210
+ this.authorOfTarget
4211
+ );
4212
+ this.emit();
4213
+ }
4214
+ /** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
4215
+ * attribution, no server contact: the message is OMITTED from THIS view and the
4216
+ * suppression key persists per chat (survives reload). The key is the message's
4217
+ * clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
4218
+ async deleteForMe(message) {
4219
+ const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
4220
+ if (this.suppressed.has(key)) return;
4221
+ this.suppressed.add(key);
4222
+ this.emit();
4223
+ if (this._group) {
4224
+ try {
4225
+ await this.backend.saveSuppressed(this._group, [...this.suppressed]);
4226
+ } catch {
4227
+ }
4228
+ }
4229
+ }
3756
4230
  };
4231
+ function sameMentions(a, b) {
4232
+ if (a.length !== b.length) return false;
4233
+ for (let i = 0; i < a.length; i++) {
4234
+ const x = a[i];
4235
+ const y = b[i];
4236
+ if (!x || !y) return false;
4237
+ if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
4238
+ return false;
4239
+ }
4240
+ }
4241
+ return true;
4242
+ }
3757
4243
  function sameReactions(a, b) {
3758
4244
  const ak = Object.keys(a);
3759
4245
  const bk = Object.keys(b);
@@ -3933,6 +4419,7 @@ var MessageDeliverySource = class {
3933
4419
  const { text, clientMsgId, replyTo } = decoded;
3934
4420
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
3935
4421
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4422
+ const isDelete = decoded.type === "delete" && decoded.delete != null;
3936
4423
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3937
4424
  const stored = {
3938
4425
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3964,12 +4451,31 @@ var MessageDeliverySource = class {
3964
4451
  // Thread the edit discriminator + new text through the persisted row so an
3965
4452
  // edit folded LIVE re-folds onto its target after a reload (the reload-parity
3966
4453
  // boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
3967
- // `'text'`/no-edit (backward-compat).
4454
+ // `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
4455
+ // along so the edited message's mentions re-resolve on cold launch (T6).
3968
4456
  ...isEdit && decoded.edit ? {
3969
4457
  envelopeType: "edit",
3970
4458
  edit: {
3971
4459
  targetClientMsgId: decoded.edit.targetClientMsgId,
3972
- newText: decoded.edit.newText
4460
+ newText: decoded.edit.newText,
4461
+ ...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
4462
+ }
4463
+ } : {},
4464
+ // Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
4465
+ // mention surfaced LIVE re-resolves onto its bubble after a reload (the
4466
+ // reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
4467
+ // bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
4468
+ ...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
4469
+ // Thread the delete discriminator + target through the persisted row so a
4470
+ // delete-for-everyone tombstone folded LIVE re-folds onto its target after
4471
+ // a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
4472
+ // the projection's `.delete` branch re-folds it so it never leaks a blank
4473
+ // bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
4474
+ ...isDelete && decoded.delete ? {
4475
+ envelopeType: "delete",
4476
+ delete: {
4477
+ targetClientMsgId: decoded.delete.targetClientMsgId,
4478
+ scope: decoded.delete.scope
3973
4479
  }
3974
4480
  } : {}
3975
4481
  };
@@ -3990,7 +4496,11 @@ var MessageDeliverySource = class {
3990
4496
  replyRef: replyTo,
3991
4497
  envelopeType: decoded.type ?? "text",
3992
4498
  reaction: isReaction ? decoded.reaction : null,
3993
- edit: isEdit ? decoded.edit : null
4499
+ edit: isEdit ? decoded.edit : null,
4500
+ delete: isDelete ? decoded.delete : null,
4501
+ // The raw mention ranges (text bubble or the edit's replacement ranges); the
4502
+ // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4503
+ bodyRanges: decoded.bodyRanges ?? null
3994
4504
  });
3995
4505
  return true;
3996
4506
  }
@@ -4145,6 +4655,36 @@ var GroupCatalog = class {
4145
4655
  }
4146
4656
  };
4147
4657
 
4658
+ // src/messaging/mention-elevation.ts
4659
+ var MentionElevationStore = class {
4660
+ constructor(kv) {
4661
+ this.kv = kv;
4662
+ }
4663
+ kv;
4664
+ key(rfcGroupId) {
4665
+ return `elev:${rfcGroupId}`;
4666
+ }
4667
+ /** Load the persisted elevation keys for a chat (empty array if none). */
4668
+ async load(rfcGroupId) {
4669
+ const raw = await this.kv.get(this.key(rfcGroupId));
4670
+ if (!raw) return [];
4671
+ try {
4672
+ const parsed = JSON.parse(decodeUtf8(raw));
4673
+ return Array.isArray(parsed) ? parsed : [];
4674
+ } catch {
4675
+ return [];
4676
+ }
4677
+ }
4678
+ /** Persist the full elevation key set for a chat (deterministic, deduped order). */
4679
+ async save(rfcGroupId, keys) {
4680
+ const sorted = [...new Set(keys)].sort();
4681
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
4682
+ }
4683
+ async wipe() {
4684
+ for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
4685
+ }
4686
+ };
4687
+
4148
4688
  // src/messaging/wasm/pkg/palbe_mls_bg.js
4149
4689
  var palbe_mls_bg_exports = {};
4150
4690
  __export(palbe_mls_bg_exports, {
@@ -5812,6 +6352,36 @@ var SignatureKeyStore = class {
5812
6352
  }
5813
6353
  };
5814
6354
 
6355
+ // src/messaging/suppression.ts
6356
+ var SuppressionStore = class {
6357
+ constructor(kv) {
6358
+ this.kv = kv;
6359
+ }
6360
+ kv;
6361
+ key(rfcGroupId) {
6362
+ return `supp:${rfcGroupId}`;
6363
+ }
6364
+ /** Load the persisted suppression keys for a chat (empty array if none). */
6365
+ async load(rfcGroupId) {
6366
+ const raw = await this.kv.get(this.key(rfcGroupId));
6367
+ if (!raw) return [];
6368
+ try {
6369
+ const parsed = JSON.parse(decodeUtf8(raw));
6370
+ return Array.isArray(parsed) ? parsed : [];
6371
+ } catch {
6372
+ return [];
6373
+ }
6374
+ }
6375
+ /** Persist the full suppression key set for a chat (deterministic order). */
6376
+ async save(rfcGroupId, keys) {
6377
+ const sorted = [...new Set(keys)].sort();
6378
+ await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
6379
+ }
6380
+ async wipe() {
6381
+ for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
6382
+ }
6383
+ };
6384
+
5815
6385
  // src/messaging/coordinator.ts
5816
6386
  var MessagingCoordinator = class {
5817
6387
  constructor(rt) {
@@ -5821,6 +6391,8 @@ var MessagingCoordinator = class {
5821
6391
  this.sigStore = new SignatureKeyStore(this.kv);
5822
6392
  this.groupStore = new GroupStateStorage(this.kv);
5823
6393
  this.kpStore = new KeyPackageStorage(this.kv);
6394
+ this.suppressionStore = new SuppressionStore(this.kv);
6395
+ this.elevationStore = new MentionElevationStore(this.kv);
5824
6396
  this.registry.attachChatList(
5825
6397
  (chats) => {
5826
6398
  this.chatList = chats;
@@ -5835,6 +6407,8 @@ var MessagingCoordinator = class {
5835
6407
  sigStore;
5836
6408
  groupStore;
5837
6409
  kpStore;
6410
+ suppressionStore;
6411
+ elevationStore;
5838
6412
  registry = new GroupRegistry();
5839
6413
  resolved = null;
5840
6414
  resolvePromise = null;
@@ -5990,9 +6564,9 @@ var MessagingCoordinator = class {
5990
6564
  });
5991
6565
  return group;
5992
6566
  }
5993
- async sendText(group, text, replyTo) {
6567
+ async sendText(group, text, replyTo, bodyRanges) {
5994
6568
  const r = await this.resolve();
5995
- return r.groups.sendText(group, text, replyTo);
6569
+ return r.groups.sendText(group, text, replyTo, bodyRanges);
5996
6570
  }
5997
6571
  async sendReaction(group, args) {
5998
6572
  const r = await this.resolve();
@@ -6002,6 +6576,26 @@ var MessagingCoordinator = class {
6002
6576
  const r = await this.resolve();
6003
6577
  return r.groups.sendEdit(group, args);
6004
6578
  }
6579
+ async sendDelete(group, args) {
6580
+ const r = await this.resolve();
6581
+ return r.groups.sendDelete(group, args);
6582
+ }
6583
+ /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6584
+ loadSuppressed(group) {
6585
+ return this.suppressionStore.load(group.rfcGroupId);
6586
+ }
6587
+ /** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
6588
+ saveSuppressed(group, keys) {
6589
+ return this.suppressionStore.save(group.rfcGroupId, keys);
6590
+ }
6591
+ /** Load this chat's persisted self-elevation dedup keys (durable-only). */
6592
+ loadElevated(group) {
6593
+ return this.elevationStore.load(group.rfcGroupId);
6594
+ }
6595
+ /** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
6596
+ saveElevated(group, keys) {
6597
+ return this.elevationStore.save(group.rfcGroupId, keys);
6598
+ }
6005
6599
  async history(group, limit, before) {
6006
6600
  const r = await this.resolve();
6007
6601
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
@@ -6099,9 +6693,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6099
6693
  });
6100
6694
  }
6101
6695
  const editFold = new EditFold();
6696
+ const deleteFold = new DeleteFold();
6102
6697
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6103
6698
  for (const s of rows) {
6104
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6699
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6700
+ continue;
6105
6701
  const cid = s.clientMsgId ?? "";
6106
6702
  if (!cid) continue;
6107
6703
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
@@ -6118,15 +6714,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6118
6714
  newText: s.edit.newText,
6119
6715
  epoch: s.epoch,
6120
6716
  serverSeq: s.serverSeq,
6121
- eventClientMsgId: s.clientMsgId ?? `${s.id}`
6717
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`,
6718
+ // Mentions T6: the edit's replacement ranges ride the fold so the WINNING
6719
+ // edit's ranges drive the edited message's mentions on cold launch.
6720
+ bodyRanges: s.edit.bodyRanges ?? null
6122
6721
  },
6123
6722
  authorOfTarget
6124
6723
  );
6125
6724
  }
6126
6725
  editFold.reevaluateHeld(authorOfTarget);
6726
+ for (const s of rows) {
6727
+ if (s.envelopeType !== "delete" || !s.delete) continue;
6728
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6729
+ deleteFold.ingest(
6730
+ {
6731
+ targetClientMsgId: s.delete.targetClientMsgId,
6732
+ actorUserId: actor,
6733
+ epoch: s.epoch,
6734
+ serverSeq: s.serverSeq,
6735
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6736
+ },
6737
+ authorOfTarget
6738
+ );
6739
+ }
6740
+ for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
6127
6741
  const lookup = /* @__PURE__ */ new Map();
6128
6742
  for (const s of rows) {
6129
- if (s.envelopeType === "reaction") continue;
6743
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6744
+ continue;
6130
6745
  const cid = s.clientMsgId ?? "";
6131
6746
  if (cid && s.text !== null) {
6132
6747
  const senderUserId = s.direction === "outgoing" ? selfUserId : "";
@@ -6135,8 +6750,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6135
6750
  }
6136
6751
  const out = [];
6137
6752
  for (const s of rows) {
6138
- if (s.envelopeType === "reaction" || s.envelopeType === "edit") continue;
6753
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6754
+ continue;
6139
6755
  const clientMsgId = s.clientMsgId ?? "";
6756
+ const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
6757
+ if (isDeleted) {
6758
+ out.push({
6759
+ id: `${displayId}#${s.serverSeq}`,
6760
+ kind: "text",
6761
+ direction: s.direction,
6762
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6763
+ text: DELETED_DESCRIPTOR,
6764
+ serverSeq: s.serverSeq,
6765
+ sentAt: new Date(s.at),
6766
+ clientMsgId,
6767
+ replyTo: null,
6768
+ reactions: {},
6769
+ edited: false,
6770
+ isDeleted: true,
6771
+ // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6772
+ mentions: []
6773
+ });
6774
+ continue;
6775
+ }
6140
6776
  let replyTo = null;
6141
6777
  if (s.replyTo) {
6142
6778
  const ref = {
@@ -6153,22 +6789,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6153
6789
  }
6154
6790
  const editText = clientMsgId ? editFold.text(clientMsgId) : null;
6155
6791
  const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
6792
+ const text = editText ?? s.text;
6793
+ const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
6794
+ const mentions = normalizeMentionsNullNames(rawRanges, text);
6156
6795
  out.push({
6157
6796
  id: `${displayId}#${s.serverSeq}`,
6158
6797
  kind: s.text != null ? "text" : "system",
6159
6798
  direction: s.direction,
6160
6799
  senderUserId: s.direction === "outgoing" ? selfUserId : null,
6161
- text: editText ?? s.text,
6800
+ text,
6162
6801
  serverSeq: s.serverSeq,
6163
6802
  sentAt: new Date(s.at),
6164
6803
  clientMsgId,
6165
6804
  replyTo,
6166
6805
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6167
- edited
6806
+ edited,
6807
+ isDeleted: false,
6808
+ mentions
6168
6809
  });
6169
6810
  }
6170
6811
  return out;
6171
6812
  }
6813
+ function normalizeMentionsNullNames(raw, text) {
6814
+ if (text === null || !raw || raw.length === 0) return [];
6815
+ return normalizeMentionRangesUtf16(raw, text).map((r) => ({
6816
+ start: r.start,
6817
+ length: r.length,
6818
+ mentionedUserId: r.mentionedUserId,
6819
+ displayName: null
6820
+ }));
6821
+ }
6172
6822
 
6173
6823
  // src/messaging/facade.ts
6174
6824
  var PalbeMessaging = class {
@@ -6902,7 +7552,7 @@ function defaultSessionStorage(key) {
6902
7552
  }
6903
7553
 
6904
7554
  // src/version.ts
6905
- var VERSION = "1.3.0";
7555
+ var VERSION = "1.5.0";
6906
7556
 
6907
7557
  // src/runtime.ts
6908
7558
  function buildRuntime(config) {