@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.
@@ -2628,6 +2628,47 @@ var PalbeFlags = class {
2628
2628
  }
2629
2629
  };
2630
2630
 
2631
+ // src/messaging/deadline-calculator.ts
2632
+ function remainingSeconds(args) {
2633
+ const ttl = args.ttlSeconds;
2634
+ const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
2635
+ let elapsed;
2636
+ if (args.nowBootToken === args.anchor.bAnchorToken) {
2637
+ const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
2638
+ elapsed = Math.max(monoDeltaSec, wallDeltaSec);
2639
+ } else {
2640
+ elapsed = wallDeltaSec;
2641
+ }
2642
+ const remaining = Math.min(ttl, ttl - elapsed);
2643
+ return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
2644
+ }
2645
+ var cachedBootToken = null;
2646
+ var MonotonicClock = {
2647
+ nowMs() {
2648
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
2649
+ },
2650
+ nowWallEpochMs() {
2651
+ return Date.now();
2652
+ },
2653
+ bootToken() {
2654
+ if (cachedBootToken !== null) return cachedBootToken;
2655
+ try {
2656
+ const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
2657
+ if (existing) {
2658
+ cachedBootToken = existing;
2659
+ return existing;
2660
+ }
2661
+ const fresh = crypto.randomUUID();
2662
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
2663
+ cachedBootToken = fresh;
2664
+ return fresh;
2665
+ } catch {
2666
+ cachedBootToken = crypto.randomUUID();
2667
+ return cachedBootToken;
2668
+ }
2669
+ }
2670
+ };
2671
+
2631
2672
  // src/messaging/delete-fold.ts
