@palbase/web 1.5.0 → 1.6.1

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,47 @@ var PalbeFlags = class {
2413
2413
  }
2414
2414
  };
2415
2415
 
2416
+ // src/messaging/deadline-calculator.ts
2417
+ function remainingSeconds(args) {
2418
+ const ttl = args.ttlSeconds;
2419
+ const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
2420
+ let elapsed;
2421
+ if (args.nowBootToken === args.anchor.bAnchorToken) {
2422
+ const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
2423
+ elapsed = Math.max(monoDeltaSec, wallDeltaSec);
2424
+ } else {
2425
+ elapsed = wallDeltaSec;
2426
+ }
2427
+ const remaining = Math.min(ttl, ttl - elapsed);
2428
+ return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
2429
+ }
2430
+ var cachedBootToken = null;
2431
+ var MonotonicClock = {
2432
+ nowMs() {
2433
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
2434
+ },
2435
+ nowWallEpochMs() {
2436
+ return Date.now();
2437
+ },
2438
+ bootToken() {
2439
+ if (cachedBootToken !== null) return cachedBootToken;
2440
+ try {
2441
+ const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
2442
+ if (existing) {
2443
+ cachedBootToken = existing;
2444
+ return existing;
2445
+ }
2446
+ const fresh = crypto.randomUUID();
2447
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
2448
+ cachedBootToken = fresh;
2449
+ return fresh;
2450
+ } catch {
2451
+ cachedBootToken = crypto.randomUUID();
2452
+ return cachedBootToken;
2453
+ }
2454
+ }
2455
+ };
2456
+
2416
2457
  // src/messaging/delete-fold.ts
