@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.
@@ -2591,6 +2591,47 @@ var PalbeFlags = class {
2591
2591
  }
2592
2592
  };
2593
2593
 
2594
+ // src/messaging/deadline-calculator.ts
2595
+ function remainingSeconds(args) {
2596
+ const ttl = args.ttlSeconds;
2597
+ const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
2598
+ let elapsed;
2599
+ if (args.nowBootToken === args.anchor.bAnchorToken) {
2600
+ const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
2601
+ elapsed = Math.max(monoDeltaSec, wallDeltaSec);
2602
+ } else {
2603
+ elapsed = wallDeltaSec;
2604
+ }
2605
+ const remaining = Math.min(ttl, ttl - elapsed);
2606
+ return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
2607
+ }
2608
+ var cachedBootToken = null;
2609
+ var MonotonicClock = {
2610
+ nowMs() {
2611
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
2612
+ },
2613
+ nowWallEpochMs() {
2614
+ return Date.now();
2615
+ },
2616
+ bootToken() {
2617
+ if (cachedBootToken !== null) return cachedBootToken;
2618
+ try {
2619
+ const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
2620
+ if (existing) {
2621
+ cachedBootToken = existing;
2622
+ return existing;
2623
+ }
2624
+ const fresh = crypto.randomUUID();
2625
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
2626
+ cachedBootToken = fresh;
2627
+ return fresh;
2628
+ } catch {
2629
+ cachedBootToken = crypto.randomUUID();
2630
+ return cachedBootToken;
2631
+ }
2632
+ }
2633
+ };
2634
+
2594
2635
  // src/messaging/delete-fold.ts