2632
2673
  var DeleteFold = class {
2633
2674
  // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
@@ -2640,17 +2681,24 @@ var DeleteFold = class {
2640
2681
  // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
2641
2682
  held = [];
2642
2683
  /**
2643
- * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
2644
- * userId (null = target absent locally → defer).
2684
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's author via
2685
+ * {@link AuthorResolution}: `'author'` run the author-gate; `'unknown'` → defer
2686
+ * (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
2687
+ * was TTL-purged — a delete of an already-gone message is already satisfied; never
2688
+ * park in pending, never re-attempt).
2645
2689
  */
2646
2690
  ingest(e, authorOfTarget) {
2647
2691
  if (this.tombstoned.has(e.targetClientMsgId)) return;
2648
2692
  if (this.seen.has(e.eventClientMsgId)) return;
2649
2693
  if (this.heldContains(e.eventClientMsgId)) return;
2650
- const author = authorOfTarget(e.targetClientMsgId);
2651
- if (author !== null) {
2694
+ const res = authorOfTarget(e.targetClientMsgId);
2695
+ if (res.kind === "purged") {
2696
+ this.seen.add(e.eventClientMsgId);
2697
+ return;
2698
+ }
2699
+ if (res.kind === "author") {
2652
2700
  this.seen.add(e.eventClientMsgId);
2653
- if (e.actorUserId === null || e.actorUserId !== author) return;
2701
+ if (e.actorUserId === null || e.actorUserId !== res.userId) return;
2654
2702
  this.tombstoned.add(e.targetClientMsgId);
2655
2703
  } else if (e.actorUserId !== null) {
2656
2704
  this.seen.add(e.eventClientMsgId);
@@ -2670,12 +2718,13 @@ var DeleteFold = class {
2670
2718
  * the in-order path.
2671
2719
  */
2672
2720
  reevaluatePending(target, author) {
2721
+ const res = author;
2673
2722
  const actor = this.pending.get(target);
2674
2723
  if (actor !== void 0) {
2675
- if (author !== null && actor === author) {
2676
- this.tombstoned.add(target);
2724
+ if (res.kind === "author") {
2725
+ if (actor === res.userId) this.tombstoned.add(target);
2677
2726
  this.pending.delete(target);
2678
- } else if (author !== null) {
2727
+ } else if (res.kind === "purged") {
2679
2728
  this.pending.delete(target);
2680
2729
  }
2681
2730
  }
@@ -2683,7 +2732,7 @@ var DeleteFold = class {
2683
2732
  const pendingHeld = this.held;
2684
2733
  this.held = [];
2685
2734
  for (const e of pendingHeld) {
2686
- this.ingest(e, (t) => t === target ? author : null);
2735
+ this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
2687
2736
  }
2688
2737
  }
2689
2738
  heldContains(eventClientMsgId) {
@@ -2709,15 +2758,23 @@ var EditFold = class {
2709
2758
  // targets that have had ≥1 valid edit applied (write-once)
2710
2759
  editedTargets = /* @__PURE__ */ new Set();
2711
2760
  /**
2712
- * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
2713
- * (null = target unknown/dangling → HOLD).
2761
+ * Ingest one edit. `authorOfTarget` resolves the target message's author via
2762
+ * {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
2763
+ * (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
2764
+ * editing a disappeared message is a no-op; mark the event seen so it never re-holds
2765
+ * and a later author "resolution" cannot resurrect it).
2714
2766
  */
2715
2767
  ingest(e, authorOfTarget) {
2716
- const author = authorOfTarget(e.targetClientMsgId);
2717
- if (author === null) {
2768
+ const res = authorOfTarget(e.targetClientMsgId);
2769
+ if (res.kind === "unknown") {
2718
2770
  this.holdIfNew(e);
2719
2771
  return;
2720
2772
  }
2773
+ if (res.kind === "purged") {
2774
+ this.seenEvents.add(e.eventClientMsgId);
2775
+ return;
2776
+ }
2777
+ const author = res.userId;
2721
2778
  if (e.editorUserId === null) {
2722
2779
  this.holdIfNew(e);
2723
2780
  return;
@@ -2976,14 +3033,46 @@ function encodeEnvelope(args) {
2976
3033
  length: r.length,
2977
3034
  mentioned_user_id: r.mentionedUserId
2978
3035
  }))
3036
+ } : {},
3037
+ ...args.expiry ? {
3038
+ expiry: {
3039
+ v: args.expiry.v,
3040
+ ttl_seconds: args.expiry.ttlSeconds,
3041
+ start: args.expiry.start,
3042
+ // present IFF send (drop a stray senderSendTs on a read anchor)
3043
+ ...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
3044
+ }
2979
3045
  } : {}
2980
3046
  };
2981
3047
  return encodeUtf8(JSON.stringify(env));
2982
3048
  }
3049
+ function encodeTimerSet(args) {
3050
+ return encodeUtf8(
3051
+ JSON.stringify({
3052
+ v: 1,
3053
+ type: "timer_set",
3054
+ client_msg_id: args.clientMsgId,
3055
+ ...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
3056
+ start: args.start
3057
+ })
3058
+ );
3059
+ }
2983
3060
  function decodeEnvelope(bytes) {
2984
3061
  const s = decodeUtf8(bytes);
2985
3062
  try {
2986
3063
  const o = JSON.parse(s);
3064
+ if (typeof o === "object" && o !== null && o.type === "timer_set") {
3065
+ return {
3066
+ type: "timer_set",
3067
+ text: null,
3068
+ clientMsgId: o.client_msg_id ?? "",
3069
+ replyTo: null,
3070
+ timer: {
3071
+ ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
3072
+ start: o.start === "read" ? "read" : "send"
3073
+ }
3074
+ };
3075
+ }
2987
3076
  if (typeof o === "object" && o !== null && o.type === "delete") {
2988
3077
  return {
2989
3078
  type: "delete",
@@ -3025,12 +3114,14 @@ function decodeEnvelope(bytes) {
3025
3114
  }
3026
3115
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
3027
3116
  const textRanges = decodeBodyRanges(o.body_ranges);
3117
+ const expiry = decodeExpiry(o.expiry);
3028
3118
  return {
3029
3119
  type: "text",
3030
3120
  text: o.text ?? null,
3031
3121
  clientMsgId: o.client_msg_id ?? "",
3032
3122
  replyTo: o.reply_to ?? null,
3033
- ...textRanges ? { bodyRanges: textRanges } : {}
3123
+ ...textRanges ? { bodyRanges: textRanges } : {},
3124
+ ...expiry ? { expiry } : {}
3034
3125
  };
3035
3126
  }
3036
3127
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -3042,6 +3133,19 @@ function decodeEnvelope(bytes) {
3042
3133
  }
3043
3134
  return { text: s, clientMsgId: "", replyTo: null };
3044
3135
  }
3136
+ function decodeExpiry(raw) {
3137
+ if (typeof raw !== "object" || raw === null) return void 0;
3138
+ const o = raw;
3139
+ if (typeof o.ttl_seconds !== "number") return void 0;
3140
+ const start = o.start === "read" ? "read" : "send";
3141
+ return {
3142
+ v: typeof o.v === "number" ? o.v : 1,
3143
+ ttlSeconds: o.ttl_seconds,
3144
+ start,
3145
+ // tolerant: only honor sender_send_ts on a send anchor; null otherwise.
3146
+ senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
3147
+ };
3148
+ }
3045
3149
  function decodeBodyRanges(raw) {
3046
3150
  if (!raw || raw.length === 0) return void 0;
3047
3151
  return raw.map((r) => ({
@@ -3261,9 +3365,9 @@ var GroupMessaging = class {
3261
3365
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
3262
3366
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
3263
3367
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
3264
- async sendText(group, text, replyTo, bodyRanges) {
3368
+ async sendText(group, text, replyTo, bodyRanges, expiry) {
3265
3369
  const clientMsgId = mintClientMsgId();
3266
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
3370
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
3267
3371
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3268
3372
  const body = {
3269
3373
  ciphertext_b64: toBase64(ct),
@@ -3292,7 +3396,10 @@ var GroupMessaging = class {
3292
3396
  } : null,
3293
3397
  // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
3294
3398
  // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
3295
- ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
3399
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
3400
+ // Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
3401
+ // after a cold launch (the projection derives the deadline from this row's expiry).
3402
+ ...expiry ? { expiry } : {}
3296
3403
  };
3297
3404
  try {
3298
3405
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3300,6 +3407,51 @@ var GroupMessaging = class {
3300
3407
  }
3301
3408
  return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
3302
3409
  }
3410
+ /** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
3411
+ * a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
3412
+ * application path as `sendText` (the server stays blind — it's an opaque app message,
3413
+ * NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
3414
+ * Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
3415
+ * (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
3416
+ async sendTimerSet(group, args) {
3417
+ const plaintext = encodeTimerSet({
3418
+ clientMsgId: args.clientMsgId,
3419
+ ttlSeconds: args.ttlSeconds,
3420
+ start: args.start
3421
+ });
3422
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3423
+ const body = {
3424
+ ciphertext_b64: toBase64(ct),
3425
+ client_idem_key: randomId()
3426
+ };
3427
+ const wire = await palbeRequest(
3428
+ this.rt,
3429
+ "POST",
3430
+ MessagingPaths.groupMessages(group.displayId),
3431
+ { body }
3432
+ );
3433
+ const stored = {
3434
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3435
+ direction: "outgoing",
3436
+ text: null,
3437
+ senderDeviceId: this.selfDeviceId,
3438
+ epoch: wire.epoch,
3439
+ serverSeq: wire.server_seq,
3440
+ at: Date.now(),
3441
+ clientMsgId: args.clientMsgId,
3442
+ replyTo: null,
3443
+ envelopeType: "timer_set",
3444
+ timer: { ttlSeconds: args.ttlSeconds, start: args.start }
3445
+ };
3446
+ try {
3447
+ await this.messageStore.append(group.rfcGroupId, stored);
3448
+ } catch {
3449
+ }
3450
+ return {
3451
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3452
+ clientMsgId: args.clientMsgId
3453
+ };
3454
+ }
3303
3455
  /** Send a reaction (add/remove of an emoji on a target message). Encrypts a
3304
3456
  * `type:'reaction'` envelope at the current epoch and sends through the SAME
3305
3457
  * MLS application path as `sendText` (the server stays blind — a reaction is
@@ -3602,6 +3754,43 @@ var ReactionFold = class {
3602
3754
  }
3603
3755
  };
3604
3756
 
3757
+ // src/messaging/timer-fold.ts
3758
+ function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
3759
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
3760
+ return aSeq <= bSeq;
3761
+ }
3762
+ var TimerFold = class {
3763
+ cell = null;
3764
+ seenEvents = /* @__PURE__ */ new Set();
3765
+ ingest(e) {
3766
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
3767
+ this.seenEvents.add(e.eventClientMsgId);
3768
+ if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
3769
+ return;
3770
+ }
3771
+ this.cell = {
3772
+ orderEpoch: e.epoch,
3773
+ orderSeq: e.serverSeq,
3774
+ ttlSeconds: e.ttlSeconds,
3775
+ start: e.start,
3776
+ actor: e.actorUserId
3777
+ };
3778
+ }
3779
+ /**
3780
+ * The active chat default, or null if no timer_set has applied.
3781
+ * `ttlSeconds === null` means DISABLED (still applied — distinct from "never
3782
+ * set"). `start` is meaningful only when ttlSeconds !== null.
3783
+ */
3784
+ active() {
3785
+ if (this.cell === null) return null;
3786
+ return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
3787
+ }
3788
+ /** The userId behind the winning timer_set (for the optional system line). null if unset. */
3789
+ lastActor() {
3790
+ return this.cell?.actor ?? null;
3791
+ }
3792
+ };
3793
+
3605
3794
  // src/messaging/chat.ts
3606
3795
  var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
3607
3796
  var Chat = class {
@@ -3628,6 +3817,22 @@ var Chat = class {
3628
3817
  /** The single authoritative delete-for-everyone fold (live + own-send + history).
3629
3818
  * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
3630
3819
  deleteFold = new DeleteFold();
3820
+ /** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
3821
+ * on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
3822
+ * bubble; it routes here. The active default governs a subsequent bubble that carries
3823
+ * no per-message expiry (disappearing T10). */
3824
+ timerFold = new TimerFold();
3825
+ /** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
3826
+ * persisted anchor + a re-check on every load; this just drives live eviction while
3827
+ * the tab is open. Cancelled when the message purges (disappearing T10). */
3828
+ purgeTimers = /* @__PURE__ */ new Map();
3829
+ /** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
3830
+ * hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
3831
+ * by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
3832
+ * `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
3833
+ * tombstone (disappearing T10). */
3834
+ purgedCids = /* @__PURE__ */ new Set();
3835
+ purgedLoaded = false;
3631
3836
  /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
3632
3837
  * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
3633
3838
  suppressed = /* @__PURE__ */ new Set();
@@ -3738,7 +3943,9 @@ var Chat = class {
3738
3943
  replyTo: null,
3739
3944
  edited: false,
3740
3945
  isDeleted: true,
3741
- mentions: []
3946
+ mentions: [],
3947
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
3948
+ expiresAt: null
3742
3949
  });
3743
3950
  continue;
3744
3951
  }
@@ -3775,9 +3982,21 @@ var Chat = class {
3775
3982
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
3776
3983
  void this.loadSuppressed();
3777
3984
  void this.loadElevated();
3778
- void this.hydrateHistory();
3985
+ void this.loadPurged().then(() => this.hydrateHistory());
3779
3986
  void this.refreshMembers();
3780
3987
  }
3988
+ /** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
3989
+ * live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
3990
+ * (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
3991
+ async loadPurged() {
3992
+ if (this.purgedLoaded || !this._group) return;
3993
+ this.purgedLoaded = true;
3994
+ try {
3995
+ const ids = await this.backend.purgedClientMsgIds(this._group);
3996
+ for (const id of ids) this.purgedCids.add(id);
3997
+ } catch {
3998
+ }
3999
+ }
3781
4000
  /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
3782
4001
  * any already-surfaced suppressed message is omitted (cold-launch parity). */
3783
4002
  async loadSuppressed() {
@@ -3834,13 +4053,16 @@ var Chat = class {
3834
4053
  if (this.seenKeys.has(key)) continue;
3835
4054
  this.seenKeys.add(key);
3836
4055
  if (m.clientMsgId && !m.isDeleted) {
3837
- this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
4056
+ this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
3838
4057
  }
3839
4058
  this.messageList.push(
3840
4059
  this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
3841
4060
  );
3842
4061
  changed = true;
3843
4062
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
4063
+ if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
4064
+ void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
4065
+ }
3844
4066
  }
3845
4067
  if (changed) {
3846
4068
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3855,6 +4077,7 @@ var Chat = class {
3855
4077
  return;
3856
4078
  }
3857
4079
  if (incoming.serverSeq <= 0) return;
4080
+ if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
3858
4081
  const key = this.internalKey(incoming.serverSeq);
3859
4082
  if (this.seenKeys.has(key)) return;
3860
4083
  this.seenKeys.add(key);
@@ -3863,6 +4086,20 @@ var Chat = class {
3863
4086
  senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
3864
4087
  }
3865
4088
  const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
4089
+ if (incoming.envelopeType === "timer_set" && incoming.timer) {
4090
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
4091
+ if (actorUserId !== null) {
4092
+ this.timerFold.ingest({
4093
+ ttlSeconds: incoming.timer.ttlSeconds,
4094
+ start: incoming.timer.start,
4095
+ actorUserId,
4096
+ epoch: incoming.epoch,
4097
+ serverSeq: incoming.serverSeq,
4098
+ eventClientMsgId: incoming.clientMsgId
4099
+ });
4100
+ }
4101
+ return;
4102
+ }
3866
4103
  if (incoming.envelopeType === "reaction" && incoming.reaction) {
3867
4104
  const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3868
4105
  if (actorUserId !== null) {
@@ -3937,7 +4174,10 @@ var Chat = class {
3937
4174
  edited: false,
3938
4175
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
3939
4176
  isDeleted: false,
3940
- mentions
4177
+ mentions,
4178
+ // Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
4179
+ // active AS OF arrival). null when this message is non-disappearing.
4180
+ expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
3941
4181
  };
3942
4182
  this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
3943
4183
  if (incomingClientMsgId && incoming.text !== null) {
@@ -3949,7 +4189,10 @@ var Chat = class {
3949
4189
  if (incomingClientMsgId) {
3950
4190
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
3951
4191
  this.editFold.reevaluateHeld(this.authorOfTarget);
3952
- this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
4192
+ this.deleteFold.reevaluatePending(
4193
+ incomingClientMsgId,
4194
+ this.authorOfTarget(incomingClientMsgId)
4195
+ );
3953
4196
  }
3954
4197
  this.messageList.push(this.applyEditOverlay(msg));
3955
4198
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -3958,11 +4201,131 @@ var Chat = class {
3958
4201
  incoming.serverSeq
3959
4202
  );
3960
4203
  this.emit();
4204
+ void this.armPurge(
4205
+ this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
4206
+ incoming.serverSeq,
4207
+ incomingClientMsgId
4208
+ );
3961
4209
  }
3962
- /** The EditFold author-gate input: the target message's resolved author userId
3963
- * (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
3964
- * so it can be passed to the pure EditFold. */
3965
- authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
4210
+ // ── Disappearing (TTL T10) ──
4211
+ /** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
4212
+ * `ExpirySpec` the arm path consumes (or null when absent). */
4213
+ toExpirySpec(e) {
4214
+ return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
4215
+ }
4216
+ /** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
4217
+ * so a bubble with no per-message expiry inherits it. null when no default is active or
4218
+ * the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
4219
+ * the default has no per-message sender clock; the arrival anchor drives the deadline
4220
+ * (mirrors iOS `defaultExpiry()`). */
4221
+ defaultExpiry() {
4222
+ const active = this.timerFold.active();
4223
+ if (!active || active.ttlSeconds === null) return null;
4224
+ return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
4225
+ }
4226
+ /** The surfaced display deadline for an effective expiry (a local, monotonic-derived
4227
+ * value — the wall-clock projection of the TTL from now). null for a non-disappearing
4228
+ * message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
4229
+ * UI countdown baseline. */
4230
+ deadlineFor(expiry) {
4231
+ if (!expiry) return null;
4232
+ return new Date(Date.now() + expiry.ttlSeconds * 1e3);
4233
+ }
4234
+ /** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
4235
+ * monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
4236
+ * reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
4237
+ * computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
4238
+ * `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
4239
+ * capture), then either purges immediately or schedules an advisory `setTimeout`. A null
4240
+ * expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
4241
+ async armPurge(expiry, serverSeq, clientMsgId) {
4242
+ if (!expiry || !clientMsgId || !this._group) return;
4243
+ const group = this._group;
4244
+ const fresh = {
4245
+ mAnchorMs: MonotonicClock.nowMs(),
4246
+ wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
4247
+ bAnchorToken: MonotonicClock.bootToken()
4248
+ };
4249
+ await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
4250
+ const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
4251
+ const result = remainingSeconds({
4252
+ ttlSeconds: expiry.ttlSeconds,
4253
+ anchor: effective,
4254
+ nowMonotonicMs: MonotonicClock.nowMs(),
4255
+ nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
4256
+ nowBootToken: MonotonicClock.bootToken()
4257
+ });
4258
+ let purgeInSeconds;
4259
+ if (result.kind === "purgeNow") {
4260
+ purgeInSeconds = 0;
4261
+ } else if (expiry.start === "send" && expiry.senderSendTs !== null) {
4262
+ const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
4263
+ purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
4264
+ } else {
4265
+ purgeInSeconds = result.seconds;
4266
+ }
4267
+ const prior = this.purgeTimers.get(serverSeq);
4268
+ if (prior) clearTimeout(prior);
4269
+ this.purgeTimers.delete(serverSeq);
4270
+ if (purgeInSeconds <= 0) {
4271
+ await this.purge(serverSeq, clientMsgId);
4272
+ return;
4273
+ }
4274
+ const handle = setTimeout(() => {
4275
+ void this.purge(serverSeq, clientMsgId);
4276
+ }, purgeInSeconds * 1e3);
4277
+ this.purgeTimers.set(serverSeq, handle);
4278
+ }
4279
+ /** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
4280
+ * the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
4281
+ * remaining time (purge immediately if the deadline has already passed). The durable
4282
+ * tombstone is written by `purge` when it fires (the crash-safe commit point). */
4283
+ async armFromDeadline(deadline, serverSeq, clientMsgId) {
4284
+ if (!this._group) return;
4285
+ const remainingMs = deadline.getTime() - Date.now();
4286
+ const prior = this.purgeTimers.get(serverSeq);
4287
+ if (prior) clearTimeout(prior);
4288
+ this.purgeTimers.delete(serverSeq);
4289
+ if (remainingMs <= 0) {
4290
+ await this.purge(serverSeq, clientMsgId);
4291
+ return;
4292
+ }
4293
+ const handle = setTimeout(() => {
4294
+ void this.purge(serverSeq, clientMsgId);
4295
+ }, remainingMs);
4296
+ this.purgeTimers.set(serverSeq, handle);
4297
+ }
4298
+ /** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
4299
+ * persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
4300
+ * record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
4301
+ * edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
4302
+ * (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
4303
+ async purge(serverSeq, clientMsgId) {
4304
+ if (!this._group) return;
4305
+ const prior = this.purgeTimers.get(serverSeq);
4306
+ if (prior) clearTimeout(prior);
4307
+ this.purgeTimers.delete(serverSeq);
4308
+ await this.backend.tombstone(this._group, serverSeq, clientMsgId);
4309
+ if (clientMsgId) this.purgedCids.add(clientMsgId);
4310
+ this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
4311
+ this.seenKeys.delete(this.internalKey(serverSeq));
4312
+ this.emit();
4313
+ this.editFold.reevaluateHeld(this.authorOfTarget);
4314
+ if (clientMsgId) {
4315
+ this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
4316
+ }
4317
+ }
4318
+ /** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
4319
+ * T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
4320
+ * durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
4321
+ * disappeared message); `'author'` when its author is locally known → run the
4322
+ * author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
4323
+ * resolver. Captured as a bound arrow so it can be passed to the pure folds. */
4324
+ authorOfTarget = (targetClientMsgId) => {
4325
+ if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
4326
+ const a = this.authorByClientMsgId.get(targetClientMsgId);
4327
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
4328
+ };
3966
4329
  // ── Mentions (mentions T6) ──
3967
4330
  /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
3968
4331
  * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
@@ -4248,11 +4611,63 @@ var Chat = class {
4248
4611
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
4249
4612
  }
4250
4613
  const bodyRanges = opts?.mentions ?? null;
4251
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
4252
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
4614
+ const effectiveExpiry = this.composeExpiry(opts?.expiresIn);
4615
+ const { receipt, clientMsgId } = await this.backend.sendText(
4616
+ group,
4617
+ text,
4618
+ replyRef,
4619
+ bodyRanges,
4620
+ effectiveExpiry
4621
+ );
4622
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, effectiveExpiry);
4623
+ if (effectiveExpiry) void this.armPurge(effectiveExpiry, receipt.serverSeq, clientMsgId);
4253
4624
  return receipt;
4254
4625
  }
4255
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4626
+ /** Resolve a send's effective per-message expiry at COMPOSE TIME: the caller's explicit
4627
+ * `expiresIn` if present, ELSE the chat's active default timer stamped onto the message
4628
+ * NOW (the durable record per spec §"Compose-time stamping"). A `send`-anchored expiry
4629
+ * (explicit or default-inherited) stamps `senderSendTs` = the sender's compose epoch
4630
+ * seconds; a `read`-anchored one carries none (the deadline is the recipient's local
4631
+ * first-read). Returns null when there is neither an explicit expiry nor an active
4632
+ * default (a plain, non-disappearing send). Mirrors the iOS compose-time stamping. */
4633
+ composeExpiry(explicit) {
4634
+ const base = explicit ? {
4635
+ v: 1,
4636
+ ttlSeconds: explicit.ttlSeconds,
4637
+ start: explicit.start ?? "send",
4638
+ senderSendTs: null
4639
+ } : this.defaultExpiry();
4640
+ if (!base) return null;
4641
+ return {
4642
+ ...base,
4643
+ senderSendTs: base.start === "send" ? Math.floor(Date.now() / 1e3) : null
4644
+ };
4645
+ }
4646
+ /** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
4647
+ * envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
4648
+ * own-set locally so the default applies immediately to subsequent sends that carry no
4649
+ * per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
4650
+ * ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
4651
+ * Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
4652
+ async setDisappearing(opts) {
4653
+ const group = await this.materializeIfNeeded();
4654
+ const clientMsgId = mintClientMsgId();
4655
+ const start = opts.start ?? "send";
4656
+ const { receipt } = await this.backend.sendTimerSet(group, {
4657
+ clientMsgId,
4658
+ ttlSeconds: opts.ttlSeconds,
4659
+ start
4660
+ });
4661
+ this.timerFold.ingest({
4662
+ ttlSeconds: opts.ttlSeconds,
4663
+ start,
4664
+ actorUserId: this.backend.selfUserId,
4665
+ epoch: receipt.epoch,
4666
+ serverSeq: receipt.serverSeq,
4667
+ eventClientMsgId: clientMsgId
4668
+ });
4669
+ }
4670
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, expiry) {
4256
4671
  if (receipt.serverSeq <= 0) return;
4257
4672
  const key = this.internalKey(receipt.serverSeq);
4258
4673
  if (this.seenKeys.has(key)) return;
@@ -4280,7 +4695,12 @@ var Chat = class {
4280
4695
  isDeleted: false,
4281
4696
  // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4282
4697
  // sender never gets a wire echo of its own message — this is the only local copy).
4283
- mentions: this.resolveMentions(text, bodyRanges)
4698
+ mentions: this.resolveMentions(text, bodyRanges),
4699
+ // Disappearing T10: the surfaced deadline reflects the message's effective expiry
4700
+ // (explicit `expiresIn` OR the chat default stamped at compose time). null only when
4701
+ // this send is non-disappearing. armPurge re-derives the durable monotonic deadline;
4702
+ // this is the immediate UI countdown baseline (own sender and receiver are symmetric).
4703
+ expiresAt: this.deadlineFor(expiry ?? null)
4284
4704
  });
4285
4705
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4286
4706
  this.emit();
@@ -4635,6 +5055,7 @@ var MessageDeliverySource = class {
4635
5055
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4636
5056
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4637
5057
  const isDelete = decoded.type === "delete" && decoded.delete != null;
5058
+ const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
4638
5059
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4639
5060
  const stored = {
4640
5061
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4692,7 +5113,18 @@ var MessageDeliverySource = class {
4692
5113
  targetClientMsgId: decoded.delete.targetClientMsgId,
4693
5114
  scope: decoded.delete.scope
4694
5115
  }
4695
- } : {}
5116
+ } : {},
5117
+ // Disappearing T10: thread the timer_set discriminator + payload through the
5118
+ // persisted row so the chat default re-folds on cold launch (the page-local
5119
+ // TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
5120
+ ...isTimerSet && decoded.timer ? {
5121
+ envelopeType: "timer_set",
5122
+ timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
5123
+ } : {},
5124
+ // Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
5125
+ // row so the message re-arms its purge on cold launch (the projection derives the
5126
+ // deadline from this). Only on a text bubble; omitted when absent (backward-compat).
5127
+ ...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
4696
5128
  };
4697
5129
  try {
4698
5130
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -4715,7 +5147,12 @@ var MessageDeliverySource = class {
4715
5147
  delete: isDelete ? decoded.delete : null,
4716
5148
  // The raw mention ranges (text bubble or the edit's replacement ranges); the
4717
5149
  // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4718
- bodyRanges: decoded.bodyRanges ?? null
5150
+ bodyRanges: decoded.bodyRanges ?? null,
5151
+ // Disappearing T10: the decoded timer_set payload (chat default control) + a text
5152
+ // bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
5153
+ // arms a bubble's purge from the expiry (or the active default).
5154
+ timer: isTimerSet ? decoded.timer : null,
5155
+ expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
4719
5156
  });
4720
5157
  return true;
4721
5158
  }
@@ -4794,6 +5231,67 @@ function isOwnEchoOrConsumed(e) {
4794
5231
  return msg.includes("message from self") || msg.includes("key not available, invalid generation");
4795
5232
  }
4796
5233
 
5234
+ // src/messaging/disappearing.ts
5235
+ var DisappearingStore = class {
5236
+ constructor(kv) {
5237
+ this.kv = kv;
5238
+ }
5239
+ kv;
5240
+ key(rfc) {
5241
+ return `disappear:${rfc}`;
5242
+ }
5243
+ async load(rfc) {
5244
+ const raw = await this.kv.get(this.key(rfc));
5245
+ if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5246
+ try {
5247
+ const r = JSON.parse(decodeUtf8(raw));
5248
+ return {
5249
+ tombstonedSeqs: r.tombstonedSeqs ?? [],
5250
+ purgedClientMsgIds: r.purgedClientMsgIds ?? [],
5251
+ anchors: r.anchors ?? {}
5252
+ };
5253
+ } catch {
5254
+ return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5255
+ }
5256
+ }
5257
+ async save(rfc, rec) {
5258
+ await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
5259
+ }
5260
+ /** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
5261
+ async tombstonedSeqs(rfc) {
5262
+ return new Set((await this.load(rfc)).tombstonedSeqs);
5263
+ }
5264
+ /** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
5265
+ async purgedClientMsgIds(rfc) {
5266
+ return new Set((await this.load(rfc)).purgedClientMsgIds);
5267
+ }
5268
+ /**
5269
+ * Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
5270
+ * written together in ONE durable record. Idempotent (re-tombstoning the same seq /
5271
+ * id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
5272
+ * from the store excludes the seq and a redelivery is dropped, even across a crash.
5273
+ */
5274
+ async tombstone(rfc, serverSeq, clientMsgId) {
5275
+ const rec = await this.load(rfc);
5276
+ if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
5277
+ if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
5278
+ rec.purgedClientMsgIds.push(clientMsgId);
5279
+ }
5280
+ await this.save(rfc, rec);
5281
+ }
5282
+ /** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
5283
+ async anchor(rfc, clientMsgId) {
5284
+ return (await this.load(rfc)).anchors[clientMsgId] ?? null;
5285
+ }
5286
+ /** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
5287
+ async writeAnchorOnce(rfc, clientMsgId, a) {
5288
+ const rec = await this.load(rfc);
5289
+ if (rec.anchors[clientMsgId]) return;
5290
+ rec.anchors[clientMsgId] = a;
5291
+ await this.save(rfc, rec);
5292
+ }
5293
+ };
5294
+
4797
5295
  // src/messaging/history.ts
4798
5296
  var MessageStore = class {
4799
5297
  constructor(kv) {
@@ -6608,6 +7106,7 @@ var MessagingCoordinator = class {
6608
7106
  this.kpStore = new KeyPackageStorage(this.kv);
6609
7107
  this.suppressionStore = new SuppressionStore(this.kv);
6610
7108
  this.elevationStore = new MentionElevationStore(this.kv);
7109
+ this.disappearingStore = new DisappearingStore(this.kv);
6611
7110
  this.registry.attachChatList(
6612
7111
  (chats) => {
6613
7112
  this.chatList = chats;
@@ -6624,6 +7123,7 @@ var MessagingCoordinator = class {
6624
7123
  kpStore;
6625
7124
  suppressionStore;
6626
7125
  elevationStore;
7126
+ disappearingStore;
6627
7127
  registry = new GroupRegistry();
6628
7128
  resolved = null;
6629
7129
  resolvePromise = null;
@@ -6795,6 +7295,10 @@ var MessagingCoordinator = class {
6795
7295
  const r = await this.resolve();
6796
7296
  return r.groups.sendDelete(group, args);
6797
7297
  }
7298
+ async sendTimerSet(group, args) {
7299
+ const r = await this.resolve();
7300
+ return r.groups.sendTimerSet(group, args);
7301
+ }
6798
7302
  /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6799
7303
  loadSuppressed(group) {
6800
7304
  return this.suppressionStore.load(group.rfcGroupId);
@@ -6811,10 +7315,28 @@ var MessagingCoordinator = class {
6811
7315
  saveElevated(group, keys) {
6812
7316
  return this.elevationStore.save(group.rfcGroupId, keys);
6813
7317
  }
7318
+ // ── Disappearing / TTL seam (durable-only, no wire) ──
7319
+ tombstonedSeqs(group) {
7320
+ return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7321
+ }
7322
+ purgedClientMsgIds(group) {
7323
+ return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7324
+ }
7325
+ anchor(group, clientMsgId) {
7326
+ return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
7327
+ }
7328
+ writeAnchorOnce(group, clientMsgId, a) {
7329
+ return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
7330
+ }
7331
+ tombstone(group, serverSeq, clientMsgId) {
7332
+ return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
7333
+ }
6814
7334
  async history(group, limit, before) {
6815
7335
  const r = await this.resolve();
6816
7336
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
6817
- return projectHistory(group.displayId, rows, this.selfUserId);
7337
+ const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7338
+ const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7339
+ return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
6818
7340
  }
6819
7341
  async members(group) {
6820
7342
  const r = await this.resolve();
@@ -6891,9 +7413,10 @@ var MessagingCoordinator = class {
6891
7413
  return res.devices.map((d) => d.device_id);
6892
7414
  }
6893
7415
  };
6894
- function projectHistory(displayId, rows, selfUserId, resolveActor) {
7416
+ function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
7417
+ const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
6895
7418
  const fold = new ReactionFold();
6896
- for (const s of rows) {
7419
+ for (const s of visible) {
6897
7420
  if (s.envelopeType !== "reaction" || !s.reaction) continue;
6898
7421
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6899
7422
  if (actor === null) continue;
@@ -6909,17 +7432,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6909
7432
  }
6910
7433
  const editFold = new EditFold();
6911
7434
  const deleteFold = new DeleteFold();
7435
+ const pageTimerFold = new TimerFold();
6912
7436
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6913
- for (const s of rows) {
6914
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7437
+ for (const s of visible) {
7438
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6915
7439
  continue;
6916
7440
  const cid = s.clientMsgId ?? "";
6917
7441
  if (!cid) continue;
6918
7442
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6919
7443
  if (author != null) authorByClientMsgId.set(cid, author);
6920
7444
  }
6921
- const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6922
- for (const s of rows) {
7445
+ const authorOfTarget = (cid) => {
7446
+ if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
7447
+ const a = authorByClientMsgId.get(cid);
7448
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
7449
+ };
7450
+ for (const s of visible) {
6923
7451
  if (s.envelopeType !== "edit" || !s.edit) continue;
6924
7452
  const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6925
7453
  editFold.ingest(
@@ -6938,7 +7466,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6938
7466
  );
6939
7467
  }
6940
7468
  editFold.reevaluateHeld(authorOfTarget);
6941
- for (const s of rows) {
7469
+ for (const s of visible) {
6942
7470
  if (s.envelopeType !== "delete" || !s.delete) continue;
6943
7471
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6944
7472
  deleteFold.ingest(
@@ -6952,10 +7480,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6952
7480
  authorOfTarget
6953
7481
  );
6954
7482
  }
6955
- for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
7483
+ for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
6956
7484
  const lookup = /* @__PURE__ */ new Map();
6957
- for (const s of rows) {
6958
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7485
+ for (const s of visible) {
7486
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6959
7487
  continue;
6960
7488
  const cid = s.clientMsgId ?? "";
6961
7489
  if (cid && s.text !== null) {
@@ -6964,7 +7492,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6964
7492
  }
6965
7493
  }
6966
7494
  const out = [];
6967
- for (const s of rows) {
7495
+ for (const s of visible) {
7496
+ if (s.envelopeType === "timer_set") {
7497
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
7498
+ if (actor !== null && s.timer) {
7499
+ pageTimerFold.ingest({
7500
+ ttlSeconds: s.timer.ttlSeconds,
7501
+ start: s.timer.start,
7502
+ actorUserId: actor,
7503
+ epoch: s.epoch,
7504
+ serverSeq: s.serverSeq,
7505
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
7506
+ });
7507
+ }
7508
+ continue;
7509
+ }
6968
7510
  if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6969
7511
  continue;
6970
7512
  const clientMsgId = s.clientMsgId ?? "";
@@ -6984,10 +7526,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6984
7526
  edited: false,
6985
7527
  isDeleted: true,
6986
7528
  // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6987
- mentions: []
7529
+ mentions: [],
7530
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
7531
+ expiresAt: null
6988
7532
  });
6989
7533
  continue;
6990
7534
  }
7535
+ let expiresAt = null;
7536
+ if (s.expiry) {
7537
+ expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
7538
+ } else {
7539
+ const active = pageTimerFold.active();
7540
+ if (active && active.ttlSeconds !== null) {
7541
+ expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
7542
+ }
7543
+ }
6991
7544
  let replyTo = null;
6992
7545
  if (s.replyTo) {
6993
7546
  const ref = {
@@ -7020,7 +7573,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
7020
7573
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
7021
7574
  edited,
7022
7575
  isDeleted: false,
7023
- mentions
7576
+ mentions,
7577
+ expiresAt
7024
7578
  });
7025
7579
  }
7026
7580
  return out;
@@ -7767,7 +8321,7 @@ function defaultSessionStorage(key) {
7767
8321
  }
7768
8322
 
7769
8323
  // src/version.ts
7770
- var VERSION = "1.5.0";
8324
+ var VERSION = "1.6.1";
7771
8325
 
7772
8326
  // src/runtime.ts
7773
8327
  function buildRuntime(config) {