@palbase/web 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/internal.cjs CHANGED
@@ -2612,6 +2612,47 @@ var PalbeFlags = class {
2612
2612
  }
2613
2613
  };
2614
2614
 
2615
+ // src/messaging/deadline-calculator.ts
2616
+ function remainingSeconds(args) {
2617
+ const ttl = args.ttlSeconds;
2618
+ const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
2619
+ let elapsed;
2620
+ if (args.nowBootToken === args.anchor.bAnchorToken) {
2621
+ const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
2622
+ elapsed = Math.max(monoDeltaSec, wallDeltaSec);
2623
+ } else {
2624
+ elapsed = wallDeltaSec;
2625
+ }
2626
+ const remaining = Math.min(ttl, ttl - elapsed);
2627
+ return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
2628
+ }
2629
+ var cachedBootToken = null;
2630
+ var MonotonicClock = {
2631
+ nowMs() {
2632
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
2633
+ },
2634
+ nowWallEpochMs() {
2635
+ return Date.now();
2636
+ },
2637
+ bootToken() {
2638
+ if (cachedBootToken !== null) return cachedBootToken;
2639
+ try {
2640
+ const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
2641
+ if (existing) {
2642
+ cachedBootToken = existing;
2643
+ return existing;
2644
+ }
2645
+ const fresh = crypto.randomUUID();
2646
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
2647
+ cachedBootToken = fresh;
2648
+ return fresh;
2649
+ } catch {
2650
+ cachedBootToken = crypto.randomUUID();
2651
+ return cachedBootToken;
2652
+ }
2653
+ }
2654
+ };
2655
+
2615
2656
  // src/messaging/delete-fold.ts