2595
2636
  var DeleteFold = class {
2596
2637
  // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
@@ -2603,17 +2644,24 @@ var DeleteFold = class {
2603
2644
  // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2604
2645
  held = [];
2605
2646
  /**
2606
- * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2607
- * userId (null = target absent locally → defer).
2647
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's author via
2648
+ * {@link AuthorResolution}: `'author'` run the author-gate; `'unknown'` → defer
2649
+ * (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
2650
+ * was TTL-purged — a delete of an already-gone message is already satisfied; never
2651
+ * park in pending, never re-attempt).
2608
2652
  */
2609
2653
  ingest(e, authorOfTarget) {
2610
2654
  if (this.tombstoned.has(e.targetClientMsgId)) return;
2611
2655
  if (this.seen.has(e.eventClientMsgId)) return;
2612
2656
  if (this.heldContains(e.eventClientMsgId)) return;
2613
- const author = authorOfTarget(e.targetClientMsgId);
2614
- if (author !== null) {
2657
+ const res = authorOfTarget(e.targetClientMsgId);
2658
+ if (res.kind === "purged") {
2659
+ this.seen.add(e.eventClientMsgId);
2660
+ return;
2661
+ }
2662
+ if (res.kind === "author") {
2615
2663
  this.seen.add(e.eventClientMsgId);
2616
- if (e.actorUserId === null || e.actorUserId !== author) return;
2664
+ if (e.actorUserId === null || e.actorUserId !== res.userId) return;
2617
2665
  this.tombstoned.add(e.targetClientMsgId);
2618
2666
  } else if (e.actorUserId !== null) {
2619
2667
  this.seen.add(e.eventClientMsgId);
@@ -2633,12 +2681,13 @@ var DeleteFold = class {
2633
2681
  * the in-order path.
2634
2682
  */
2635
2683
  reevaluatePending(target, author) {
2684
+ const res = author;
2636
2685
  const actor = this.pending.get(target);
2637
2686
  if (actor !== void 0) {
2638
- if (author !== null && actor === author) {
2639
- this.tombstoned.add(target);
2687
+ if (res.kind === "author") {
2688
+ if (actor === res.userId) this.tombstoned.add(target);
2640
2689
  this.pending.delete(target);
2641
- } else if (author !== null) {
2690
+ } else if (res.kind === "purged") {
2642
2691
  this.pending.delete(target);
2643
2692
  }
2644
2693
  }
@@ -2646,7 +2695,7 @@ var DeleteFold = class {
2646
2695
  const pendingHeld = this.held;
2647
2696
  this.held = [];
2648
2697
  for (const e of pendingHeld) {
2649
- this.ingest(e, (t) => t === target ? author : null);
2698
+ this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
2650
2699
  }
2651
2700
  }
2652
2701
  heldContains(eventClientMsgId) {
@@ -2672,15 +2721,23 @@ var EditFold = class {
2672
2721
  // targets that have had ≥1 valid edit applied (write-once)
2673
2722
  editedTargets = /* @__PURE__ */ new Set();
2674
2723
  /**
2675
- * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2676
- * (null = target unknown/dangling → HOLD).
2724
+ * Ingest one edit. `authorOfTarget` resolves the target message's author via
2725
+ * {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
2726
+ * (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
2727
+ * editing a disappeared message is a no-op; mark the event seen so it never re-holds
2728
+ * and a later author "resolution" cannot resurrect it).
2677
2729
  */
2678
2730
  ingest(e, authorOfTarget) {
2679
- const author = authorOfTarget(e.targetClientMsgId);
2680
- if (author === null) {
2731
+ const res = authorOfTarget(e.targetClientMsgId);
2732
+ if (res.kind === "unknown") {
2681
2733
  this.holdIfNew(e);
2682
2734
  return;
2683
2735
  }
2736
+ if (res.kind === "purged") {
2737
+ this.seenEvents.add(e.eventClientMsgId);
2738
+ return;
2739
+ }
2740
+ const author = res.userId;
2684
2741
  if (e.editorUserId === null) {
2685
2742
  this.holdIfNew(e);
2686
2743
  return;
@@ -2939,14 +2996,46 @@ function encodeEnvelope(args) {
2939
2996
  length: r.length,
2940
2997
  mentioned_user_id: r.mentionedUserId
2941
2998
  }))
2999
+ } : {},
3000
+ ...args.expiry ? {
3001
+ expiry: {
3002
+ v: args.expiry.v,
3003
+ ttl_seconds: args.expiry.ttlSeconds,
3004
+ start: args.expiry.start,
3005
+ // present IFF send (drop a stray senderSendTs on a read anchor)
3006
+ ...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
3007
+ }
2942
3008
  } : {}
2943
3009
  };
2944
3010
  return encodeUtf8(JSON.stringify(env));
2945
3011
  }
3012
+ function encodeTimerSet(args) {
3013
+ return encodeUtf8(
3014
+ JSON.stringify({
3015
+ v: 1,
3016
+ type: "timer_set",
3017
+ client_msg_id: args.clientMsgId,
3018
+ ...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
3019
+ start: args.start
3020
+ })
3021
+ );
3022
+ }
2946
3023
  function decodeEnvelope(bytes) {
2947
3024
  const s = decodeUtf8(bytes);
2948
3025
  try {
2949
3026
  const o = JSON.parse(s);
3027
+ if (typeof o === "object" && o !== null && o.type === "timer_set") {
3028
+ return {
3029
+ type: "timer_set",
3030
+ text: null,
3031
+ clientMsgId: o.client_msg_id ?? "",
3032
+ replyTo: null,
3033
+ timer: {
3034
+ ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
3035
+ start: o.start === "read" ? "read" : "send"
3036
+ }
3037
+ };
3038
+ }
2950
3039
  if (typeof o === "object" && o !== null && o.type === "delete") {
2951
3040
  return {
2952
3041
  type: "delete",
@@ -2988,12 +3077,14 @@ function decodeEnvelope(bytes) {
2988
3077
  }
2989
3078
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2990
3079
  const textRanges = decodeBodyRanges(o.body_ranges);
3080
+ const expiry = decodeExpiry(o.expiry);
2991
3081
  return {
2992
3082
  type: "text",
2993
3083
  text: o.text ?? null,
2994
3084
  clientMsgId: o.client_msg_id ?? "",
2995
3085
  replyTo: o.reply_to ?? null,
2996
- ...textRanges ? { bodyRanges: textRanges } : {}
3086
+ ...textRanges ? { bodyRanges: textRanges } : {},
3087
+ ...expiry ? { expiry } : {}
2997
3088
  };
2998
3089
  }
2999
3090
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -3005,6 +3096,19 @@ function decodeEnvelope(bytes) {
3005
3096
  }
3006
3097
  return { text: s, clientMsgId: "", replyTo: null };
3007
3098
  }
3099
+ function decodeExpiry(raw) {
3100
+ if (typeof raw !== "object" || raw === null) return void 0;
3101
+ const o = raw;
3102
+ if (typeof o.ttl_seconds !== "number") return void 0;
3103
+ const start = o.start === "read" ? "read" : "send";
3104
+ return {
3105
+ v: typeof o.v === "number" ? o.v : 1,
3106
+ ttlSeconds: o.ttl_seconds,
3107
+ start,
3108
+ // tolerant: only honor sender_send_ts on a send anchor; null otherwise.
3109
+ senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
3110
+ };
3111
+ }
3008
3112
  function decodeBodyRanges(raw) {
3009
3113
  if (!raw || raw.length === 0) return void 0;
3010
3114
  return raw.map((r) => ({
@@ -3224,9 +3328,9 @@ var GroupMessaging = class {
3224
3328
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3225
3329
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3226
3330
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3227
- async sendText(group, text, replyTo, bodyRanges) {
3331
+ async sendText(group, text, replyTo, bodyRanges, expiry) {
3228
3332
  const clientMsgId = mintClientMsgId();
3229
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3333
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
3230
3334
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3231
3335
  const body = {
3232
3336
  ciphertext_b64: toBase64(ct),
@@ -3255,7 +3359,10 @@ var GroupMessaging = class {
3255
3359
  } : null,
3256
3360
  // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3257
3361
  // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3258
- ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3362
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
3363
+ // Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
3364
+ // after a cold launch (the projection derives the deadline from this row's expiry).
3365
+ ...expiry ? { expiry } : {}
3259
3366
  };
3260
3367
  try {
3261
3368
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3263,6 +3370,51 @@ var GroupMessaging = class {
3263
3370
  }
3264
3371
  return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
3265
3372
  }
3373
+ /** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
3374
+ * a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
3375
+ * application path as `sendText` (the server stays blind — it's an opaque app message,
3376
+ * NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
3377
+ * Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
3378
+ * (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
3379
+ async sendTimerSet(group, args) {
3380
+ const plaintext = encodeTimerSet({
3381
+ clientMsgId: args.clientMsgId,
3382
+ ttlSeconds: args.ttlSeconds,
3383
+ start: args.start
3384
+ });
3385
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3386
+ const body = {
3387
+ ciphertext_b64: toBase64(ct),
3388
+ client_idem_key: randomId()
3389
+ };
3390
+ const wire = await palbeRequest(
3391
+ this.rt,
3392
+ "POST",
3393
+ MessagingPaths.groupMessages(group.displayId),
3394
+ { body }
3395
+ );
3396
+ const stored = {
3397
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3398
+ direction: "outgoing",
3399
+ text: null,
3400
+ senderDeviceId: this.selfDeviceId,
3401
+ epoch: wire.epoch,
3402
+ serverSeq: wire.server_seq,
3403
+ at: Date.now(),
3404
+ clientMsgId: args.clientMsgId,
3405
+ replyTo: null,
3406
+ envelopeType: "timer_set",
3407
+ timer: { ttlSeconds: args.ttlSeconds, start: args.start }
3408
+ };
3409
+ try {
3410
+ await this.messageStore.append(group.rfcGroupId, stored);
3411
+ } catch {
3412
+ }
3413
+ return {
3414
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3415
+ clientMsgId: args.clientMsgId
3416
+ };
3417
+ }
3266
3418
  /** Send a reaction (add/remove of an emoji on a target message). Encrypts a
3267
3419
  * `type:'reaction'` envelope at the current epoch and sends through the SAME
3268
3420
  * MLS application path as `sendText` (the server stays blind — a reaction is
@@ -3565,6 +3717,43 @@ var ReactionFold = class {
3565
3717
  }
3566
3718
  };
3567
3719
 
3720
+ // src/messaging/timer-fold.ts
3721
+ function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
3722
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
3723
+ return aSeq <= bSeq;
3724
+ }
3725
+ var TimerFold = class {
3726
+ cell = null;
3727
+ seenEvents = /* @__PURE__ */ new Set();
3728
+ ingest(e) {
3729
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
3730
+ this.seenEvents.add(e.eventClientMsgId);
3731
+ if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
3732
+ return;
3733
+ }
3734
+ this.cell = {
3735
+ orderEpoch: e.epoch,
3736
+ orderSeq: e.serverSeq,
3737
+ ttlSeconds: e.ttlSeconds,
3738
+ start: e.start,
3739
+ actor: e.actorUserId
3740
+ };
3741
+ }
3742
+ /**
3743
+ * The active chat default, or null if no timer_set has applied.
3744
+ * `ttlSeconds === null` means DISABLED (still applied — distinct from "never
3745
+ * set"). `start` is meaningful only when ttlSeconds !== null.
3746
+ */
3747
+ active() {
3748
+ if (this.cell === null) return null;
3749
+ return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
3750
+ }
3751
+ /** The userId behind the winning timer_set (for the optional system line). null if unset. */
3752
+ lastActor() {
3753
+ return this.cell?.actor ?? null;
3754
+ }
3755
+ };
3756
+
3568
3757
  // src/messaging/chat.ts
3569
3758
  var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3570
3759
  var Chat = class {
@@ -3591,6 +3780,22 @@ var Chat = class {
3591
3780
  /** The single authoritative delete-for-everyone fold (live + own-send + history).
3592
3781
  * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3593
3782
  deleteFold = new DeleteFold();
3783
+ /** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
3784
+ * on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
3785
+ * bubble; it routes here. The active default governs a subsequent bubble that carries
3786
+ * no per-message expiry (disappearing T10). */
3787
+ timerFold = new TimerFold();
3788
+ /** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
3789
+ * persisted anchor + a re-check on every load; this just drives live eviction while
3790
+ * the tab is open. Cancelled when the message purges (disappearing T10). */
3791
+ purgeTimers = /* @__PURE__ */ new Map();
3792
+ /** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
3793
+ * hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
3794
+ * by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
3795
+ * `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
3796
+ * tombstone (disappearing T10). */
3797
+ purgedCids = /* @__PURE__ */ new Set();
3798
+ purgedLoaded = false;
3594
3799
  /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3595
3800
  * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3596
3801
  suppressed = /* @__PURE__ */ new Set();
@@ -3701,7 +3906,9 @@ var Chat = class {
3701
3906
  replyTo: null,
3702
3907
  edited: false,
3703
3908
  isDeleted: true,
3704
- mentions: []
3909
+ mentions: [],
3910
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
3911
+ expiresAt: null
3705
3912
  });
3706
3913
  continue;
3707
3914
  }
@@ -3738,9 +3945,21 @@ var Chat = class {
3738
3945
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3739
3946
  void this.loadSuppressed();
3740
3947
  void this.loadElevated();
3741
- void this.hydrateHistory();
3948
+ void this.loadPurged().then(() => this.hydrateHistory());
3742
3949
  void this.refreshMembers();
3743
3950
  }
3951
+ /** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
3952
+ * live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
3953
+ * (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
3954
+ async loadPurged() {
3955
+ if (this.purgedLoaded || !this._group) return;
3956
+ this.purgedLoaded = true;
3957
+ try {
3958
+ const ids = await this.backend.purgedClientMsgIds(this._group);
3959
+ for (const id of ids) this.purgedCids.add(id);
3960
+ } catch {
3961
+ }
3962
+ }
3744
3963
  /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3745
3964
  * any already-surfaced suppressed message is omitted (cold-launch parity). */
3746
3965
  async loadSuppressed() {
@@ -3797,13 +4016,16 @@ var Chat = class {
3797
4016
  if (this.seenKeys.has(key)) continue;
3798
4017
  this.seenKeys.add(key);
3799
4018
  if (m.clientMsgId && !m.isDeleted) {
3800
- this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
4019
+ this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
3801
4020
  }
3802
4021
  this.messageList.push(
3803
4022
  this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3804
4023
  );
3805
4024
  changed = true;
3806
4025
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
4026
+ if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
4027
+ void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
4028
+ }
3807
4029
  }
3808
4030
  if (changed) {
3809
4031
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3818,6 +4040,7 @@ var Chat = class {
3818
4040
  return;
3819
4041
  }
3820
4042
  if (incoming.serverSeq <= 0) return;
4043
+ if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
3821
4044
  const key = this.internalKey(incoming.serverSeq);
3822
4045
  if (this.seenKeys.has(key)) return;
3823
4046
  this.seenKeys.add(key);
@@ -3826,6 +4049,20 @@ var Chat = class {
3826
4049
  senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
3827
4050
  }
3828
4051
  const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
4052
+ if (incoming.envelopeType === "timer_set" && incoming.timer) {
4053
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
4054
+ if (actorUserId !== null) {
4055
+ this.timerFold.ingest({
4056
+ ttlSeconds: incoming.timer.ttlSeconds,
4057
+ start: incoming.timer.start,
4058
+ actorUserId,
4059
+ epoch: incoming.epoch,
4060
+ serverSeq: incoming.serverSeq,
4061
+ eventClientMsgId: incoming.clientMsgId
4062
+ });
4063
+ }
4064
+ return;
4065
+ }
3829
4066
  if (incoming.envelopeType === "reaction" && incoming.reaction) {
3830
4067
  const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3831
4068
  if (actorUserId !== null) {
@@ -3900,7 +4137,10 @@ var Chat = class {
3900
4137
  edited: false,
3901
4138
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3902
4139
  isDeleted: false,
3903
- mentions
4140
+ mentions,
4141
+ // Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
4142
+ // active AS OF arrival). null when this message is non-disappearing.
4143
+ expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
3904
4144
  };
3905
4145
  this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3906
4146
  if (incomingClientMsgId && incoming.text !== null) {
@@ -3912,7 +4152,10 @@ var Chat = class {
3912
4152
  if (incomingClientMsgId) {
3913
4153
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3914
4154
  this.editFold.reevaluateHeld(this.authorOfTarget);
3915
- this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
4155
+ this.deleteFold.reevaluatePending(
4156
+ incomingClientMsgId,
4157
+ this.authorOfTarget(incomingClientMsgId)
4158
+ );
3916
4159
  }
3917
4160
  this.messageList.push(this.applyEditOverlay(msg));
3918
4161
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3921,11 +4164,131 @@ var Chat = class {
3921
4164
  incoming.serverSeq
3922
4165
  );
3923
4166
  this.emit();
4167
+ void this.armPurge(
4168
+ this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
4169
+ incoming.serverSeq,
4170
+ incomingClientMsgId
4171
+ );
4172
+ }
4173
+ // ── Disappearing (TTL — T10) ──
4174
+ /** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
4175
+ * `ExpirySpec` the arm path consumes (or null when absent). */
4176
+ toExpirySpec(e) {
4177
+ return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
4178
+ }
4179
+ /** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
4180
+ * so a bubble with no per-message expiry inherits it. null when no default is active or
4181
+ * the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
4182
+ * the default has no per-message sender clock; the arrival anchor drives the deadline
4183
+ * (mirrors iOS `defaultExpiry()`). */
4184
+ defaultExpiry() {
4185
+ const active = this.timerFold.active();
4186
+ if (!active || active.ttlSeconds === null) return null;
4187
+ return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
4188
+ }
4189
+ /** The surfaced display deadline for an effective expiry (a local, monotonic-derived
4190
+ * value — the wall-clock projection of the TTL from now). null for a non-disappearing
4191
+ * message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
4192
+ * UI countdown baseline. */
4193
+ deadlineFor(expiry) {
4194
+ if (!expiry) return null;
4195
+ return new Date(Date.now() + expiry.ttlSeconds * 1e3);
4196
+ }
4197
+ /** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
4198
+ * monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
4199
+ * reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
4200
+ * computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
4201
+ * `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
4202
+ * capture), then either purges immediately or schedules an advisory `setTimeout`. A null
4203
+ * expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
4204
+ async armPurge(expiry, serverSeq, clientMsgId) {
4205
+ if (!expiry || !clientMsgId || !this._group) return;
4206
+ const group = this._group;
4207
+ const fresh = {
4208
+ mAnchorMs: MonotonicClock.nowMs(),
4209
+ wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
4210
+ bAnchorToken: MonotonicClock.bootToken()
4211
+ };
4212
+ await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
4213
+ const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
4214
+ const result = remainingSeconds({
4215
+ ttlSeconds: expiry.ttlSeconds,
4216
+ anchor: effective,
4217
+ nowMonotonicMs: MonotonicClock.nowMs(),
4218
+ nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
4219
+ nowBootToken: MonotonicClock.bootToken()
4220
+ });
4221
+ let purgeInSeconds;
4222
+ if (result.kind === "purgeNow") {
4223
+ purgeInSeconds = 0;
4224
+ } else if (expiry.start === "send" && expiry.senderSendTs !== null) {
4225
+ const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
4226
+ purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
4227
+ } else {
4228
+ purgeInSeconds = result.seconds;
4229
+ }
4230
+ const prior = this.purgeTimers.get(serverSeq);
4231
+ if (prior) clearTimeout(prior);
4232
+ this.purgeTimers.delete(serverSeq);
4233
+ if (purgeInSeconds <= 0) {
4234
+ await this.purge(serverSeq, clientMsgId);
4235
+ return;
4236
+ }
4237
+ const handle = setTimeout(() => {
4238
+ void this.purge(serverSeq, clientMsgId);
4239
+ }, purgeInSeconds * 1e3);
4240
+ this.purgeTimers.set(serverSeq, handle);
3924
4241
  }
3925
- /** The EditFold author-gate input: the target message's resolved author userId
3926
- * (null = target unknown/dangling the fold HOLDs). Captured as a bound arrow
3927
- * so it can be passed to the pure EditFold. */
3928
- authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
4242
+ /** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
4243
+ * the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
4244
+ * remaining time (purge immediately if the deadline has already passed). The durable
4245
+ * tombstone is written by `purge` when it fires (the crash-safe commit point). */
4246
+ async armFromDeadline(deadline, serverSeq, clientMsgId) {
4247
+ if (!this._group) return;
4248
+ const remainingMs = deadline.getTime() - Date.now();
4249
+ const prior = this.purgeTimers.get(serverSeq);
4250
+ if (prior) clearTimeout(prior);
4251
+ this.purgeTimers.delete(serverSeq);
4252
+ if (remainingMs <= 0) {
4253
+ await this.purge(serverSeq, clientMsgId);
4254
+ return;
4255
+ }
4256
+ const handle = setTimeout(() => {
4257
+ void this.purge(serverSeq, clientMsgId);
4258
+ }, remainingMs);
4259
+ this.purgeTimers.set(serverSeq, handle);
4260
+ }
4261
+ /** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
4262
+ * persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
4263
+ * record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
4264
+ * edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
4265
+ * (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
4266
+ async purge(serverSeq, clientMsgId) {
4267
+ if (!this._group) return;
4268
+ const prior = this.purgeTimers.get(serverSeq);
4269
+ if (prior) clearTimeout(prior);
4270
+ this.purgeTimers.delete(serverSeq);
4271
+ await this.backend.tombstone(this._group, serverSeq, clientMsgId);
4272
+ if (clientMsgId) this.purgedCids.add(clientMsgId);
4273
+ this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
4274
+ this.seenKeys.delete(this.internalKey(serverSeq));
4275
+ this.emit();
4276
+ this.editFold.reevaluateHeld(this.authorOfTarget);
4277
+ if (clientMsgId) {
4278
+ this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
4279
+ }
4280
+ }
4281
+ /** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
4282
+ * T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
4283
+ * durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
4284
+ * disappeared message); `'author'` when its author is locally known → run the
4285
+ * author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
4286
+ * resolver. Captured as a bound arrow so it can be passed to the pure folds. */
4287
+ authorOfTarget = (targetClientMsgId) => {
4288
+ if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
4289
+ const a = this.authorByClientMsgId.get(targetClientMsgId);
4290
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
4291
+ };
3929
4292
  // ── Mentions (mentions T6) ──
3930
4293
  /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3931
4294
  * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
@@ -4211,10 +4574,48 @@ var Chat = class {
4211
4574
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4212
4575
  }
4213
4576
  const bodyRanges = opts?.mentions ?? null;
4214
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4577
+ const start = opts?.expiresIn?.start ?? "send";
4578
+ const expiry = opts?.expiresIn ? {
4579
+ v: 1,
4580
+ ttlSeconds: opts.expiresIn.ttlSeconds,
4581
+ start,
4582
+ senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
4583
+ } : null;
4584
+ const { receipt, clientMsgId } = await this.backend.sendText(
4585
+ group,
4586
+ text,
4587
+ replyRef,
4588
+ bodyRanges,
4589
+ expiry
4590
+ );
4215
4591
  this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
4592
+ if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
4216
4593
  return receipt;
4217
4594
  }
4595
+ /** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
4596
+ * envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
4597
+ * own-set locally so the default applies immediately to subsequent sends that carry no
4598
+ * per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
4599
+ * ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
4600
+ * Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
4601
+ async setDisappearing(opts) {
4602
+ const group = await this.materializeIfNeeded();
4603
+ const clientMsgId = mintClientMsgId();
4604
+ const start = opts.start ?? "send";
4605
+ const { receipt } = await this.backend.sendTimerSet(group, {
4606
+ clientMsgId,
4607
+ ttlSeconds: opts.ttlSeconds,
4608
+ start
4609
+ });
4610
+ this.timerFold.ingest({
4611
+ ttlSeconds: opts.ttlSeconds,
4612
+ start,
4613
+ actorUserId: this.backend.selfUserId,
4614
+ epoch: receipt.epoch,
4615
+ serverSeq: receipt.serverSeq,
4616
+ eventClientMsgId: clientMsgId
4617
+ });
4618
+ }
4218
4619
  appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4219
4620
  if (receipt.serverSeq <= 0) return;
4220
4621
  const key = this.internalKey(receipt.serverSeq);
@@ -4243,7 +4644,10 @@ var Chat = class {
4243
4644
  isDeleted: false,
4244
4645
  // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4245
4646
  // sender never gets a wire echo of its own message — this is the only local copy).
4246
- mentions: this.resolveMentions(text, bodyRanges)
4647
+ mentions: this.resolveMentions(text, bodyRanges),
4648
+ // Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
4649
+ // via the messageList overlay; default null here (a plain own-send has no deadline).
4650
+ expiresAt: null
4247
4651
  });
4248
4652
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4249
4653
  this.emit();
@@ -4598,6 +5002,7 @@ var MessageDeliverySource = class {
4598
5002
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4599
5003
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4600
5004
  const isDelete = decoded.type === "delete" && decoded.delete != null;
5005
+ const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
4601
5006
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4602
5007
  const stored = {
4603
5008
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4655,7 +5060,18 @@ var MessageDeliverySource = class {
4655
5060
  targetClientMsgId: decoded.delete.targetClientMsgId,
4656
5061
  scope: decoded.delete.scope
4657
5062
  }
4658
- } : {}
5063
+ } : {},
5064
+ // Disappearing T10: thread the timer_set discriminator + payload through the
5065
+ // persisted row so the chat default re-folds on cold launch (the page-local
5066
+ // TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
5067
+ ...isTimerSet && decoded.timer ? {
5068
+ envelopeType: "timer_set",
5069
+ timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
5070
+ } : {},
5071
+ // Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
5072
+ // row so the message re-arms its purge on cold launch (the projection derives the
5073
+ // deadline from this). Only on a text bubble; omitted when absent (backward-compat).
5074
+ ...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
4659
5075
  };
4660
5076
  try {
4661
5077
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -4678,7 +5094,12 @@ var MessageDeliverySource = class {
4678
5094
  delete: isDelete ? decoded.delete : null,
4679
5095
  // The raw mention ranges (text bubble or the edit's replacement ranges); the
4680
5096
  // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4681
- bodyRanges: decoded.bodyRanges ?? null
5097
+ bodyRanges: decoded.bodyRanges ?? null,
5098
+ // Disappearing T10: the decoded timer_set payload (chat default control) + a text
5099
+ // bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
5100
+ // arms a bubble's purge from the expiry (or the active default).
5101
+ timer: isTimerSet ? decoded.timer : null,
5102
+ expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
4682
5103
  });
4683
5104
  return true;
4684
5105
  }
@@ -4757,6 +5178,67 @@ function isOwnEchoOrConsumed(e) {
4757
5178
  return msg.includes("message from self") || msg.includes("key not available, invalid generation");
4758
5179
  }
4759
5180
 
5181
+ // src/messaging/disappearing.ts
5182
+ var DisappearingStore = class {
5183
+ constructor(kv) {
5184
+ this.kv = kv;
5185
+ }
5186
+ kv;
5187
+ key(rfc) {
5188
+ return `disappear:${rfc}`;
5189
+ }
5190
+ async load(rfc) {
5191
+ const raw = await this.kv.get(this.key(rfc));
5192
+ if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5193
+ try {
5194
+ const r = JSON.parse(decodeUtf8(raw));
5195
+ return {
5196
+ tombstonedSeqs: r.tombstonedSeqs ?? [],
5197
+ purgedClientMsgIds: r.purgedClientMsgIds ?? [],
5198
+ anchors: r.anchors ?? {}
5199
+ };
5200
+ } catch {
5201
+ return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5202
+ }
5203
+ }
5204
+ async save(rfc, rec) {
5205
+ await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
5206
+ }
5207
+ /** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
5208
+ async tombstonedSeqs(rfc) {
5209
+ return new Set((await this.load(rfc)).tombstonedSeqs);
5210
+ }
5211
+ /** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
5212
+ async purgedClientMsgIds(rfc) {
5213
+ return new Set((await this.load(rfc)).purgedClientMsgIds);
5214
+ }
5215
+ /**
5216
+ * Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
5217
+ * written together in ONE durable record. Idempotent (re-tombstoning the same seq /
5218
+ * id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
5219
+ * from the store excludes the seq and a redelivery is dropped, even across a crash.
5220
+ */
5221
+ async tombstone(rfc, serverSeq, clientMsgId) {
5222
+ const rec = await this.load(rfc);
5223
+ if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
5224
+ if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
5225
+ rec.purgedClientMsgIds.push(clientMsgId);
5226
+ }
5227
+ await this.save(rfc, rec);
5228
+ }
5229
+ /** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
5230
+ async anchor(rfc, clientMsgId) {
5231
+ return (await this.load(rfc)).anchors[clientMsgId] ?? null;
5232
+ }
5233
+ /** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
5234
+ async writeAnchorOnce(rfc, clientMsgId, a) {
5235
+ const rec = await this.load(rfc);
5236
+ if (rec.anchors[clientMsgId]) return;
5237
+ rec.anchors[clientMsgId] = a;
5238
+ await this.save(rfc, rec);
5239
+ }
5240
+ };
5241
+
4760
5242
  // src/messaging/history.ts
4761
5243
  var MessageStore = class {
4762
5244
  constructor(kv) {
@@ -6570,6 +7052,7 @@ var MessagingCoordinator = class {
6570
7052
  this.kpStore = new KeyPackageStorage(this.kv);
6571
7053
  this.suppressionStore = new SuppressionStore(this.kv);
6572
7054
  this.elevationStore = new MentionElevationStore(this.kv);
7055
+ this.disappearingStore = new DisappearingStore(this.kv);
6573
7056
  this.registry.attachChatList(
6574
7057
  (chats) => {
6575
7058
  this.chatList = chats;
@@ -6586,6 +7069,7 @@ var MessagingCoordinator = class {
6586
7069
  kpStore;
6587
7070
  suppressionStore;
6588
7071
  elevationStore;
7072
+ disappearingStore;
6589
7073
  registry = new GroupRegistry();
6590
7074
  resolved = null;
6591
7075
  resolvePromise = null;
@@ -6757,6 +7241,10 @@ var MessagingCoordinator = class {
6757
7241
  const r = await this.resolve();
6758
7242
  return r.groups.sendDelete(group, args);
6759
7243
  }
7244
+ async sendTimerSet(group, args) {
7245
+ const r = await this.resolve();
7246
+ return r.groups.sendTimerSet(group, args);
7247
+ }
6760
7248
  /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6761
7249
  loadSuppressed(group) {
6762
7250
  return this.suppressionStore.load(group.rfcGroupId);
@@ -6773,10 +7261,28 @@ var MessagingCoordinator = class {
6773
7261
  saveElevated(group, keys) {
6774
7262
  return this.elevationStore.save(group.rfcGroupId, keys);
6775
7263
  }
7264
+ // ── Disappearing / TTL seam (durable-only, no wire) ──
7265
+ tombstonedSeqs(group) {
7266
+ return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7267
+ }
7268
+ purgedClientMsgIds(group) {
7269
+ return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7270
+ }
7271
+ anchor(group, clientMsgId) {
7272
+ return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
7273
+ }
7274
+ writeAnchorOnce(group, clientMsgId, a) {
7275
+ return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
7276
+ }
7277
+ tombstone(group, serverSeq, clientMsgId) {
7278
+ return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
7279
+ }
6776
7280
  async history(group, limit, before) {
6777
7281
  const r = await this.resolve();
6778
7282
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
6779
- return projectHistory(group.displayId, rows, this.selfUserId);
7283
+ const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7284
+ const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7285
+ return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
6780
7286
  }
6781
7287
  async members(group) {
6782
7288
  const r = await this.resolve();
@@ -6853,9 +7359,10 @@ var MessagingCoordinator = class {
6853
7359
  return res.devices.map((d) => d.device_id);
6854
7360
  }
6855
7361
  };
6856
- function projectHistory(displayId, rows, selfUserId, resolveActor) {
7362
+ function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
7363
+ const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
6857
7364
  const fold = new ReactionFold();
6858
- for (const s of rows) {
7365
+ for (const s of visible) {
6859
7366
  if (s.envelopeType !== "reaction" || !s.reaction) continue;
6860
7367
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6861
7368
  if (actor === null) continue;
@@ -6871,17 +7378,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6871
7378
  }
6872
7379
  const editFold = new EditFold();
6873
7380
  const deleteFold = new DeleteFold();
7381
+ const pageTimerFold = new TimerFold();
6874
7382
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6875
- for (const s of rows) {
6876
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7383
+ for (const s of visible) {
7384
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6877
7385
  continue;
6878
7386
  const cid = s.clientMsgId ?? "";
6879
7387
  if (!cid) continue;
6880
7388
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6881
7389
  if (author != null) authorByClientMsgId.set(cid, author);
6882
7390
  }
6883
- const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6884
- for (const s of rows) {
7391
+ const authorOfTarget = (cid) => {
7392
+ if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
7393
+ const a = authorByClientMsgId.get(cid);
7394
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
7395
+ };
7396
+ for (const s of visible) {
6885
7397
  if (s.envelopeType !== "edit" || !s.edit) continue;
6886
7398
  const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6887
7399
  editFold.ingest(
@@ -6900,7 +7412,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6900
7412
  );
6901
7413
  }
6902
7414
  editFold.reevaluateHeld(authorOfTarget);
6903
- for (const s of rows) {
7415
+ for (const s of visible) {
6904
7416
  if (s.envelopeType !== "delete" || !s.delete) continue;
6905
7417
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6906
7418
  deleteFold.ingest(
@@ -6914,10 +7426,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6914
7426
  authorOfTarget
6915
7427
  );
6916
7428
  }
6917
- for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
7429
+ for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
6918
7430
  const lookup = /* @__PURE__ */ new Map();
6919
- for (const s of rows) {
6920
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7431
+ for (const s of visible) {
7432
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6921
7433
  continue;
6922
7434
  const cid = s.clientMsgId ?? "";
6923
7435
  if (cid && s.text !== null) {
@@ -6926,7 +7438,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6926
7438
  }
6927
7439
  }
6928
7440
  const out = [];
6929
- for (const s of rows) {
7441
+ for (const s of visible) {
7442
+ if (s.envelopeType === "timer_set") {
7443
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
7444
+ if (actor !== null && s.timer) {
7445
+ pageTimerFold.ingest({
7446
+ ttlSeconds: s.timer.ttlSeconds,
7447
+ start: s.timer.start,
7448
+ actorUserId: actor,
7449
+ epoch: s.epoch,
7450
+ serverSeq: s.serverSeq,
7451
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
7452
+ });
7453
+ }
7454
+ continue;
7455
+ }
6930
7456
  if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6931
7457
  continue;
6932
7458
  const clientMsgId = s.clientMsgId ?? "";
@@ -6946,10 +7472,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6946
7472
  edited: false,
6947
7473
  isDeleted: true,
6948
7474
  // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6949
- mentions: []
7475
+ mentions: [],
7476
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
7477
+ expiresAt: null
6950
7478
  });
6951
7479
  continue;
6952
7480
  }
7481
+ let expiresAt = null;
7482
+ if (s.expiry) {
7483
+ expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
7484
+ } else {
7485
+ const active = pageTimerFold.active();
7486
+ if (active && active.ttlSeconds !== null) {
7487
+ expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
7488
+ }
7489
+ }
6953
7490
  let replyTo = null;
6954
7491
  if (s.replyTo) {
6955
7492
  const ref = {
@@ -6982,7 +7519,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6982
7519
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6983
7520
  edited,
6984
7521
  isDeleted: false,
6985
- mentions
7522
+ mentions,
7523
+ expiresAt
6986
7524
  });
6987
7525
  }
6988
7526
  return out;
@@ -7729,7 +8267,7 @@ function defaultSessionStorage(key) {
7729
8267
  }
7730
8268
 
7731
8269
  // src/version.ts
7732
- var VERSION = "1.5.0";
8270
+ var VERSION = "1.6.0";
7733
8271
 
7734
8272
  // src/runtime.ts
7735
8273
  function buildRuntime(config) {
@@ -8089,4 +8627,4 @@ export {
8089
8627
  pb,
8090
8628
  createBoundClient
8091
8629
  };
8092
- //# sourceMappingURL=chunk-MBA2NAKS.js.map
8630
+ //# sourceMappingURL=chunk-A5WIPBGQ.js.map