2417
2458
  var DeleteFold = class {
2418
2459
  // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
@@ -2425,17 +2466,24 @@ var DeleteFold = class {
2425
2466
  // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2426
2467
  held = [];
2427
2468
  /**
2428
- * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2429
- * userId (null = target absent locally → defer).
2469
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's author via
2470
+ * {@link AuthorResolution}: `'author'` run the author-gate; `'unknown'` → defer
2471
+ * (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
2472
+ * was TTL-purged — a delete of an already-gone message is already satisfied; never
2473
+ * park in pending, never re-attempt).
2430
2474
  */
2431
2475
  ingest(e, authorOfTarget) {
2432
2476
  if (this.tombstoned.has(e.targetClientMsgId)) return;
2433
2477
  if (this.seen.has(e.eventClientMsgId)) return;
2434
2478
  if (this.heldContains(e.eventClientMsgId)) return;
2435
- const author = authorOfTarget(e.targetClientMsgId);
2436
- if (author !== null) {
2479
+ const res = authorOfTarget(e.targetClientMsgId);
2480
+ if (res.kind === "purged") {
2481
+ this.seen.add(e.eventClientMsgId);
2482
+ return;
2483
+ }
2484
+ if (res.kind === "author") {
2437
2485
  this.seen.add(e.eventClientMsgId);
2438
- if (e.actorUserId === null || e.actorUserId !== author) return;
2486
+ if (e.actorUserId === null || e.actorUserId !== res.userId) return;
2439
2487
  this.tombstoned.add(e.targetClientMsgId);
2440
2488
  } else if (e.actorUserId !== null) {
2441
2489
  this.seen.add(e.eventClientMsgId);
@@ -2455,12 +2503,13 @@ var DeleteFold = class {
2455
2503
  * the in-order path.
2456
2504
  */
2457
2505
  reevaluatePending(target, author) {
2506
+ const res = author;
2458
2507
  const actor = this.pending.get(target);
2459
2508
  if (actor !== void 0) {
2460
- if (author !== null && actor === author) {
2461
- this.tombstoned.add(target);
2509
+ if (res.kind === "author") {
2510
+ if (actor === res.userId) this.tombstoned.add(target);
2462
2511
  this.pending.delete(target);
2463
- } else if (author !== null) {
2512
+ } else if (res.kind === "purged") {
2464
2513
  this.pending.delete(target);
2465
2514
  }
2466
2515
  }
@@ -2468,7 +2517,7 @@ var DeleteFold = class {
2468
2517
  const pendingHeld = this.held;
2469
2518
  this.held = [];
2470
2519
  for (const e of pendingHeld) {
2471
- this.ingest(e, (t) => t === target ? author : null);
2520
+ this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
2472
2521
  }
2473
2522
  }
2474
2523
  heldContains(eventClientMsgId) {
@@ -2494,15 +2543,23 @@ var EditFold = class {
2494
2543
  // targets that have had ≥1 valid edit applied (write-once)
2495
2544
  editedTargets = /* @__PURE__ */ new Set();
2496
2545
  /**
2497
- * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2498
- * (null = target unknown/dangling → HOLD).
2546
+ * Ingest one edit. `authorOfTarget` resolves the target message's author via
2547
+ * {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
2548
+ * (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
2549
+ * editing a disappeared message is a no-op; mark the event seen so it never re-holds
2550
+ * and a later author "resolution" cannot resurrect it).
2499
2551
  */
2500
2552
  ingest(e, authorOfTarget) {
2501
- const author = authorOfTarget(e.targetClientMsgId);
2502
- if (author === null) {
2553
+ const res = authorOfTarget(e.targetClientMsgId);
2554
+ if (res.kind === "unknown") {
2503
2555
  this.holdIfNew(e);
2504
2556
  return;
2505
2557
  }
2558
+ if (res.kind === "purged") {
2559
+ this.seenEvents.add(e.eventClientMsgId);
2560
+ return;
2561
+ }
2562
+ const author = res.userId;
2506
2563
  if (e.editorUserId === null) {
2507
2564
  this.holdIfNew(e);
2508
2565
  return;
@@ -2761,14 +2818,46 @@ function encodeEnvelope(args) {
2761
2818
  length: r.length,
2762
2819
  mentioned_user_id: r.mentionedUserId
2763
2820
  }))
2821
+ } : {},
2822
+ ...args.expiry ? {
2823
+ expiry: {
2824
+ v: args.expiry.v,
2825
+ ttl_seconds: args.expiry.ttlSeconds,
2826
+ start: args.expiry.start,
2827
+ // present IFF send (drop a stray senderSendTs on a read anchor)
2828
+ ...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
2829
+ }
2764
2830
  } : {}
2765
2831
  };
2766
2832
  return encodeUtf8(JSON.stringify(env));
2767
2833
  }
2834
+ function encodeTimerSet(args) {
2835
+ return encodeUtf8(
2836
+ JSON.stringify({
2837
+ v: 1,
2838
+ type: "timer_set",
2839
+ client_msg_id: args.clientMsgId,
2840
+ ...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
2841
+ start: args.start
2842
+ })
2843
+ );
2844
+ }
2768
2845
  function decodeEnvelope(bytes) {
2769
2846
  const s = decodeUtf8(bytes);
2770
2847
  try {
2771
2848
  const o = JSON.parse(s);
2849
+ if (typeof o === "object" && o !== null && o.type === "timer_set") {
2850
+ return {
2851
+ type: "timer_set",
2852
+ text: null,
2853
+ clientMsgId: o.client_msg_id ?? "",
2854
+ replyTo: null,
2855
+ timer: {
2856
+ ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
2857
+ start: o.start === "read" ? "read" : "send"
2858
+ }
2859
+ };
2860
+ }
2772
2861
  if (typeof o === "object" && o !== null && o.type === "delete") {
2773
2862
  return {
2774
2863
  type: "delete",
@@ -2810,12 +2899,14 @@ function decodeEnvelope(bytes) {
2810
2899
  }
2811
2900
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2812
2901
  const textRanges = decodeBodyRanges(o.body_ranges);
2902
+ const expiry = decodeExpiry(o.expiry);
2813
2903
  return {
2814
2904
  type: "text",
2815
2905
  text: o.text ?? null,
2816
2906
  clientMsgId: o.client_msg_id ?? "",
2817
2907
  replyTo: o.reply_to ?? null,
2818
- ...textRanges ? { bodyRanges: textRanges } : {}
2908
+ ...textRanges ? { bodyRanges: textRanges } : {},
2909
+ ...expiry ? { expiry } : {}
2819
2910
  };
2820
2911
  }
2821
2912
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2827,6 +2918,19 @@ function decodeEnvelope(bytes) {
2827
2918
  }
2828
2919
  return { text: s, clientMsgId: "", replyTo: null };
2829
2920
  }
2921
+ function decodeExpiry(raw) {
2922
+ if (typeof raw !== "object" || raw === null) return void 0;
2923
+ const o = raw;
2924
+ if (typeof o.ttl_seconds !== "number") return void 0;
2925
+ const start = o.start === "read" ? "read" : "send";
2926
+ return {
2927
+ v: typeof o.v === "number" ? o.v : 1,
2928
+ ttlSeconds: o.ttl_seconds,
2929
+ start,
2930
+ // tolerant: only honor sender_send_ts on a send anchor; null otherwise.
2931
+ senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
2932
+ };
2933
+ }
2830
2934
  function decodeBodyRanges(raw) {
2831
2935
  if (!raw || raw.length === 0) return void 0;
2832
2936
  return raw.map((r) => ({
@@ -3046,9 +3150,9 @@ var GroupMessaging = class {
3046
3150
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3047
3151
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3048
3152
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3049
- async sendText(group, text, replyTo, bodyRanges) {
3153
+ async sendText(group, text, replyTo, bodyRanges, expiry) {
3050
3154
  const clientMsgId = mintClientMsgId();
3051
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3155
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
3052
3156
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3053
3157
  const body = {
3054
3158
  ciphertext_b64: toBase64(ct),
@@ -3077,7 +3181,10 @@ var GroupMessaging = class {
3077
3181
  } : null,
3078
3182
  // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3079
3183
  // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3080
- ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3184
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
3185
+ // Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
3186
+ // after a cold launch (the projection derives the deadline from this row's expiry).
3187
+ ...expiry ? { expiry } : {}
3081
3188
  };
3082
3189
  try {
3083
3190
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3085,6 +3192,51 @@ var GroupMessaging = class {
3085
3192
  }
3086
3193
  return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
3087
3194
  }
3195
+ /** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
3196
+ * a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
3197
+ * application path as `sendText` (the server stays blind — it's an opaque app message,
3198
+ * NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
3199
+ * Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
3200
+ * (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
3201
+ async sendTimerSet(group, args) {
3202
+ const plaintext = encodeTimerSet({
3203
+ clientMsgId: args.clientMsgId,
3204
+ ttlSeconds: args.ttlSeconds,
3205
+ start: args.start
3206
+ });
3207
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3208
+ const body = {
3209
+ ciphertext_b64: toBase64(ct),
3210
+ client_idem_key: randomId()
3211
+ };
3212
+ const wire = await palbeRequest(
3213
+ this.rt,
3214
+ "POST",
3215
+ MessagingPaths.groupMessages(group.displayId),
3216
+ { body }
3217
+ );
3218
+ const stored = {
3219
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3220
+ direction: "outgoing",
3221
+ text: null,
3222
+ senderDeviceId: this.selfDeviceId,
3223
+ epoch: wire.epoch,
3224
+ serverSeq: wire.server_seq,
3225
+ at: Date.now(),
3226
+ clientMsgId: args.clientMsgId,
3227
+ replyTo: null,
3228
+ envelopeType: "timer_set",
3229
+ timer: { ttlSeconds: args.ttlSeconds, start: args.start }
3230
+ };
3231
+ try {
3232
+ await this.messageStore.append(group.rfcGroupId, stored);
3233
+ } catch {
3234
+ }
3235
+ return {
3236
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3237
+ clientMsgId: args.clientMsgId
3238
+ };
3239
+ }
3088
3240
  /** Send a reaction (add/remove of an emoji on a target message). Encrypts a
3089
3241
  * `type:'reaction'` envelope at the current epoch and sends through the SAME
3090
3242
  * MLS application path as `sendText` (the server stays blind — a reaction is
@@ -3387,6 +3539,43 @@ var ReactionFold = class {
3387
3539
  }
3388
3540
  };
3389
3541
 
3542
+ // src/messaging/timer-fold.ts
3543
+ function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
3544
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
3545
+ return aSeq <= bSeq;
3546
+ }
3547
+ var TimerFold = class {
3548
+ cell = null;
3549
+ seenEvents = /* @__PURE__ */ new Set();
3550
+ ingest(e) {
3551
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
3552
+ this.seenEvents.add(e.eventClientMsgId);
3553
+ if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
3554
+ return;
3555
+ }
3556
+ this.cell = {
3557
+ orderEpoch: e.epoch,
3558
+ orderSeq: e.serverSeq,
3559
+ ttlSeconds: e.ttlSeconds,
3560
+ start: e.start,
3561
+ actor: e.actorUserId
3562
+ };
3563
+ }
3564
+ /**
3565
+ * The active chat default, or null if no timer_set has applied.
3566
+ * `ttlSeconds === null` means DISABLED (still applied — distinct from "never
3567
+ * set"). `start` is meaningful only when ttlSeconds !== null.
3568
+ */
3569
+ active() {
3570
+ if (this.cell === null) return null;
3571
+ return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
3572
+ }
3573
+ /** The userId behind the winning timer_set (for the optional system line). null if unset. */
3574
+ lastActor() {
3575
+ return this.cell?.actor ?? null;
3576
+ }
3577
+ };
3578
+
3390
3579
  // src/messaging/chat.ts
3391
3580
  var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3392
3581
  var Chat = class {
@@ -3413,6 +3602,22 @@ var Chat = class {
3413
3602
  /** The single authoritative delete-for-everyone fold (live + own-send + history).
3414
3603
  * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3415
3604
  deleteFold = new DeleteFold();
3605
+ /** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
3606
+ * on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
3607
+ * bubble; it routes here. The active default governs a subsequent bubble that carries
3608
+ * no per-message expiry (disappearing T10). */
3609
+ timerFold = new TimerFold();
3610
+ /** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
3611
+ * persisted anchor + a re-check on every load; this just drives live eviction while
3612
+ * the tab is open. Cancelled when the message purges (disappearing T10). */
3613
+ purgeTimers = /* @__PURE__ */ new Map();
3614
+ /** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
3615
+ * hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
3616
+ * by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
3617
+ * `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
3618
+ * tombstone (disappearing T10). */
3619
+ purgedCids = /* @__PURE__ */ new Set();
3620
+ purgedLoaded = false;
3416
3621
  /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3417
3622
  * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3418
3623
  suppressed = /* @__PURE__ */ new Set();
@@ -3523,7 +3728,9 @@ var Chat = class {
3523
3728
  replyTo: null,
3524
3729
  edited: false,
3525
3730
  isDeleted: true,
3526
- mentions: []
3731
+ mentions: [],
3732
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
3733
+ expiresAt: null
3527
3734
  });
3528
3735
  continue;
3529
3736
  }
@@ -3560,9 +3767,21 @@ var Chat = class {
3560
3767
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3561
3768
  void this.loadSuppressed();
3562
3769
  void this.loadElevated();
3563
- void this.hydrateHistory();
3770
+ void this.loadPurged().then(() => this.hydrateHistory());
3564
3771
  void this.refreshMembers();
3565
3772
  }
3773
+ /** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
3774
+ * live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
3775
+ * (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
3776
+ async loadPurged() {
3777
+ if (this.purgedLoaded || !this._group) return;
3778
+ this.purgedLoaded = true;
3779
+ try {
3780
+ const ids = await this.backend.purgedClientMsgIds(this._group);
3781
+ for (const id of ids) this.purgedCids.add(id);
3782
+ } catch {
3783
+ }
3784
+ }
3566
3785
  /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3567
3786
  * any already-surfaced suppressed message is omitted (cold-launch parity). */
3568
3787
  async loadSuppressed() {
@@ -3619,13 +3838,16 @@ var Chat = class {
3619
3838
  if (this.seenKeys.has(key)) continue;
3620
3839
  this.seenKeys.add(key);
3621
3840
  if (m.clientMsgId && !m.isDeleted) {
3622
- this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3841
+ this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
3623
3842
  }
3624
3843
  this.messageList.push(
3625
3844
  this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3626
3845
  );
3627
3846
  changed = true;
3628
3847
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3848
+ if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
3849
+ void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
3850
+ }
3629
3851
  }
3630
3852
  if (changed) {
3631
3853
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3640,6 +3862,7 @@ var Chat = class {
3640
3862
  return;
3641
3863
  }
3642
3864
  if (incoming.serverSeq <= 0) return;
3865
+ if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
3643
3866
  const key = this.internalKey(incoming.serverSeq);
3644
3867
  if (this.seenKeys.has(key)) return;
3645
3868
  this.seenKeys.add(key);
@@ -3648,6 +3871,20 @@ var Chat = class {
3648
3871
  senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
3649
3872
  }
3650
3873
  const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
3874
+ if (incoming.envelopeType === "timer_set" && incoming.timer) {
3875
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3876
+ if (actorUserId !== null) {
3877
+ this.timerFold.ingest({
3878
+ ttlSeconds: incoming.timer.ttlSeconds,
3879
+ start: incoming.timer.start,
3880
+ actorUserId,
3881
+ epoch: incoming.epoch,
3882
+ serverSeq: incoming.serverSeq,
3883
+ eventClientMsgId: incoming.clientMsgId
3884
+ });
3885
+ }
3886
+ return;
3887
+ }
3651
3888
  if (incoming.envelopeType === "reaction" && incoming.reaction) {
3652
3889
  const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3653
3890
  if (actorUserId !== null) {
@@ -3722,7 +3959,10 @@ var Chat = class {
3722
3959
  edited: false,
3723
3960
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3724
3961
  isDeleted: false,
3725
- mentions
3962
+ mentions,
3963
+ // Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
3964
+ // active AS OF arrival). null when this message is non-disappearing.
3965
+ expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
3726
3966
  };
3727
3967
  this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3728
3968
  if (incomingClientMsgId && incoming.text !== null) {
@@ -3734,7 +3974,10 @@ var Chat = class {
3734
3974
  if (incomingClientMsgId) {
3735
3975
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3736
3976
  this.editFold.reevaluateHeld(this.authorOfTarget);
3737
- this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3977
+ this.deleteFold.reevaluatePending(
3978
+ incomingClientMsgId,
3979
+ this.authorOfTarget(incomingClientMsgId)
3980
+ );
3738
3981
  }
3739
3982
  this.messageList.push(this.applyEditOverlay(msg));
3740
3983
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3743,11 +3986,131 @@ var Chat = class {
3743
3986
  incoming.serverSeq
3744
3987
  );
3745
3988
  this.emit();
3989
+ void this.armPurge(
3990
+ this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
3991
+ incoming.serverSeq,
3992
+ incomingClientMsgId
3993
+ );
3746
3994
  }
3747
- /** The EditFold author-gate input: the target message's resolved author userId
3748
- * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3749
- * so it can be passed to the pure EditFold. */
3750
- authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3995
+ // ── Disappearing (TTL T10) ──
3996
+ /** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
3997
+ * `ExpirySpec` the arm path consumes (or null when absent). */
3998
+ toExpirySpec(e) {
3999
+ return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
4000
+ }
4001
+ /** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
4002
+ * so a bubble with no per-message expiry inherits it. null when no default is active or
4003
+ * the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
4004
+ * the default has no per-message sender clock; the arrival anchor drives the deadline
4005
+ * (mirrors iOS `defaultExpiry()`). */
4006
+ defaultExpiry() {
4007
+ const active = this.timerFold.active();
4008
+ if (!active || active.ttlSeconds === null) return null;
4009
+ return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
4010
+ }
4011
+ /** The surfaced display deadline for an effective expiry (a local, monotonic-derived
4012
+ * value — the wall-clock projection of the TTL from now). null for a non-disappearing
4013
+ * message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
4014
+ * UI countdown baseline. */
4015
+ deadlineFor(expiry) {
4016
+ if (!expiry) return null;
4017
+ return new Date(Date.now() + expiry.ttlSeconds * 1e3);
4018
+ }
4019
+ /** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
4020
+ * monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
4021
+ * reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
4022
+ * computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
4023
+ * `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
4024
+ * capture), then either purges immediately or schedules an advisory `setTimeout`. A null
4025
+ * expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
4026
+ async armPurge(expiry, serverSeq, clientMsgId) {
4027
+ if (!expiry || !clientMsgId || !this._group) return;
4028
+ const group = this._group;
4029
+ const fresh = {
4030
+ mAnchorMs: MonotonicClock.nowMs(),
4031
+ wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
4032
+ bAnchorToken: MonotonicClock.bootToken()
4033
+ };
4034
+ await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
4035
+ const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
4036
+ const result = remainingSeconds({
4037
+ ttlSeconds: expiry.ttlSeconds,
4038
+ anchor: effective,
4039
+ nowMonotonicMs: MonotonicClock.nowMs(),
4040
+ nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
4041
+ nowBootToken: MonotonicClock.bootToken()
4042
+ });
4043
+ let purgeInSeconds;
4044
+ if (result.kind === "purgeNow") {
4045
+ purgeInSeconds = 0;
4046
+ } else if (expiry.start === "send" && expiry.senderSendTs !== null) {
4047
+ const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
4048
+ purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
4049
+ } else {
4050
+ purgeInSeconds = result.seconds;
4051
+ }
4052
+ const prior = this.purgeTimers.get(serverSeq);
4053
+ if (prior) clearTimeout(prior);
4054
+ this.purgeTimers.delete(serverSeq);
4055
+ if (purgeInSeconds <= 0) {
4056
+ await this.purge(serverSeq, clientMsgId);
4057
+ return;
4058
+ }
4059
+ const handle = setTimeout(() => {
4060
+ void this.purge(serverSeq, clientMsgId);
4061
+ }, purgeInSeconds * 1e3);
4062
+ this.purgeTimers.set(serverSeq, handle);
4063
+ }
4064
+ /** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
4065
+ * the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
4066
+ * remaining time (purge immediately if the deadline has already passed). The durable
4067
+ * tombstone is written by `purge` when it fires (the crash-safe commit point). */
4068
+ async armFromDeadline(deadline, serverSeq, clientMsgId) {
4069
+ if (!this._group) return;
4070
+ const remainingMs = deadline.getTime() - Date.now();
4071
+ const prior = this.purgeTimers.get(serverSeq);
4072
+ if (prior) clearTimeout(prior);
4073
+ this.purgeTimers.delete(serverSeq);
4074
+ if (remainingMs <= 0) {
4075
+ await this.purge(serverSeq, clientMsgId);
4076
+ return;
4077
+ }
4078
+ const handle = setTimeout(() => {
4079
+ void this.purge(serverSeq, clientMsgId);
4080
+ }, remainingMs);
4081
+ this.purgeTimers.set(serverSeq, handle);
4082
+ }
4083
+ /** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
4084
+ * persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
4085
+ * record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
4086
+ * edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
4087
+ * (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
4088
+ async purge(serverSeq, clientMsgId) {
4089
+ if (!this._group) return;
4090
+ const prior = this.purgeTimers.get(serverSeq);
4091
+ if (prior) clearTimeout(prior);
4092
+ this.purgeTimers.delete(serverSeq);
4093
+ await this.backend.tombstone(this._group, serverSeq, clientMsgId);
4094
+ if (clientMsgId) this.purgedCids.add(clientMsgId);
4095
+ this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
4096
+ this.seenKeys.delete(this.internalKey(serverSeq));
4097
+ this.emit();
4098
+ this.editFold.reevaluateHeld(this.authorOfTarget);
4099
+ if (clientMsgId) {
4100
+ this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
4101
+ }
4102
+ }
4103
+ /** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
4104
+ * T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
4105
+ * durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
4106
+ * disappeared message); `'author'` when its author is locally known → run the
4107
+ * author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
4108
+ * resolver. Captured as a bound arrow so it can be passed to the pure folds. */
4109
+ authorOfTarget = (targetClientMsgId) => {
4110
+ if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
4111
+ const a = this.authorByClientMsgId.get(targetClientMsgId);
4112
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
4113
+ };
3751
4114
  // ── Mentions (mentions T6) ──
3752
4115
  /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3753
4116
  * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
@@ -4033,11 +4396,63 @@ var Chat = class {
4033
4396
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4034
4397
  }
4035
4398
  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);
4399
+ const effectiveExpiry = this.composeExpiry(opts?.expiresIn);
4400
+ const { receipt, clientMsgId } = await this.backend.sendText(
4401
+ group,
4402
+ text,
4403
+ replyRef,
4404
+ bodyRanges,
4405
+ effectiveExpiry
4406
+ );
4407
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, effectiveExpiry);
4408
+ if (effectiveExpiry) void this.armPurge(effectiveExpiry, receipt.serverSeq, clientMsgId);
4038
4409
  return receipt;
4039
4410
  }
4040
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4411
+ /** Resolve a send's effective per-message expiry at COMPOSE TIME: the caller's explicit
4412
+ * `expiresIn` if present, ELSE the chat's active default timer stamped onto the message
4413
+ * NOW (the durable record per spec §"Compose-time stamping"). A `send`-anchored expiry
4414
+ * (explicit or default-inherited) stamps `senderSendTs` = the sender's compose epoch
4415
+ * seconds; a `read`-anchored one carries none (the deadline is the recipient's local
4416
+ * first-read). Returns null when there is neither an explicit expiry nor an active
4417
+ * default (a plain, non-disappearing send). Mirrors the iOS compose-time stamping. */
4418
+ composeExpiry(explicit) {
4419
+ const base = explicit ? {
4420
+ v: 1,
4421
+ ttlSeconds: explicit.ttlSeconds,
4422
+ start: explicit.start ?? "send",
4423
+ senderSendTs: null
4424
+ } : this.defaultExpiry();
4425
+ if (!base) return null;
4426
+ return {
4427
+ ...base,
4428
+ senderSendTs: base.start === "send" ? Math.floor(Date.now() / 1e3) : null
4429
+ };
4430
+ }
4431
+ /** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
4432
+ * envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
4433
+ * own-set locally so the default applies immediately to subsequent sends that carry no
4434
+ * per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
4435
+ * ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
4436
+ * Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
4437
+ async setDisappearing(opts) {
4438
+ const group = await this.materializeIfNeeded();
4439
+ const clientMsgId = mintClientMsgId();
4440
+ const start = opts.start ?? "send";
4441
+ const { receipt } = await this.backend.sendTimerSet(group, {
4442
+ clientMsgId,
4443
+ ttlSeconds: opts.ttlSeconds,
4444
+ start
4445
+ });
4446
+ this.timerFold.ingest({
4447
+ ttlSeconds: opts.ttlSeconds,
4448
+ start,
4449
+ actorUserId: this.backend.selfUserId,
4450
+ epoch: receipt.epoch,
4451
+ serverSeq: receipt.serverSeq,
4452
+ eventClientMsgId: clientMsgId
4453
+ });
4454
+ }
4455
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, expiry) {
4041
4456
  if (receipt.serverSeq <= 0) return;
4042
4457
  const key = this.internalKey(receipt.serverSeq);
4043
4458
  if (this.seenKeys.has(key)) return;
@@ -4065,7 +4480,12 @@ var Chat = class {
4065
4480
  isDeleted: false,
4066
4481
  // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4067
4482
  // sender never gets a wire echo of its own message — this is the only local copy).
4068
- mentions: this.resolveMentions(text, bodyRanges)
4483
+ mentions: this.resolveMentions(text, bodyRanges),
4484
+ // Disappearing T10: the surfaced deadline reflects the message's effective expiry
4485
+ // (explicit `expiresIn` OR the chat default stamped at compose time). null only when
4486
+ // this send is non-disappearing. armPurge re-derives the durable monotonic deadline;
4487
+ // this is the immediate UI countdown baseline (own sender and receiver are symmetric).
4488
+ expiresAt: this.deadlineFor(expiry ?? null)
4069
4489
  });
4070
4490
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4071
4491
  this.emit();
@@ -4420,6 +4840,7 @@ var MessageDeliverySource = class {
4420
4840
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4421
4841
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4422
4842
  const isDelete = decoded.type === "delete" && decoded.delete != null;
4843
+ const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
4423
4844
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4424
4845
  const stored = {
4425
4846
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4477,7 +4898,18 @@ var MessageDeliverySource = class {
4477
4898
  targetClientMsgId: decoded.delete.targetClientMsgId,
4478
4899
  scope: decoded.delete.scope
4479
4900
  }
4480
- } : {}
4901
+ } : {},
4902
+ // Disappearing T10: thread the timer_set discriminator + payload through the
4903
+ // persisted row so the chat default re-folds on cold launch (the page-local
4904
+ // TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
4905
+ ...isTimerSet && decoded.timer ? {
4906
+ envelopeType: "timer_set",
4907
+ timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
4908
+ } : {},
4909
+ // Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
4910
+ // row so the message re-arms its purge on cold launch (the projection derives the
4911
+ // deadline from this). Only on a text bubble; omitted when absent (backward-compat).
4912
+ ...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
4481
4913
  };
4482
4914
  try {
4483
4915
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -4500,7 +4932,12 @@ var MessageDeliverySource = class {
4500
4932
  delete: isDelete ? decoded.delete : null,
4501
4933
  // The raw mention ranges (text bubble or the edit's replacement ranges); the
4502
4934
  // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4503
- bodyRanges: decoded.bodyRanges ?? null
4935
+ bodyRanges: decoded.bodyRanges ?? null,
4936
+ // Disappearing T10: the decoded timer_set payload (chat default control) + a text
4937
+ // bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
4938
+ // arms a bubble's purge from the expiry (or the active default).
4939
+ timer: isTimerSet ? decoded.timer : null,
4940
+ expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
4504
4941
  });
4505
4942
  return true;
4506
4943
  }
@@ -4579,6 +5016,67 @@ function isOwnEchoOrConsumed(e) {
4579
5016
  return msg.includes("message from self") || msg.includes("key not available, invalid generation");
4580
5017
  }
4581
5018
 
5019
+ // src/messaging/disappearing.ts
5020
+ var DisappearingStore = class {
5021
+ constructor(kv) {
5022
+ this.kv = kv;
5023
+ }
5024
+ kv;
5025
+ key(rfc) {
5026
+ return `disappear:${rfc}`;
5027
+ }
5028
+ async load(rfc) {
5029
+ const raw = await this.kv.get(this.key(rfc));
5030
+ if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5031
+ try {
5032
+ const r = JSON.parse(decodeUtf8(raw));
5033
+ return {
5034
+ tombstonedSeqs: r.tombstonedSeqs ?? [],
5035
+ purgedClientMsgIds: r.purgedClientMsgIds ?? [],
5036
+ anchors: r.anchors ?? {}
5037
+ };
5038
+ } catch {
5039
+ return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5040
+ }
5041
+ }
5042
+ async save(rfc, rec) {
5043
+ await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
5044
+ }
5045
+ /** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
5046
+ async tombstonedSeqs(rfc) {
5047
+ return new Set((await this.load(rfc)).tombstonedSeqs);
5048
+ }
5049
+ /** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
5050
+ async purgedClientMsgIds(rfc) {
5051
+ return new Set((await this.load(rfc)).purgedClientMsgIds);
5052
+ }
5053
+ /**
5054
+ * Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
5055
+ * written together in ONE durable record. Idempotent (re-tombstoning the same seq /
5056
+ * id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
5057
+ * from the store excludes the seq and a redelivery is dropped, even across a crash.
5058
+ */
5059
+ async tombstone(rfc, serverSeq, clientMsgId) {
5060
+ const rec = await this.load(rfc);
5061
+ if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
5062
+ if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
5063
+ rec.purgedClientMsgIds.push(clientMsgId);
5064
+ }
5065
+ await this.save(rfc, rec);
5066
+ }
5067
+ /** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
5068
+ async anchor(rfc, clientMsgId) {
5069
+ return (await this.load(rfc)).anchors[clientMsgId] ?? null;
5070
+ }
5071
+ /** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
5072
+ async writeAnchorOnce(rfc, clientMsgId, a) {
5073
+ const rec = await this.load(rfc);
5074
+ if (rec.anchors[clientMsgId]) return;
5075
+ rec.anchors[clientMsgId] = a;
5076
+ await this.save(rfc, rec);
5077
+ }
5078
+ };
5079
+
4582
5080
  // src/messaging/history.ts
4583
5081
  var MessageStore = class {
4584
5082
  constructor(kv) {
@@ -6393,6 +6891,7 @@ var MessagingCoordinator = class {
6393
6891
  this.kpStore = new KeyPackageStorage(this.kv);
6394
6892
  this.suppressionStore = new SuppressionStore(this.kv);
6395
6893
  this.elevationStore = new MentionElevationStore(this.kv);
6894
+ this.disappearingStore = new DisappearingStore(this.kv);
6396
6895
  this.registry.attachChatList(
6397
6896
  (chats) => {
6398
6897
  this.chatList = chats;
@@ -6409,6 +6908,7 @@ var MessagingCoordinator = class {
6409
6908
  kpStore;
6410
6909
  suppressionStore;
6411
6910
  elevationStore;
6911
+ disappearingStore;
6412
6912
  registry = new GroupRegistry();
6413
6913
  resolved = null;
6414
6914
  resolvePromise = null;
@@ -6580,6 +7080,10 @@ var MessagingCoordinator = class {
6580
7080
  const r = await this.resolve();
6581
7081
  return r.groups.sendDelete(group, args);
6582
7082
  }
7083
+ async sendTimerSet(group, args) {
7084
+ const r = await this.resolve();
7085
+ return r.groups.sendTimerSet(group, args);
7086
+ }
6583
7087
  /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6584
7088
  loadSuppressed(group) {
6585
7089
  return this.suppressionStore.load(group.rfcGroupId);
@@ -6596,10 +7100,28 @@ var MessagingCoordinator = class {
6596
7100
  saveElevated(group, keys) {
6597
7101
  return this.elevationStore.save(group.rfcGroupId, keys);
6598
7102
  }
7103
+ // ── Disappearing / TTL seam (durable-only, no wire) ──
7104
+ tombstonedSeqs(group) {
7105
+ return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7106
+ }
7107
+ purgedClientMsgIds(group) {
7108
+ return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7109
+ }
7110
+ anchor(group, clientMsgId) {
7111
+ return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
7112
+ }
7113
+ writeAnchorOnce(group, clientMsgId, a) {
7114
+ return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
7115
+ }
7116
+ tombstone(group, serverSeq, clientMsgId) {
7117
+ return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
7118
+ }
6599
7119
  async history(group, limit, before) {
6600
7120
  const r = await this.resolve();
6601
7121
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
6602
- return projectHistory(group.displayId, rows, this.selfUserId);
7122
+ const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7123
+ const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7124
+ return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
6603
7125
  }
6604
7126
  async members(group) {
6605
7127
  const r = await this.resolve();
@@ -6676,9 +7198,10 @@ var MessagingCoordinator = class {
6676
7198
  return res.devices.map((d) => d.device_id);
6677
7199
  }
6678
7200
  };
6679
- function projectHistory(displayId, rows, selfUserId, resolveActor) {
7201
+ function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
7202
+ const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
6680
7203
  const fold = new ReactionFold();
6681
- for (const s of rows) {
7204
+ for (const s of visible) {
6682
7205
  if (s.envelopeType !== "reaction" || !s.reaction) continue;
6683
7206
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6684
7207
  if (actor === null) continue;
@@ -6694,17 +7217,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6694
7217
  }
6695
7218
  const editFold = new EditFold();
6696
7219
  const deleteFold = new DeleteFold();
7220
+ const pageTimerFold = new TimerFold();
6697
7221
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6698
- for (const s of rows) {
6699
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7222
+ for (const s of visible) {
7223
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6700
7224
  continue;
6701
7225
  const cid = s.clientMsgId ?? "";
6702
7226
  if (!cid) continue;
6703
7227
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6704
7228
  if (author != null) authorByClientMsgId.set(cid, author);
6705
7229
  }
6706
- const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6707
- for (const s of rows) {
7230
+ const authorOfTarget = (cid) => {
7231
+ if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
7232
+ const a = authorByClientMsgId.get(cid);
7233
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
7234
+ };
7235
+ for (const s of visible) {
6708
7236
  if (s.envelopeType !== "edit" || !s.edit) continue;
6709
7237
  const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6710
7238
  editFold.ingest(
@@ -6723,7 +7251,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6723
7251
  );
6724
7252
  }
6725
7253
  editFold.reevaluateHeld(authorOfTarget);
6726
- for (const s of rows) {
7254
+ for (const s of visible) {
6727
7255
  if (s.envelopeType !== "delete" || !s.delete) continue;
6728
7256
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6729
7257
  deleteFold.ingest(
@@ -6737,10 +7265,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6737
7265
  authorOfTarget
6738
7266
  );
6739
7267
  }
6740
- for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
7268
+ for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
6741
7269
  const lookup = /* @__PURE__ */ new Map();
6742
- for (const s of rows) {
6743
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7270
+ for (const s of visible) {
7271
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6744
7272
  continue;
6745
7273
  const cid = s.clientMsgId ?? "";
6746
7274
  if (cid && s.text !== null) {
@@ -6749,7 +7277,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6749
7277
  }
6750
7278
  }
6751
7279
  const out = [];
6752
- for (const s of rows) {
7280
+ for (const s of visible) {
7281
+ if (s.envelopeType === "timer_set") {
7282
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
7283
+ if (actor !== null && s.timer) {
7284
+ pageTimerFold.ingest({
7285
+ ttlSeconds: s.timer.ttlSeconds,
7286
+ start: s.timer.start,
7287
+ actorUserId: actor,
7288
+ epoch: s.epoch,
7289
+ serverSeq: s.serverSeq,
7290
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
7291
+ });
7292
+ }
7293
+ continue;
7294
+ }
6753
7295
  if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6754
7296
  continue;
6755
7297
  const clientMsgId = s.clientMsgId ?? "";
@@ -6769,10 +7311,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6769
7311
  edited: false,
6770
7312
  isDeleted: true,
6771
7313
  // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6772
- mentions: []
7314
+ mentions: [],
7315
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
7316
+ expiresAt: null
6773
7317
  });
6774
7318
  continue;
6775
7319
  }
7320
+ let expiresAt = null;
7321
+ if (s.expiry) {
7322
+ expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
7323
+ } else {
7324
+ const active = pageTimerFold.active();
7325
+ if (active && active.ttlSeconds !== null) {
7326
+ expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
7327
+ }
7328
+ }
6776
7329
  let replyTo = null;
6777
7330
  if (s.replyTo) {
6778
7331
  const ref = {
@@ -6805,7 +7358,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6805
7358
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6806
7359
  edited,
6807
7360
  isDeleted: false,
6808
- mentions
7361
+ mentions,
7362
+ expiresAt
6809
7363
  });
6810
7364
  }
6811
7365
  return out;
@@ -7552,7 +8106,7 @@ function defaultSessionStorage(key) {
7552
8106
  }
7553
8107
 
7554
8108
  // src/version.ts
7555
- var VERSION = "1.5.0";
8109
+ var VERSION = "1.6.1";
7556
8110
 
7557
8111
  // src/runtime.ts
7558
8112
  function buildRuntime(config) {