2616
2657
  var DeleteFold = class {
2617
2658
  // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
@@ -2624,17 +2665,24 @@ var DeleteFold = class {
2624
2665
  // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2625
2666
  held = [];
2626
2667
  /**
2627
- * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2628
- * userId (null = target absent locally → defer).
2668
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's author via
2669
+ * {@link AuthorResolution}: `'author'` run the author-gate; `'unknown'` → defer
2670
+ * (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
2671
+ * was TTL-purged — a delete of an already-gone message is already satisfied; never
2672
+ * park in pending, never re-attempt).
2629
2673
  */
2630
2674
  ingest(e, authorOfTarget) {
2631
2675
  if (this.tombstoned.has(e.targetClientMsgId)) return;
2632
2676
  if (this.seen.has(e.eventClientMsgId)) return;
2633
2677
  if (this.heldContains(e.eventClientMsgId)) return;
2634
- const author = authorOfTarget(e.targetClientMsgId);
2635
- if (author !== null) {
2678
+ const res = authorOfTarget(e.targetClientMsgId);
2679
+ if (res.kind === "purged") {
2680
+ this.seen.add(e.eventClientMsgId);
2681
+ return;
2682
+ }
2683
+ if (res.kind === "author") {
2636
2684
  this.seen.add(e.eventClientMsgId);
2637
- if (e.actorUserId === null || e.actorUserId !== author) return;
2685
+ if (e.actorUserId === null || e.actorUserId !== res.userId) return;
2638
2686
  this.tombstoned.add(e.targetClientMsgId);
2639
2687
  } else if (e.actorUserId !== null) {
2640
2688
  this.seen.add(e.eventClientMsgId);
@@ -2654,12 +2702,13 @@ var DeleteFold = class {
2654
2702
  * the in-order path.
2655
2703
  */
2656
2704
  reevaluatePending(target, author) {
2705
+ const res = author;
2657
2706
  const actor = this.pending.get(target);
2658
2707
  if (actor !== void 0) {
2659
- if (author !== null && actor === author) {
2660
- this.tombstoned.add(target);
2708
+ if (res.kind === "author") {
2709
+ if (actor === res.userId) this.tombstoned.add(target);
2661
2710
  this.pending.delete(target);
2662
- } else if (author !== null) {
2711
+ } else if (res.kind === "purged") {
2663
2712
  this.pending.delete(target);
2664
2713
  }
2665
2714
  }
@@ -2667,7 +2716,7 @@ var DeleteFold = class {
2667
2716
  const pendingHeld = this.held;
2668
2717
  this.held = [];
2669
2718
  for (const e of pendingHeld) {
2670
- this.ingest(e, (t) => t === target ? author : null);
2719
+ this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
2671
2720
  }
2672
2721
  }
2673
2722
  heldContains(eventClientMsgId) {
@@ -2693,15 +2742,23 @@ var EditFold = class {
2693
2742
  // targets that have had ≥1 valid edit applied (write-once)
2694
2743
  editedTargets = /* @__PURE__ */ new Set();
2695
2744
  /**
2696
- * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2697
- * (null = target unknown/dangling → HOLD).
2745
+ * Ingest one edit. `authorOfTarget` resolves the target message's author via
2746
+ * {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
2747
+ * (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
2748
+ * editing a disappeared message is a no-op; mark the event seen so it never re-holds
2749
+ * and a later author "resolution" cannot resurrect it).
2698
2750
  */
2699
2751
  ingest(e, authorOfTarget) {
2700
- const author = authorOfTarget(e.targetClientMsgId);
2701
- if (author === null) {
2752
+ const res = authorOfTarget(e.targetClientMsgId);
2753
+ if (res.kind === "unknown") {
2702
2754
  this.holdIfNew(e);
2703
2755
  return;
2704
2756
  }
2757
+ if (res.kind === "purged") {
2758
+ this.seenEvents.add(e.eventClientMsgId);
2759
+ return;
2760
+ }
2761
+ const author = res.userId;
2705
2762
  if (e.editorUserId === null) {
2706
2763
  this.holdIfNew(e);
2707
2764
  return;
@@ -2960,14 +3017,46 @@ function encodeEnvelope(args) {
2960
3017
  length: r.length,
2961
3018
  mentioned_user_id: r.mentionedUserId
2962
3019
  }))
3020
+ } : {},
3021
+ ...args.expiry ? {
3022
+ expiry: {
3023
+ v: args.expiry.v,
3024
+ ttl_seconds: args.expiry.ttlSeconds,
3025
+ start: args.expiry.start,
3026
+ // present IFF send (drop a stray senderSendTs on a read anchor)
3027
+ ...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
3028
+ }
2963
3029
  } : {}
2964
3030
  };
2965
3031
  return encodeUtf8(JSON.stringify(env));
2966
3032
  }
3033
+ function encodeTimerSet(args) {
3034
+ return encodeUtf8(
3035
+ JSON.stringify({
3036
+ v: 1,
3037
+ type: "timer_set",
3038
+ client_msg_id: args.clientMsgId,
3039
+ ...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
3040
+ start: args.start
3041
+ })
3042
+ );
3043
+ }
2967
3044
  function decodeEnvelope(bytes) {
2968
3045
  const s = decodeUtf8(bytes);
2969
3046
  try {
2970
3047
  const o = JSON.parse(s);
3048
+ if (typeof o === "object" && o !== null && o.type === "timer_set") {
3049
+ return {
3050
+ type: "timer_set",
3051
+ text: null,
3052
+ clientMsgId: o.client_msg_id ?? "",
3053
+ replyTo: null,
3054
+ timer: {
3055
+ ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
3056
+ start: o.start === "read" ? "read" : "send"
3057
+ }
3058
+ };
3059
+ }
2971
3060
  if (typeof o === "object" && o !== null && o.type === "delete") {
2972
3061
  return {
2973
3062
  type: "delete",
@@ -3009,12 +3098,14 @@ function decodeEnvelope(bytes) {
3009
3098
  }
3010
3099
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
3011
3100
  const textRanges = decodeBodyRanges(o.body_ranges);
3101
+ const expiry = decodeExpiry(o.expiry);
3012
3102
  return {
3013
3103
  type: "text",
3014
3104
  text: o.text ?? null,
3015
3105
  clientMsgId: o.client_msg_id ?? "",
3016
3106
  replyTo: o.reply_to ?? null,
3017
- ...textRanges ? { bodyRanges: textRanges } : {}
3107
+ ...textRanges ? { bodyRanges: textRanges } : {},
3108
+ ...expiry ? { expiry } : {}
3018
3109
  };
3019
3110
  }
3020
3111
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -3026,6 +3117,19 @@ function decodeEnvelope(bytes) {
3026
3117
  }
3027
3118
  return { text: s, clientMsgId: "", replyTo: null };
3028
3119
  }
3120
+ function decodeExpiry(raw) {
3121
+ if (typeof raw !== "object" || raw === null) return void 0;
3122
+ const o = raw;
3123
+ if (typeof o.ttl_seconds !== "number") return void 0;
3124
+ const start = o.start === "read" ? "read" : "send";
3125
+ return {
3126
+ v: typeof o.v === "number" ? o.v : 1,
3127
+ ttlSeconds: o.ttl_seconds,
3128
+ start,
3129
+ // tolerant: only honor sender_send_ts on a send anchor; null otherwise.
3130
+ senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
3131
+ };
3132
+ }
3029
3133
  function decodeBodyRanges(raw) {
3030
3134
  if (!raw || raw.length === 0) return void 0;
3031
3135
  return raw.map((r) => ({
@@ -3245,9 +3349,9 @@ var GroupMessaging = class {
3245
3349
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3246
3350
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3247
3351
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3248
- async sendText(group, text, replyTo, bodyRanges) {
3352
+ async sendText(group, text, replyTo, bodyRanges, expiry) {
3249
3353
  const clientMsgId = mintClientMsgId();
3250
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3354
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
3251
3355
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3252
3356
  const body = {
3253
3357
  ciphertext_b64: toBase64(ct),
@@ -3276,7 +3380,10 @@ var GroupMessaging = class {
3276
3380
  } : null,
3277
3381
  // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3278
3382
  // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3279
- ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3383
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
3384
+ // Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
3385
+ // after a cold launch (the projection derives the deadline from this row's expiry).
3386
+ ...expiry ? { expiry } : {}
3280
3387
  };
3281
3388
  try {
3282
3389
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3284,6 +3391,51 @@ var GroupMessaging = class {
3284
3391
  }
3285
3392
  return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
3286
3393
  }
3394
+ /** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
3395
+ * a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
3396
+ * application path as `sendText` (the server stays blind — it's an opaque app message,
3397
+ * NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
3398
+ * Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
3399
+ * (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
3400
+ async sendTimerSet(group, args) {
3401
+ const plaintext = encodeTimerSet({
3402
+ clientMsgId: args.clientMsgId,
3403
+ ttlSeconds: args.ttlSeconds,
3404
+ start: args.start
3405
+ });
3406
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3407
+ const body = {
3408
+ ciphertext_b64: toBase64(ct),
3409
+ client_idem_key: randomId()
3410
+ };
3411
+ const wire = await palbeRequest(
3412
+ this.rt,
3413
+ "POST",
3414
+ MessagingPaths.groupMessages(group.displayId),
3415
+ { body }
3416
+ );
3417
+ const stored = {
3418
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3419
+ direction: "outgoing",
3420
+ text: null,
3421
+ senderDeviceId: this.selfDeviceId,
3422
+ epoch: wire.epoch,
3423
+ serverSeq: wire.server_seq,
3424
+ at: Date.now(),
3425
+ clientMsgId: args.clientMsgId,
3426
+ replyTo: null,
3427
+ envelopeType: "timer_set",
3428
+ timer: { ttlSeconds: args.ttlSeconds, start: args.start }
3429
+ };
3430
+ try {
3431
+ await this.messageStore.append(group.rfcGroupId, stored);
3432
+ } catch {
3433
+ }
3434
+ return {
3435
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3436
+ clientMsgId: args.clientMsgId
3437
+ };
3438
+ }
3287
3439
  /** Send a reaction (add/remove of an emoji on a target message). Encrypts a
3288
3440
  * `type:'reaction'` envelope at the current epoch and sends through the SAME
3289
3441
  * MLS application path as `sendText` (the server stays blind — a reaction is
@@ -3586,6 +3738,43 @@ var ReactionFold = class {
3586
3738
  }
3587
3739
  };
3588
3740
 
3741
+ // src/messaging/timer-fold.ts
3742
+ function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
3743
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
3744
+ return aSeq <= bSeq;
3745
+ }
3746
+ var TimerFold = class {
3747
+ cell = null;
3748
+ seenEvents = /* @__PURE__ */ new Set();
3749
+ ingest(e) {
3750
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
3751
+ this.seenEvents.add(e.eventClientMsgId);
3752
+ if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
3753
+ return;
3754
+ }
3755
+ this.cell = {
3756
+ orderEpoch: e.epoch,
3757
+ orderSeq: e.serverSeq,
3758
+ ttlSeconds: e.ttlSeconds,
3759
+ start: e.start,
3760
+ actor: e.actorUserId
3761
+ };
3762
+ }
3763
+ /**
3764
+ * The active chat default, or null if no timer_set has applied.
3765
+ * `ttlSeconds === null` means DISABLED (still applied — distinct from "never
3766
+ * set"). `start` is meaningful only when ttlSeconds !== null.
3767
+ */
3768
+ active() {
3769
+ if (this.cell === null) return null;
3770
+ return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
3771
+ }
3772
+ /** The userId behind the winning timer_set (for the optional system line). null if unset. */
3773
+ lastActor() {
3774
+ return this.cell?.actor ?? null;
3775
+ }
3776
+ };
3777
+
3589
3778
  // src/messaging/chat.ts
3590
3779
  var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3591
3780
  var Chat = class {
@@ -3612,6 +3801,22 @@ var Chat = class {
3612
3801
  /** The single authoritative delete-for-everyone fold (live + own-send + history).
3613
3802
  * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3614
3803
  deleteFold = new DeleteFold();
3804
+ /** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
3805
+ * on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
3806
+ * bubble; it routes here. The active default governs a subsequent bubble that carries
3807
+ * no per-message expiry (disappearing T10). */
3808
+ timerFold = new TimerFold();
3809
+ /** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
3810
+ * persisted anchor + a re-check on every load; this just drives live eviction while
3811
+ * the tab is open. Cancelled when the message purges (disappearing T10). */
3812
+ purgeTimers = /* @__PURE__ */ new Map();
3813
+ /** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
3814
+ * hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
3815
+ * by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
3816
+ * `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
3817
+ * tombstone (disappearing T10). */
3818
+ purgedCids = /* @__PURE__ */ new Set();
3819
+ purgedLoaded = false;
3615
3820
  /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3616
3821
  * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3617
3822
  suppressed = /* @__PURE__ */ new Set();
@@ -3722,7 +3927,9 @@ var Chat = class {
3722
3927
  replyTo: null,
3723
3928
  edited: false,
3724
3929
  isDeleted: true,
3725
- mentions: []
3930
+ mentions: [],
3931
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
3932
+ expiresAt: null
3726
3933
  });
3727
3934
  continue;
3728
3935
  }
@@ -3759,9 +3966,21 @@ var Chat = class {
3759
3966
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3760
3967
  void this.loadSuppressed();
3761
3968
  void this.loadElevated();
3762
- void this.hydrateHistory();
3969
+ void this.loadPurged().then(() => this.hydrateHistory());
3763
3970
  void this.refreshMembers();
3764
3971
  }
3972
+ /** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
3973
+ * live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
3974
+ * (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
3975
+ async loadPurged() {
3976
+ if (this.purgedLoaded || !this._group) return;
3977
+ this.purgedLoaded = true;
3978
+ try {
3979
+ const ids = await this.backend.purgedClientMsgIds(this._group);
3980
+ for (const id of ids) this.purgedCids.add(id);
3981
+ } catch {
3982
+ }
3983
+ }
3765
3984
  /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3766
3985
  * any already-surfaced suppressed message is omitted (cold-launch parity). */
3767
3986
  async loadSuppressed() {
@@ -3818,13 +4037,16 @@ var Chat = class {
3818
4037
  if (this.seenKeys.has(key)) continue;
3819
4038
  this.seenKeys.add(key);
3820
4039
  if (m.clientMsgId && !m.isDeleted) {
3821
- this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
4040
+ this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
3822
4041
  }
3823
4042
  this.messageList.push(
3824
4043
  this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3825
4044
  );
3826
4045
  changed = true;
3827
4046
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
4047
+ if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
4048
+ void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
4049
+ }
3828
4050
  }
3829
4051
  if (changed) {
3830
4052
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3839,6 +4061,7 @@ var Chat = class {
3839
4061
  return;
3840
4062
  }
3841
4063
  if (incoming.serverSeq <= 0) return;
4064
+ if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
3842
4065
  const key = this.internalKey(incoming.serverSeq);
3843
4066
  if (this.seenKeys.has(key)) return;
3844
4067
  this.seenKeys.add(key);
@@ -3847,6 +4070,20 @@ var Chat = class {
3847
4070
  senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
3848
4071
  }
3849
4072
  const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
4073
+ if (incoming.envelopeType === "timer_set" && incoming.timer) {
4074
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
4075
+ if (actorUserId !== null) {
4076
+ this.timerFold.ingest({
4077
+ ttlSeconds: incoming.timer.ttlSeconds,
4078
+ start: incoming.timer.start,
4079
+ actorUserId,
4080
+ epoch: incoming.epoch,
4081
+ serverSeq: incoming.serverSeq,
4082
+ eventClientMsgId: incoming.clientMsgId
4083
+ });
4084
+ }
4085
+ return;
4086
+ }
3850
4087
  if (incoming.envelopeType === "reaction" && incoming.reaction) {
3851
4088
  const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3852
4089
  if (actorUserId !== null) {
@@ -3921,7 +4158,10 @@ var Chat = class {
3921
4158
  edited: false,
3922
4159
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3923
4160
  isDeleted: false,
3924
- mentions
4161
+ mentions,
4162
+ // Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
4163
+ // active AS OF arrival). null when this message is non-disappearing.
4164
+ expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
3925
4165
  };
3926
4166
  this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3927
4167
  if (incomingClientMsgId && incoming.text !== null) {
@@ -3933,7 +4173,10 @@ var Chat = class {
3933
4173
  if (incomingClientMsgId) {
3934
4174
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3935
4175
  this.editFold.reevaluateHeld(this.authorOfTarget);
3936
- this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
4176
+ this.deleteFold.reevaluatePending(
4177
+ incomingClientMsgId,
4178
+ this.authorOfTarget(incomingClientMsgId)
4179
+ );
3937
4180
  }
3938
4181
  this.messageList.push(this.applyEditOverlay(msg));
3939
4182
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3942,11 +4185,131 @@ var Chat = class {
3942
4185
  incoming.serverSeq
3943
4186
  );
3944
4187
  this.emit();
4188
+ void this.armPurge(
4189
+ this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
4190
+ incoming.serverSeq,
4191
+ incomingClientMsgId
4192
+ );
4193
+ }
4194
+ // ── Disappearing (TTL — T10) ──
4195
+ /** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
4196
+ * `ExpirySpec` the arm path consumes (or null when absent). */
4197
+ toExpirySpec(e) {
4198
+ return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
4199
+ }
4200
+ /** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
4201
+ * so a bubble with no per-message expiry inherits it. null when no default is active or
4202
+ * the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
4203
+ * the default has no per-message sender clock; the arrival anchor drives the deadline
4204
+ * (mirrors iOS `defaultExpiry()`). */
4205
+ defaultExpiry() {
4206
+ const active = this.timerFold.active();
4207
+ if (!active || active.ttlSeconds === null) return null;
4208
+ return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
4209
+ }
4210
+ /** The surfaced display deadline for an effective expiry (a local, monotonic-derived
4211
+ * value — the wall-clock projection of the TTL from now). null for a non-disappearing
4212
+ * message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
4213
+ * UI countdown baseline. */
4214
+ deadlineFor(expiry) {
4215
+ if (!expiry) return null;
4216
+ return new Date(Date.now() + expiry.ttlSeconds * 1e3);
4217
+ }
4218
+ /** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
4219
+ * monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
4220
+ * reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
4221
+ * computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
4222
+ * `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
4223
+ * capture), then either purges immediately or schedules an advisory `setTimeout`. A null
4224
+ * expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
4225
+ async armPurge(expiry, serverSeq, clientMsgId) {
4226
+ if (!expiry || !clientMsgId || !this._group) return;
4227
+ const group = this._group;
4228
+ const fresh = {
4229
+ mAnchorMs: MonotonicClock.nowMs(),
4230
+ wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
4231
+ bAnchorToken: MonotonicClock.bootToken()
4232
+ };
4233
+ await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
4234
+ const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
4235
+ const result = remainingSeconds({
4236
+ ttlSeconds: expiry.ttlSeconds,
4237
+ anchor: effective,
4238
+ nowMonotonicMs: MonotonicClock.nowMs(),
4239
+ nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
4240
+ nowBootToken: MonotonicClock.bootToken()
4241
+ });
4242
+ let purgeInSeconds;
4243
+ if (result.kind === "purgeNow") {
4244
+ purgeInSeconds = 0;
4245
+ } else if (expiry.start === "send" && expiry.senderSendTs !== null) {
4246
+ const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
4247
+ purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
4248
+ } else {
4249
+ purgeInSeconds = result.seconds;
4250
+ }
4251
+ const prior = this.purgeTimers.get(serverSeq);
4252
+ if (prior) clearTimeout(prior);
4253
+ this.purgeTimers.delete(serverSeq);
4254
+ if (purgeInSeconds <= 0) {
4255
+ await this.purge(serverSeq, clientMsgId);
4256
+ return;
4257
+ }
4258
+ const handle = setTimeout(() => {
4259
+ void this.purge(serverSeq, clientMsgId);
4260
+ }, purgeInSeconds * 1e3);
4261
+ this.purgeTimers.set(serverSeq, handle);
3945
4262
  }
3946
- /** The EditFold author-gate input: the target message's resolved author userId
3947
- * (null = target unknown/dangling the fold HOLDs). Captured as a bound arrow
3948
- * so it can be passed to the pure EditFold. */
3949
- authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
4263
+ /** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
4264
+ * the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
4265
+ * remaining time (purge immediately if the deadline has already passed). The durable
4266
+ * tombstone is written by `purge` when it fires (the crash-safe commit point). */
4267
+ async armFromDeadline(deadline, serverSeq, clientMsgId) {
4268
+ if (!this._group) return;
4269
+ const remainingMs = deadline.getTime() - Date.now();
4270
+ const prior = this.purgeTimers.get(serverSeq);
4271
+ if (prior) clearTimeout(prior);
4272
+ this.purgeTimers.delete(serverSeq);
4273
+ if (remainingMs <= 0) {
4274
+ await this.purge(serverSeq, clientMsgId);
4275
+ return;
4276
+ }
4277
+ const handle = setTimeout(() => {
4278
+ void this.purge(serverSeq, clientMsgId);
4279
+ }, remainingMs);
4280
+ this.purgeTimers.set(serverSeq, handle);
4281
+ }
4282
+ /** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
4283
+ * persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
4284
+ * record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
4285
+ * edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
4286
+ * (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
4287
+ async purge(serverSeq, clientMsgId) {
4288
+ if (!this._group) return;
4289
+ const prior = this.purgeTimers.get(serverSeq);
4290
+ if (prior) clearTimeout(prior);
4291
+ this.purgeTimers.delete(serverSeq);
4292
+ await this.backend.tombstone(this._group, serverSeq, clientMsgId);
4293
+ if (clientMsgId) this.purgedCids.add(clientMsgId);
4294
+ this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
4295
+ this.seenKeys.delete(this.internalKey(serverSeq));
4296
+ this.emit();
4297
+ this.editFold.reevaluateHeld(this.authorOfTarget);
4298
+ if (clientMsgId) {
4299
+ this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
4300
+ }
4301
+ }
4302
+ /** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
4303
+ * T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
4304
+ * durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
4305
+ * disappeared message); `'author'` when its author is locally known → run the
4306
+ * author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
4307
+ * resolver. Captured as a bound arrow so it can be passed to the pure folds. */
4308
+ authorOfTarget = (targetClientMsgId) => {
4309
+ if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
4310
+ const a = this.authorByClientMsgId.get(targetClientMsgId);
4311
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
4312
+ };
3950
4313
  // ── Mentions (mentions T6) ──
3951
4314
  /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3952
4315
  * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
@@ -4232,10 +4595,48 @@ var Chat = class {
4232
4595
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4233
4596
  }
4234
4597
  const bodyRanges = opts?.mentions ?? null;
4235
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4598
+ const start = opts?.expiresIn?.start ?? "send";
4599
+ const expiry = opts?.expiresIn ? {
4600
+ v: 1,
4601
+ ttlSeconds: opts.expiresIn.ttlSeconds,
4602
+ start,
4603
+ senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
4604
+ } : null;
4605
+ const { receipt, clientMsgId } = await this.backend.sendText(
4606
+ group,
4607
+ text,
4608
+ replyRef,
4609
+ bodyRanges,
4610
+ expiry
4611
+ );
4236
4612
  this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
4613
+ if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
4237
4614
  return receipt;
4238
4615
  }
4616
+ /** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
4617
+ * envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
4618
+ * own-set locally so the default applies immediately to subsequent sends that carry no
4619
+ * per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
4620
+ * ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
4621
+ * Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
4622
+ async setDisappearing(opts) {
4623
+ const group = await this.materializeIfNeeded();
4624
+ const clientMsgId = mintClientMsgId();
4625
+ const start = opts.start ?? "send";
4626
+ const { receipt } = await this.backend.sendTimerSet(group, {
4627
+ clientMsgId,
4628
+ ttlSeconds: opts.ttlSeconds,
4629
+ start
4630
+ });
4631
+ this.timerFold.ingest({
4632
+ ttlSeconds: opts.ttlSeconds,
4633
+ start,
4634
+ actorUserId: this.backend.selfUserId,
4635
+ epoch: receipt.epoch,
4636
+ serverSeq: receipt.serverSeq,
4637
+ eventClientMsgId: clientMsgId
4638
+ });
4639
+ }
4239
4640
  appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4240
4641
  if (receipt.serverSeq <= 0) return;
4241
4642
  const key = this.internalKey(receipt.serverSeq);
@@ -4264,7 +4665,10 @@ var Chat = class {
4264
4665
  isDeleted: false,
4265
4666
  // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4266
4667
  // sender never gets a wire echo of its own message — this is the only local copy).
4267
- mentions: this.resolveMentions(text, bodyRanges)
4668
+ mentions: this.resolveMentions(text, bodyRanges),
4669
+ // Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
4670
+ // via the messageList overlay; default null here (a plain own-send has no deadline).
4671
+ expiresAt: null
4268
4672
  });
4269
4673
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4270
4674
  this.emit();
@@ -4619,6 +5023,7 @@ var MessageDeliverySource = class {
4619
5023
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4620
5024
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4621
5025
  const isDelete = decoded.type === "delete" && decoded.delete != null;
5026
+ const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
4622
5027
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4623
5028
  const stored = {
4624
5029
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4676,7 +5081,18 @@ var MessageDeliverySource = class {
4676
5081
  targetClientMsgId: decoded.delete.targetClientMsgId,
4677
5082
  scope: decoded.delete.scope
4678
5083
  }
4679
- } : {}
5084
+ } : {},
5085
+ // Disappearing T10: thread the timer_set discriminator + payload through the
5086
+ // persisted row so the chat default re-folds on cold launch (the page-local
5087
+ // TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
5088
+ ...isTimerSet && decoded.timer ? {
5089
+ envelopeType: "timer_set",
5090
+ timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
5091
+ } : {},
5092
+ // Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
5093
+ // row so the message re-arms its purge on cold launch (the projection derives the
5094
+ // deadline from this). Only on a text bubble; omitted when absent (backward-compat).
5095
+ ...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
4680
5096
  };
4681
5097
  try {
4682
5098
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -4699,7 +5115,12 @@ var MessageDeliverySource = class {
4699
5115
  delete: isDelete ? decoded.delete : null,
4700
5116
  // The raw mention ranges (text bubble or the edit's replacement ranges); the
4701
5117
  // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4702
- bodyRanges: decoded.bodyRanges ?? null
5118
+ bodyRanges: decoded.bodyRanges ?? null,
5119
+ // Disappearing T10: the decoded timer_set payload (chat default control) + a text
5120
+ // bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
5121
+ // arms a bubble's purge from the expiry (or the active default).
5122
+ timer: isTimerSet ? decoded.timer : null,
5123
+ expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
4703
5124
  });
4704
5125
  return true;
4705
5126
  }
@@ -4778,6 +5199,67 @@ function isOwnEchoOrConsumed(e) {
4778
5199
  return msg.includes("message from self") || msg.includes("key not available, invalid generation");
4779
5200
  }
4780
5201
 
5202
+ // src/messaging/disappearing.ts
5203
+ var DisappearingStore = class {
5204
+ constructor(kv) {
5205
+ this.kv = kv;
5206
+ }
5207
+ kv;
5208
+ key(rfc) {
5209
+ return `disappear:${rfc}`;
5210
+ }
5211
+ async load(rfc) {
5212
+ const raw = await this.kv.get(this.key(rfc));
5213
+ if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5214
+ try {
5215
+ const r = JSON.parse(decodeUtf8(raw));
5216
+ return {
5217
+ tombstonedSeqs: r.tombstonedSeqs ?? [],
5218
+ purgedClientMsgIds: r.purgedClientMsgIds ?? [],
5219
+ anchors: r.anchors ?? {}
5220
+ };
5221
+ } catch {
5222
+ return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5223
+ }
5224
+ }
5225
+ async save(rfc, rec) {
5226
+ await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
5227
+ }
5228
+ /** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
5229
+ async tombstonedSeqs(rfc) {
5230
+ return new Set((await this.load(rfc)).tombstonedSeqs);
5231
+ }
5232
+ /** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
5233
+ async purgedClientMsgIds(rfc) {
5234
+ return new Set((await this.load(rfc)).purgedClientMsgIds);
5235
+ }
5236
+ /**
5237
+ * Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
5238
+ * written together in ONE durable record. Idempotent (re-tombstoning the same seq /
5239
+ * id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
5240
+ * from the store excludes the seq and a redelivery is dropped, even across a crash.
5241
+ */
5242
+ async tombstone(rfc, serverSeq, clientMsgId) {
5243
+ const rec = await this.load(rfc);
5244
+ if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
5245
+ if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
5246
+ rec.purgedClientMsgIds.push(clientMsgId);
5247
+ }
5248
+ await this.save(rfc, rec);
5249
+ }
5250
+ /** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
5251
+ async anchor(rfc, clientMsgId) {
5252
+ return (await this.load(rfc)).anchors[clientMsgId] ?? null;
5253
+ }
5254
+ /** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
5255
+ async writeAnchorOnce(rfc, clientMsgId, a) {
5256
+ const rec = await this.load(rfc);
5257
+ if (rec.anchors[clientMsgId]) return;
5258
+ rec.anchors[clientMsgId] = a;
5259
+ await this.save(rfc, rec);
5260
+ }
5261
+ };
5262
+
4781
5263
  // src/messaging/history.ts
4782
5264
  var MessageStore = class {
4783
5265
  constructor(kv) {
@@ -6592,6 +7074,7 @@ var MessagingCoordinator = class {
6592
7074
  this.kpStore = new KeyPackageStorage(this.kv);
6593
7075
  this.suppressionStore = new SuppressionStore(this.kv);
6594
7076
  this.elevationStore = new MentionElevationStore(this.kv);
7077
+ this.disappearingStore = new DisappearingStore(this.kv);
6595
7078
  this.registry.attachChatList(
6596
7079
  (chats) => {
6597
7080
  this.chatList = chats;
@@ -6608,6 +7091,7 @@ var MessagingCoordinator = class {
6608
7091
  kpStore;
6609
7092
  suppressionStore;
6610
7093
  elevationStore;
7094
+ disappearingStore;
6611
7095
  registry = new GroupRegistry();
6612
7096
  resolved = null;
6613
7097
  resolvePromise = null;
@@ -6779,6 +7263,10 @@ var MessagingCoordinator = class {
6779
7263
  const r = await this.resolve();
6780
7264
  return r.groups.sendDelete(group, args);
6781
7265
  }
7266
+ async sendTimerSet(group, args) {
7267
+ const r = await this.resolve();
7268
+ return r.groups.sendTimerSet(group, args);
7269
+ }
6782
7270
  /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6783
7271
  loadSuppressed(group) {
6784
7272
  return this.suppressionStore.load(group.rfcGroupId);
@@ -6795,10 +7283,28 @@ var MessagingCoordinator = class {
6795
7283
  saveElevated(group, keys) {
6796
7284
  return this.elevationStore.save(group.rfcGroupId, keys);
6797
7285
  }
7286
+ // ── Disappearing / TTL seam (durable-only, no wire) ──
7287
+ tombstonedSeqs(group) {
7288
+ return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7289
+ }
7290
+ purgedClientMsgIds(group) {
7291
+ return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7292
+ }
7293
+ anchor(group, clientMsgId) {
7294
+ return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
7295
+ }
7296
+ writeAnchorOnce(group, clientMsgId, a) {
7297
+ return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
7298
+ }
7299
+ tombstone(group, serverSeq, clientMsgId) {
7300
+ return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
7301
+ }
6798
7302
  async history(group, limit, before) {
6799
7303
  const r = await this.resolve();
6800
7304
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
6801
- return projectHistory(group.displayId, rows, this.selfUserId);
7305
+ const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7306
+ const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7307
+ return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
6802
7308
  }
6803
7309
  async members(group) {
6804
7310
  const r = await this.resolve();
@@ -6875,9 +7381,10 @@ var MessagingCoordinator = class {
6875
7381
  return res.devices.map((d) => d.device_id);
6876
7382
  }
6877
7383
  };
6878
- function projectHistory(displayId, rows, selfUserId, resolveActor) {
7384
+ function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
7385
+ const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
6879
7386
  const fold = new ReactionFold();
6880
- for (const s of rows) {
7387
+ for (const s of visible) {
6881
7388
  if (s.envelopeType !== "reaction" || !s.reaction) continue;
6882
7389
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6883
7390
  if (actor === null) continue;
@@ -6893,17 +7400,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6893
7400
  }
6894
7401
  const editFold = new EditFold();
6895
7402
  const deleteFold = new DeleteFold();
7403
+ const pageTimerFold = new TimerFold();
6896
7404
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6897
- for (const s of rows) {
6898
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7405
+ for (const s of visible) {
7406
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6899
7407
  continue;
6900
7408
  const cid = s.clientMsgId ?? "";
6901
7409
  if (!cid) continue;
6902
7410
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6903
7411
  if (author != null) authorByClientMsgId.set(cid, author);
6904
7412
  }
6905
- const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6906
- for (const s of rows) {
7413
+ const authorOfTarget = (cid) => {
7414
+ if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
7415
+ const a = authorByClientMsgId.get(cid);
7416
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
7417
+ };
7418
+ for (const s of visible) {
6907
7419
  if (s.envelopeType !== "edit" || !s.edit) continue;
6908
7420
  const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6909
7421
  editFold.ingest(
@@ -6922,7 +7434,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6922
7434
  );
6923
7435
  }
6924
7436
  editFold.reevaluateHeld(authorOfTarget);
6925
- for (const s of rows) {
7437
+ for (const s of visible) {
6926
7438
  if (s.envelopeType !== "delete" || !s.delete) continue;
6927
7439
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6928
7440
  deleteFold.ingest(
@@ -6936,10 +7448,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6936
7448
  authorOfTarget
6937
7449
  );
6938
7450
  }
6939
- for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
7451
+ for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
6940
7452
  const lookup = /* @__PURE__ */ new Map();
6941
- for (const s of rows) {
6942
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7453
+ for (const s of visible) {
7454
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6943
7455
  continue;
6944
7456
  const cid = s.clientMsgId ?? "";
6945
7457
  if (cid && s.text !== null) {
@@ -6948,7 +7460,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6948
7460
  }
6949
7461
  }
6950
7462
  const out = [];
6951
- for (const s of rows) {
7463
+ for (const s of visible) {
7464
+ if (s.envelopeType === "timer_set") {
7465
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
7466
+ if (actor !== null && s.timer) {
7467
+ pageTimerFold.ingest({
7468
+ ttlSeconds: s.timer.ttlSeconds,
7469
+ start: s.timer.start,
7470
+ actorUserId: actor,
7471
+ epoch: s.epoch,
7472
+ serverSeq: s.serverSeq,
7473
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
7474
+ });
7475
+ }
7476
+ continue;
7477
+ }
6952
7478
  if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6953
7479
  continue;
6954
7480
  const clientMsgId = s.clientMsgId ?? "";
@@ -6968,10 +7494,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6968
7494
  edited: false,
6969
7495
  isDeleted: true,
6970
7496
  // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6971
- mentions: []
7497
+ mentions: [],
7498
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
7499
+ expiresAt: null
6972
7500
  });
6973
7501
  continue;
6974
7502
  }
7503
+ let expiresAt = null;
7504
+ if (s.expiry) {
7505
+ expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
7506
+ } else {
7507
+ const active = pageTimerFold.active();
7508
+ if (active && active.ttlSeconds !== null) {
7509
+ expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
7510
+ }
7511
+ }
6975
7512
  let replyTo = null;
6976
7513
  if (s.replyTo) {
6977
7514
  const ref = {
@@ -7004,7 +7541,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
7004
7541
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
7005
7542
  edited,
7006
7543
  isDeleted: false,
7007
- mentions
7544
+ mentions,
7545
+ expiresAt
7008
7546
  });
7009
7547
  }
7010
7548
  return out;
@@ -7751,7 +8289,7 @@ function defaultSessionStorage(key) {
7751
8289
  }
7752
8290
 
7753
8291
  // src/version.ts
7754
- var VERSION = "1.5.0";
8292
+ var VERSION = "1.6.0";
7755
8293
 
7756
8294
  // src/runtime.ts
7757
8295
  function buildRuntime(config) {