@palbase/web 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1607,6 +1607,47 @@ var PalbeFlags = class {
1607
1607
  }
1608
1608
  };
1609
1609
 
1610
+ // src/messaging/deadline-calculator.ts
1611
+ function remainingSeconds(args) {
1612
+ const ttl = args.ttlSeconds;
1613
+ const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
1614
+ let elapsed;
1615
+ if (args.nowBootToken === args.anchor.bAnchorToken) {
1616
+ const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
1617
+ elapsed = Math.max(monoDeltaSec, wallDeltaSec);
1618
+ } else {
1619
+ elapsed = wallDeltaSec;
1620
+ }
1621
+ const remaining = Math.min(ttl, ttl - elapsed);
1622
+ return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
1623
+ }
1624
+ var cachedBootToken = null;
1625
+ var MonotonicClock = {
1626
+ nowMs() {
1627
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
1628
+ },
1629
+ nowWallEpochMs() {
1630
+ return Date.now();
1631
+ },
1632
+ bootToken() {
1633
+ if (cachedBootToken !== null) return cachedBootToken;
1634
+ try {
1635
+ const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
1636
+ if (existing) {
1637
+ cachedBootToken = existing;
1638
+ return existing;
1639
+ }
1640
+ const fresh = crypto.randomUUID();
1641
+ if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
1642
+ cachedBootToken = fresh;
1643
+ return fresh;
1644
+ } catch {
1645
+ cachedBootToken = crypto.randomUUID();
1646
+ return cachedBootToken;
1647
+ }
1648
+ }
1649
+ };
1650
+
1610
1651
  // src/messaging/delete-fold.ts
1611
1652
  var DeleteFold = class {
1612
1653
  // targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
@@ -1619,17 +1660,24 @@ var DeleteFold = class {
1619
1660
  // keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
1620
1661
  held = [];
1621
1662
  /**
1622
- * Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
1623
- * userId (null = target absent locally → defer).
1663
+ * Ingest one tombstone. `authorOfTarget` resolves the target message's author via
1664
+ * {@link AuthorResolution}: `'author'` run the author-gate; `'unknown'` → defer
1665
+ * (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
1666
+ * was TTL-purged — a delete of an already-gone message is already satisfied; never
1667
+ * park in pending, never re-attempt).
1624
1668
  */
1625
1669
  ingest(e, authorOfTarget) {
1626
1670
  if (this.tombstoned.has(e.targetClientMsgId)) return;
1627
1671
  if (this.seen.has(e.eventClientMsgId)) return;
1628
1672
  if (this.heldContains(e.eventClientMsgId)) return;
1629
- const author = authorOfTarget(e.targetClientMsgId);
1630
- if (author !== null) {
1673
+ const res = authorOfTarget(e.targetClientMsgId);
1674
+ if (res.kind === "purged") {
1675
+ this.seen.add(e.eventClientMsgId);
1676
+ return;
1677
+ }
1678
+ if (res.kind === "author") {
1631
1679
  this.seen.add(e.eventClientMsgId);
1632
- if (e.actorUserId === null || e.actorUserId !== author) return;
1680
+ if (e.actorUserId === null || e.actorUserId !== res.userId) return;
1633
1681
  this.tombstoned.add(e.targetClientMsgId);
1634
1682
  } else if (e.actorUserId !== null) {
1635
1683
  this.seen.add(e.eventClientMsgId);
@@ -1649,12 +1697,13 @@ var DeleteFold = class {
1649
1697
  * the in-order path.
1650
1698
  */
1651
1699
  reevaluatePending(target, author) {
1700
+ const res = author;
1652
1701
  const actor = this.pending.get(target);
1653
1702
  if (actor !== void 0) {
1654
- if (author !== null && actor === author) {
1655
- this.tombstoned.add(target);
1703
+ if (res.kind === "author") {
1704
+ if (actor === res.userId) this.tombstoned.add(target);
1656
1705
  this.pending.delete(target);
1657
- } else if (author !== null) {
1706
+ } else if (res.kind === "purged") {
1658
1707
  this.pending.delete(target);
1659
1708
  }
1660
1709
  }
@@ -1662,7 +1711,7 @@ var DeleteFold = class {
1662
1711
  const pendingHeld = this.held;
1663
1712
  this.held = [];
1664
1713
  for (const e of pendingHeld) {
1665
- this.ingest(e, (t) => t === target ? author : null);
1714
+ this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
1666
1715
  }
1667
1716
  }
1668
1717
  heldContains(eventClientMsgId) {
@@ -1688,15 +1737,23 @@ var EditFold = class {
1688
1737
  // targets that have had ≥1 valid edit applied (write-once)
1689
1738
  editedTargets = /* @__PURE__ */ new Set();
1690
1739
  /**
1691
- * Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
1692
- * (null = target unknown/dangling → HOLD).
1740
+ * Ingest one edit. `authorOfTarget` resolves the target message's author via
1741
+ * {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
1742
+ * (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
1743
+ * editing a disappeared message is a no-op; mark the event seen so it never re-holds
1744
+ * and a later author "resolution" cannot resurrect it).
1693
1745
  */
1694
1746
  ingest(e, authorOfTarget) {
1695
- const author = authorOfTarget(e.targetClientMsgId);
1696
- if (author === null) {
1747
+ const res = authorOfTarget(e.targetClientMsgId);
1748
+ if (res.kind === "unknown") {
1697
1749
  this.holdIfNew(e);
1698
1750
  return;
1699
1751
  }
1752
+ if (res.kind === "purged") {
1753
+ this.seenEvents.add(e.eventClientMsgId);
1754
+ return;
1755
+ }
1756
+ const author = res.userId;
1700
1757
  if (e.editorUserId === null) {
1701
1758
  this.holdIfNew(e);
1702
1759
  return;
@@ -1955,14 +2012,46 @@ function encodeEnvelope(args) {
1955
2012
  length: r.length,
1956
2013
  mentioned_user_id: r.mentionedUserId
1957
2014
  }))
2015
+ } : {},
2016
+ ...args.expiry ? {
2017
+ expiry: {
2018
+ v: args.expiry.v,
2019
+ ttl_seconds: args.expiry.ttlSeconds,
2020
+ start: args.expiry.start,
2021
+ // present IFF send (drop a stray senderSendTs on a read anchor)
2022
+ ...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
2023
+ }
1958
2024
  } : {}
1959
2025
  };
1960
2026
  return encodeUtf8(JSON.stringify(env));
1961
2027
  }
2028
+ function encodeTimerSet(args) {
2029
+ return encodeUtf8(
2030
+ JSON.stringify({
2031
+ v: 1,
2032
+ type: "timer_set",
2033
+ client_msg_id: args.clientMsgId,
2034
+ ...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
2035
+ start: args.start
2036
+ })
2037
+ );
2038
+ }
1962
2039
  function decodeEnvelope(bytes) {
1963
2040
  const s = decodeUtf8(bytes);
1964
2041
  try {
1965
2042
  const o = JSON.parse(s);
2043
+ if (typeof o === "object" && o !== null && o.type === "timer_set") {
2044
+ return {
2045
+ type: "timer_set",
2046
+ text: null,
2047
+ clientMsgId: o.client_msg_id ?? "",
2048
+ replyTo: null,
2049
+ timer: {
2050
+ ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
2051
+ start: o.start === "read" ? "read" : "send"
2052
+ }
2053
+ };
2054
+ }
1966
2055
  if (typeof o === "object" && o !== null && o.type === "delete") {
1967
2056
  return {
1968
2057
  type: "delete",
@@ -2004,12 +2093,14 @@ function decodeEnvelope(bytes) {
2004
2093
  }
2005
2094
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2006
2095
  const textRanges = decodeBodyRanges(o.body_ranges);
2096
+ const expiry = decodeExpiry(o.expiry);
2007
2097
  return {
2008
2098
  type: "text",
2009
2099
  text: o.text ?? null,
2010
2100
  clientMsgId: o.client_msg_id ?? "",
2011
2101
  replyTo: o.reply_to ?? null,
2012
- ...textRanges ? { bodyRanges: textRanges } : {}
2102
+ ...textRanges ? { bodyRanges: textRanges } : {},
2103
+ ...expiry ? { expiry } : {}
2013
2104
  };
2014
2105
  }
2015
2106
  if (typeof o === "object" && o !== null && o.type === "text") {
@@ -2021,6 +2112,19 @@ function decodeEnvelope(bytes) {
2021
2112
  }
2022
2113
  return { text: s, clientMsgId: "", replyTo: null };
2023
2114
  }
2115
+ function decodeExpiry(raw) {
2116
+ if (typeof raw !== "object" || raw === null) return void 0;
2117
+ const o = raw;
2118
+ if (typeof o.ttl_seconds !== "number") return void 0;
2119
+ const start = o.start === "read" ? "read" : "send";
2120
+ return {
2121
+ v: typeof o.v === "number" ? o.v : 1,
2122
+ ttlSeconds: o.ttl_seconds,
2123
+ start,
2124
+ // tolerant: only honor sender_send_ts on a send anchor; null otherwise.
2125
+ senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
2126
+ };
2127
+ }
2024
2128
  function decodeBodyRanges(raw) {
2025
2129
  if (!raw || raw.length === 0) return void 0;
2026
2130
  return raw.map((r) => ({
@@ -2240,9 +2344,9 @@ var GroupMessaging = class {
2240
2344
  /** Send a text message. Encrypt at the current epoch, POST, return the receipt +
2241
2345
  * the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
2242
2346
  * NEVER rebases (a 422 stale_application surfaces to the caller). */
2243
- async sendText(group, text, replyTo, bodyRanges) {
2347
+ async sendText(group, text, replyTo, bodyRanges, expiry) {
2244
2348
  const clientMsgId = mintClientMsgId();
2245
- const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
2349
+ const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
2246
2350
  const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2247
2351
  const body = {
2248
2352
  ciphertext_b64: toBase64(ct),
@@ -2271,7 +2375,10 @@ var GroupMessaging = class {
2271
2375
  } : null,
2272
2376
  // Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
2273
2377
  // onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
2274
- ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
2378
+ ...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
2379
+ // Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
2380
+ // after a cold launch (the projection derives the deadline from this row's expiry).
2381
+ ...expiry ? { expiry } : {}
2275
2382
  };
2276
2383
  try {
2277
2384
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -2279,6 +2386,51 @@ var GroupMessaging = class {
2279
2386
  }
2280
2387
  return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
2281
2388
  }
2389
+ /** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
2390
+ * a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
2391
+ * application path as `sendText` (the server stays blind — it's an opaque app message,
2392
+ * NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
2393
+ * Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
2394
+ * (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
2395
+ async sendTimerSet(group, args) {
2396
+ const plaintext = encodeTimerSet({
2397
+ clientMsgId: args.clientMsgId,
2398
+ ttlSeconds: args.ttlSeconds,
2399
+ start: args.start
2400
+ });
2401
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
2402
+ const body = {
2403
+ ciphertext_b64: toBase64(ct),
2404
+ client_idem_key: randomId()
2405
+ };
2406
+ const wire = await palbeRequest(
2407
+ this.rt,
2408
+ "POST",
2409
+ MessagingPaths.groupMessages(group.displayId),
2410
+ { body }
2411
+ );
2412
+ const stored = {
2413
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
2414
+ direction: "outgoing",
2415
+ text: null,
2416
+ senderDeviceId: this.selfDeviceId,
2417
+ epoch: wire.epoch,
2418
+ serverSeq: wire.server_seq,
2419
+ at: Date.now(),
2420
+ clientMsgId: args.clientMsgId,
2421
+ replyTo: null,
2422
+ envelopeType: "timer_set",
2423
+ timer: { ttlSeconds: args.ttlSeconds, start: args.start }
2424
+ };
2425
+ try {
2426
+ await this.messageStore.append(group.rfcGroupId, stored);
2427
+ } catch {
2428
+ }
2429
+ return {
2430
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
2431
+ clientMsgId: args.clientMsgId
2432
+ };
2433
+ }
2282
2434
  /** Send a reaction (add/remove of an emoji on a target message). Encrypts a
2283
2435
  * `type:'reaction'` envelope at the current epoch and sends through the SAME
2284
2436
  * MLS application path as `sendText` (the server stays blind — a reaction is
@@ -2581,6 +2733,43 @@ var ReactionFold = class {
2581
2733
  }
2582
2734
  };
2583
2735
 
2736
+ // src/messaging/timer-fold.ts
2737
+ function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
2738
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
2739
+ return aSeq <= bSeq;
2740
+ }
2741
+ var TimerFold = class {
2742
+ cell = null;
2743
+ seenEvents = /* @__PURE__ */ new Set();
2744
+ ingest(e) {
2745
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
2746
+ this.seenEvents.add(e.eventClientMsgId);
2747
+ if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
2748
+ return;
2749
+ }
2750
+ this.cell = {
2751
+ orderEpoch: e.epoch,
2752
+ orderSeq: e.serverSeq,
2753
+ ttlSeconds: e.ttlSeconds,
2754
+ start: e.start,
2755
+ actor: e.actorUserId
2756
+ };
2757
+ }
2758
+ /**
2759
+ * The active chat default, or null if no timer_set has applied.
2760
+ * `ttlSeconds === null` means DISABLED (still applied — distinct from "never
2761
+ * set"). `start` is meaningful only when ttlSeconds !== null.
2762
+ */
2763
+ active() {
2764
+ if (this.cell === null) return null;
2765
+ return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
2766
+ }
2767
+ /** The userId behind the winning timer_set (for the optional system line). null if unset. */
2768
+ lastActor() {
2769
+ return this.cell?.actor ?? null;
2770
+ }
2771
+ };
2772
+
2584
2773
  // src/messaging/chat.ts
2585
2774
  var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
2586
2775
  var Chat = class {
@@ -2607,6 +2796,22 @@ var Chat = class {
2607
2796
  /** The single authoritative delete-for-everyone fold (live + own-send + history).
2608
2797
  * A tombstone scrubs its target in place (delete DOMINATES edit at render). */
2609
2798
  deleteFold = new DeleteFold();
2799
+ /** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
2800
+ * on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
2801
+ * bubble; it routes here. The active default governs a subsequent bubble that carries
2802
+ * no per-message expiry (disappearing T10). */
2803
+ timerFold = new TimerFold();
2804
+ /** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
2805
+ * persisted anchor + a re-check on every load; this just drives live eviction while
2806
+ * the tab is open. Cancelled when the message purges (disappearing T10). */
2807
+ purgeTimers = /* @__PURE__ */ new Map();
2808
+ /** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
2809
+ * hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
2810
+ * by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
2811
+ * `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
2812
+ * tombstone (disappearing T10). */
2813
+ purgedCids = /* @__PURE__ */ new Set();
2814
+ purgedLoaded = false;
2610
2815
  /** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
2611
2816
  * — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
2612
2817
  suppressed = /* @__PURE__ */ new Set();
@@ -2717,7 +2922,9 @@ var Chat = class {
2717
2922
  replyTo: null,
2718
2923
  edited: false,
2719
2924
  isDeleted: true,
2720
- mentions: []
2925
+ mentions: [],
2926
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
2927
+ expiresAt: null
2721
2928
  });
2722
2929
  continue;
2723
2930
  }
@@ -2754,9 +2961,21 @@ var Chat = class {
2754
2961
  this.liveUnsub = this.backend.subscribeLive(this._group, this);
2755
2962
  void this.loadSuppressed();
2756
2963
  void this.loadElevated();
2757
- void this.hydrateHistory();
2964
+ void this.loadPurged().then(() => this.hydrateHistory());
2758
2965
  void this.refreshMembers();
2759
2966
  }
2967
+ /** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
2968
+ * live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
2969
+ * (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
2970
+ async loadPurged() {
2971
+ if (this.purgedLoaded || !this._group) return;
2972
+ this.purgedLoaded = true;
2973
+ try {
2974
+ const ids = await this.backend.purgedClientMsgIds(this._group);
2975
+ for (const id of ids) this.purgedCids.add(id);
2976
+ } catch {
2977
+ }
2978
+ }
2760
2979
  /** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
2761
2980
  * any already-surfaced suppressed message is omitted (cold-launch parity). */
2762
2981
  async loadSuppressed() {
@@ -2813,13 +3032,16 @@ var Chat = class {
2813
3032
  if (this.seenKeys.has(key)) continue;
2814
3033
  this.seenKeys.add(key);
2815
3034
  if (m.clientMsgId && !m.isDeleted) {
2816
- this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
3035
+ this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
2817
3036
  }
2818
3037
  this.messageList.push(
2819
3038
  this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
2820
3039
  );
2821
3040
  changed = true;
2822
3041
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3042
+ if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
3043
+ void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
3044
+ }
2823
3045
  }
2824
3046
  if (changed) {
2825
3047
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -2834,6 +3056,7 @@ var Chat = class {
2834
3056
  return;
2835
3057
  }
2836
3058
  if (incoming.serverSeq <= 0) return;
3059
+ if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
2837
3060
  const key = this.internalKey(incoming.serverSeq);
2838
3061
  if (this.seenKeys.has(key)) return;
2839
3062
  this.seenKeys.add(key);
@@ -2842,6 +3065,20 @@ var Chat = class {
2842
3065
  senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
2843
3066
  }
2844
3067
  const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
3068
+ if (incoming.envelopeType === "timer_set" && incoming.timer) {
3069
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3070
+ if (actorUserId !== null) {
3071
+ this.timerFold.ingest({
3072
+ ttlSeconds: incoming.timer.ttlSeconds,
3073
+ start: incoming.timer.start,
3074
+ actorUserId,
3075
+ epoch: incoming.epoch,
3076
+ serverSeq: incoming.serverSeq,
3077
+ eventClientMsgId: incoming.clientMsgId
3078
+ });
3079
+ }
3080
+ return;
3081
+ }
2845
3082
  if (incoming.envelopeType === "reaction" && incoming.reaction) {
2846
3083
  const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
2847
3084
  if (actorUserId !== null) {
@@ -2916,7 +3153,10 @@ var Chat = class {
2916
3153
  edited: false,
2917
3154
  // Default false; surfaced() applies the tombstone scrub if a delete folded.
2918
3155
  isDeleted: false,
2919
- mentions
3156
+ mentions,
3157
+ // Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
3158
+ // active AS OF arrival). null when this message is non-disappearing.
3159
+ expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
2920
3160
  };
2921
3161
  this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
2922
3162
  if (incomingClientMsgId && incoming.text !== null) {
@@ -2928,7 +3168,10 @@ var Chat = class {
2928
3168
  if (incomingClientMsgId) {
2929
3169
  this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
2930
3170
  this.editFold.reevaluateHeld(this.authorOfTarget);
2931
- this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
3171
+ this.deleteFold.reevaluatePending(
3172
+ incomingClientMsgId,
3173
+ this.authorOfTarget(incomingClientMsgId)
3174
+ );
2932
3175
  }
2933
3176
  this.messageList.push(this.applyEditOverlay(msg));
2934
3177
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
@@ -2937,11 +3180,131 @@ var Chat = class {
2937
3180
  incoming.serverSeq
2938
3181
  );
2939
3182
  this.emit();
3183
+ void this.armPurge(
3184
+ this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
3185
+ incoming.serverSeq,
3186
+ incomingClientMsgId
3187
+ );
3188
+ }
3189
+ // ── Disappearing (TTL — T10) ──
3190
+ /** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
3191
+ * `ExpirySpec` the arm path consumes (or null when absent). */
3192
+ toExpirySpec(e) {
3193
+ return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
3194
+ }
3195
+ /** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
3196
+ * so a bubble with no per-message expiry inherits it. null when no default is active or
3197
+ * the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
3198
+ * the default has no per-message sender clock; the arrival anchor drives the deadline
3199
+ * (mirrors iOS `defaultExpiry()`). */
3200
+ defaultExpiry() {
3201
+ const active = this.timerFold.active();
3202
+ if (!active || active.ttlSeconds === null) return null;
3203
+ return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
3204
+ }
3205
+ /** The surfaced display deadline for an effective expiry (a local, monotonic-derived
3206
+ * value — the wall-clock projection of the TTL from now). null for a non-disappearing
3207
+ * message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
3208
+ * UI countdown baseline. */
3209
+ deadlineFor(expiry) {
3210
+ if (!expiry) return null;
3211
+ return new Date(Date.now() + expiry.ttlSeconds * 1e3);
3212
+ }
3213
+ /** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
3214
+ * monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
3215
+ * reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
3216
+ * computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
3217
+ * `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
3218
+ * capture), then either purges immediately or schedules an advisory `setTimeout`. A null
3219
+ * expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
3220
+ async armPurge(expiry, serverSeq, clientMsgId) {
3221
+ if (!expiry || !clientMsgId || !this._group) return;
3222
+ const group = this._group;
3223
+ const fresh = {
3224
+ mAnchorMs: MonotonicClock.nowMs(),
3225
+ wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
3226
+ bAnchorToken: MonotonicClock.bootToken()
3227
+ };
3228
+ await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
3229
+ const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
3230
+ const result = remainingSeconds({
3231
+ ttlSeconds: expiry.ttlSeconds,
3232
+ anchor: effective,
3233
+ nowMonotonicMs: MonotonicClock.nowMs(),
3234
+ nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
3235
+ nowBootToken: MonotonicClock.bootToken()
3236
+ });
3237
+ let purgeInSeconds;
3238
+ if (result.kind === "purgeNow") {
3239
+ purgeInSeconds = 0;
3240
+ } else if (expiry.start === "send" && expiry.senderSendTs !== null) {
3241
+ const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
3242
+ purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
3243
+ } else {
3244
+ purgeInSeconds = result.seconds;
3245
+ }
3246
+ const prior = this.purgeTimers.get(serverSeq);
3247
+ if (prior) clearTimeout(prior);
3248
+ this.purgeTimers.delete(serverSeq);
3249
+ if (purgeInSeconds <= 0) {
3250
+ await this.purge(serverSeq, clientMsgId);
3251
+ return;
3252
+ }
3253
+ const handle = setTimeout(() => {
3254
+ void this.purge(serverSeq, clientMsgId);
3255
+ }, purgeInSeconds * 1e3);
3256
+ this.purgeTimers.set(serverSeq, handle);
2940
3257
  }
2941
- /** The EditFold author-gate input: the target message's resolved author userId
2942
- * (null = target unknown/dangling the fold HOLDs). Captured as a bound arrow
2943
- * so it can be passed to the pure EditFold. */
2944
- authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
3258
+ /** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
3259
+ * the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
3260
+ * remaining time (purge immediately if the deadline has already passed). The durable
3261
+ * tombstone is written by `purge` when it fires (the crash-safe commit point). */
3262
+ async armFromDeadline(deadline, serverSeq, clientMsgId) {
3263
+ if (!this._group) return;
3264
+ const remainingMs = deadline.getTime() - Date.now();
3265
+ const prior = this.purgeTimers.get(serverSeq);
3266
+ if (prior) clearTimeout(prior);
3267
+ this.purgeTimers.delete(serverSeq);
3268
+ if (remainingMs <= 0) {
3269
+ await this.purge(serverSeq, clientMsgId);
3270
+ return;
3271
+ }
3272
+ const handle = setTimeout(() => {
3273
+ void this.purge(serverSeq, clientMsgId);
3274
+ }, remainingMs);
3275
+ this.purgeTimers.set(serverSeq, handle);
3276
+ }
3277
+ /** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
3278
+ * persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
3279
+ * record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
3280
+ * edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
3281
+ * (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
3282
+ async purge(serverSeq, clientMsgId) {
3283
+ if (!this._group) return;
3284
+ const prior = this.purgeTimers.get(serverSeq);
3285
+ if (prior) clearTimeout(prior);
3286
+ this.purgeTimers.delete(serverSeq);
3287
+ await this.backend.tombstone(this._group, serverSeq, clientMsgId);
3288
+ if (clientMsgId) this.purgedCids.add(clientMsgId);
3289
+ this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
3290
+ this.seenKeys.delete(this.internalKey(serverSeq));
3291
+ this.emit();
3292
+ this.editFold.reevaluateHeld(this.authorOfTarget);
3293
+ if (clientMsgId) {
3294
+ this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
3295
+ }
3296
+ }
3297
+ /** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
3298
+ * T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
3299
+ * durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
3300
+ * disappeared message); `'author'` when its author is locally known → run the
3301
+ * author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
3302
+ * resolver. Captured as a bound arrow so it can be passed to the pure folds. */
3303
+ authorOfTarget = (targetClientMsgId) => {
3304
+ if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
3305
+ const a = this.authorByClientMsgId.get(targetClientMsgId);
3306
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
3307
+ };
2945
3308
  // ── Mentions (mentions T6) ──
2946
3309
  /** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
2947
3310
  * against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
@@ -3227,10 +3590,48 @@ var Chat = class {
3227
3590
  resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
3228
3591
  }
3229
3592
  const bodyRanges = opts?.mentions ?? null;
3230
- const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
3593
+ const start = opts?.expiresIn?.start ?? "send";
3594
+ const expiry = opts?.expiresIn ? {
3595
+ v: 1,
3596
+ ttlSeconds: opts.expiresIn.ttlSeconds,
3597
+ start,
3598
+ senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
3599
+ } : null;
3600
+ const { receipt, clientMsgId } = await this.backend.sendText(
3601
+ group,
3602
+ text,
3603
+ replyRef,
3604
+ bodyRanges,
3605
+ expiry
3606
+ );
3231
3607
  this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
3608
+ if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
3232
3609
  return receipt;
3233
3610
  }
3611
+ /** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
3612
+ * envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
3613
+ * own-set locally so the default applies immediately to subsequent sends that carry no
3614
+ * per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
3615
+ * ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
3616
+ * Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
3617
+ async setDisappearing(opts) {
3618
+ const group = await this.materializeIfNeeded();
3619
+ const clientMsgId = mintClientMsgId();
3620
+ const start = opts.start ?? "send";
3621
+ const { receipt } = await this.backend.sendTimerSet(group, {
3622
+ clientMsgId,
3623
+ ttlSeconds: opts.ttlSeconds,
3624
+ start
3625
+ });
3626
+ this.timerFold.ingest({
3627
+ ttlSeconds: opts.ttlSeconds,
3628
+ start,
3629
+ actorUserId: this.backend.selfUserId,
3630
+ epoch: receipt.epoch,
3631
+ serverSeq: receipt.serverSeq,
3632
+ eventClientMsgId: clientMsgId
3633
+ });
3634
+ }
3234
3635
  appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
3235
3636
  if (receipt.serverSeq <= 0) return;
3236
3637
  const key = this.internalKey(receipt.serverSeq);
@@ -3259,7 +3660,10 @@ var Chat = class {
3259
3660
  isDeleted: false,
3260
3661
  // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
3261
3662
  // sender never gets a wire echo of its own message — this is the only local copy).
3262
- mentions: this.resolveMentions(text, bodyRanges)
3663
+ mentions: this.resolveMentions(text, bodyRanges),
3664
+ // Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
3665
+ // via the messageList overlay; default null here (a plain own-send has no deadline).
3666
+ expiresAt: null
3263
3667
  });
3264
3668
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3265
3669
  this.emit();
@@ -3614,6 +4018,7 @@ var MessageDeliverySource = class {
3614
4018
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
3615
4019
  const isEdit = decoded.type === "edit" && decoded.edit != null;
3616
4020
  const isDelete = decoded.type === "delete" && decoded.delete != null;
4021
+ const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
3617
4022
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
3618
4023
  const stored = {
3619
4024
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -3671,7 +4076,18 @@ var MessageDeliverySource = class {
3671
4076
  targetClientMsgId: decoded.delete.targetClientMsgId,
3672
4077
  scope: decoded.delete.scope
3673
4078
  }
3674
- } : {}
4079
+ } : {},
4080
+ // Disappearing T10: thread the timer_set discriminator + payload through the
4081
+ // persisted row so the chat default re-folds on cold launch (the page-local
4082
+ // TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
4083
+ ...isTimerSet && decoded.timer ? {
4084
+ envelopeType: "timer_set",
4085
+ timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
4086
+ } : {},
4087
+ // Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
4088
+ // row so the message re-arms its purge on cold launch (the projection derives the
4089
+ // deadline from this). Only on a text bubble; omitted when absent (backward-compat).
4090
+ ...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
3675
4091
  };
3676
4092
  try {
3677
4093
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3694,7 +4110,12 @@ var MessageDeliverySource = class {
3694
4110
  delete: isDelete ? decoded.delete : null,
3695
4111
  // The raw mention ranges (text bubble or the edit's replacement ranges); the
3696
4112
  // Chat normalizes + resolves names → ChatMessage.mentions (T6).
3697
- bodyRanges: decoded.bodyRanges ?? null
4113
+ bodyRanges: decoded.bodyRanges ?? null,
4114
+ // Disappearing T10: the decoded timer_set payload (chat default control) + a text
4115
+ // bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
4116
+ // arms a bubble's purge from the expiry (or the active default).
4117
+ timer: isTimerSet ? decoded.timer : null,
4118
+ expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
3698
4119
  });
3699
4120
  return true;
3700
4121
  }
@@ -3773,6 +4194,67 @@ function isOwnEchoOrConsumed(e) {
3773
4194
  return msg.includes("message from self") || msg.includes("key not available, invalid generation");
3774
4195
  }
3775
4196
 
4197
+ // src/messaging/disappearing.ts
4198
+ var DisappearingStore = class {
4199
+ constructor(kv) {
4200
+ this.kv = kv;
4201
+ }
4202
+ kv;
4203
+ key(rfc) {
4204
+ return `disappear:${rfc}`;
4205
+ }
4206
+ async load(rfc) {
4207
+ const raw = await this.kv.get(this.key(rfc));
4208
+ if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
4209
+ try {
4210
+ const r = JSON.parse(decodeUtf8(raw));
4211
+ return {
4212
+ tombstonedSeqs: r.tombstonedSeqs ?? [],
4213
+ purgedClientMsgIds: r.purgedClientMsgIds ?? [],
4214
+ anchors: r.anchors ?? {}
4215
+ };
4216
+ } catch {
4217
+ return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
4218
+ }
4219
+ }
4220
+ async save(rfc, rec) {
4221
+ await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
4222
+ }
4223
+ /** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
4224
+ async tombstonedSeqs(rfc) {
4225
+ return new Set((await this.load(rfc)).tombstonedSeqs);
4226
+ }
4227
+ /** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
4228
+ async purgedClientMsgIds(rfc) {
4229
+ return new Set((await this.load(rfc)).purgedClientMsgIds);
4230
+ }
4231
+ /**
4232
+ * Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
4233
+ * written together in ONE durable record. Idempotent (re-tombstoning the same seq /
4234
+ * id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
4235
+ * from the store excludes the seq and a redelivery is dropped, even across a crash.
4236
+ */
4237
+ async tombstone(rfc, serverSeq, clientMsgId) {
4238
+ const rec = await this.load(rfc);
4239
+ if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
4240
+ if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
4241
+ rec.purgedClientMsgIds.push(clientMsgId);
4242
+ }
4243
+ await this.save(rfc, rec);
4244
+ }
4245
+ /** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
4246
+ async anchor(rfc, clientMsgId) {
4247
+ return (await this.load(rfc)).anchors[clientMsgId] ?? null;
4248
+ }
4249
+ /** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
4250
+ async writeAnchorOnce(rfc, clientMsgId, a) {
4251
+ const rec = await this.load(rfc);
4252
+ if (rec.anchors[clientMsgId]) return;
4253
+ rec.anchors[clientMsgId] = a;
4254
+ await this.save(rfc, rec);
4255
+ }
4256
+ };
4257
+
3776
4258
  // src/messaging/history.ts
3777
4259
  var MessageStore = class {
3778
4260
  constructor(kv) {
@@ -5587,6 +6069,7 @@ var MessagingCoordinator = class {
5587
6069
  this.kpStore = new KeyPackageStorage(this.kv);
5588
6070
  this.suppressionStore = new SuppressionStore(this.kv);
5589
6071
  this.elevationStore = new MentionElevationStore(this.kv);
6072
+ this.disappearingStore = new DisappearingStore(this.kv);
5590
6073
  this.registry.attachChatList(
5591
6074
  (chats) => {
5592
6075
  this.chatList = chats;
@@ -5603,6 +6086,7 @@ var MessagingCoordinator = class {
5603
6086
  kpStore;
5604
6087
  suppressionStore;
5605
6088
  elevationStore;
6089
+ disappearingStore;
5606
6090
  registry = new GroupRegistry();
5607
6091
  resolved = null;
5608
6092
  resolvePromise = null;
@@ -5774,6 +6258,10 @@ var MessagingCoordinator = class {
5774
6258
  const r = await this.resolve();
5775
6259
  return r.groups.sendDelete(group, args);
5776
6260
  }
6261
+ async sendTimerSet(group, args) {
6262
+ const r = await this.resolve();
6263
+ return r.groups.sendTimerSet(group, args);
6264
+ }
5777
6265
  /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
5778
6266
  loadSuppressed(group) {
5779
6267
  return this.suppressionStore.load(group.rfcGroupId);
@@ -5790,10 +6278,28 @@ var MessagingCoordinator = class {
5790
6278
  saveElevated(group, keys) {
5791
6279
  return this.elevationStore.save(group.rfcGroupId, keys);
5792
6280
  }
6281
+ // ── Disappearing / TTL seam (durable-only, no wire) ──
6282
+ tombstonedSeqs(group) {
6283
+ return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
6284
+ }
6285
+ purgedClientMsgIds(group) {
6286
+ return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
6287
+ }
6288
+ anchor(group, clientMsgId) {
6289
+ return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
6290
+ }
6291
+ writeAnchorOnce(group, clientMsgId, a) {
6292
+ return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
6293
+ }
6294
+ tombstone(group, serverSeq, clientMsgId) {
6295
+ return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
6296
+ }
5793
6297
  async history(group, limit, before) {
5794
6298
  const r = await this.resolve();
5795
6299
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
5796
- return projectHistory(group.displayId, rows, this.selfUserId);
6300
+ const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
6301
+ const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
6302
+ return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
5797
6303
  }
5798
6304
  async members(group) {
5799
6305
  const r = await this.resolve();
@@ -5870,9 +6376,10 @@ var MessagingCoordinator = class {
5870
6376
  return res.devices.map((d) => d.device_id);
5871
6377
  }
5872
6378
  };
5873
- function projectHistory(displayId, rows, selfUserId, resolveActor) {
6379
+ function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
6380
+ const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
5874
6381
  const fold = new ReactionFold();
5875
- for (const s of rows) {
6382
+ for (const s of visible) {
5876
6383
  if (s.envelopeType !== "reaction" || !s.reaction) continue;
5877
6384
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5878
6385
  if (actor === null) continue;
@@ -5888,17 +6395,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5888
6395
  }
5889
6396
  const editFold = new EditFold();
5890
6397
  const deleteFold = new DeleteFold();
6398
+ const pageTimerFold = new TimerFold();
5891
6399
  const authorByClientMsgId = /* @__PURE__ */ new Map();
5892
- for (const s of rows) {
5893
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6400
+ for (const s of visible) {
6401
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
5894
6402
  continue;
5895
6403
  const cid = s.clientMsgId ?? "";
5896
6404
  if (!cid) continue;
5897
6405
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
5898
6406
  if (author != null) authorByClientMsgId.set(cid, author);
5899
6407
  }
5900
- const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
5901
- for (const s of rows) {
6408
+ const authorOfTarget = (cid) => {
6409
+ if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
6410
+ const a = authorByClientMsgId.get(cid);
6411
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
6412
+ };
6413
+ for (const s of visible) {
5902
6414
  if (s.envelopeType !== "edit" || !s.edit) continue;
5903
6415
  const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5904
6416
  editFold.ingest(
@@ -5917,7 +6429,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5917
6429
  );
5918
6430
  }
5919
6431
  editFold.reevaluateHeld(authorOfTarget);
5920
- for (const s of rows) {
6432
+ for (const s of visible) {
5921
6433
  if (s.envelopeType !== "delete" || !s.delete) continue;
5922
6434
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5923
6435
  deleteFold.ingest(
@@ -5931,10 +6443,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5931
6443
  authorOfTarget
5932
6444
  );
5933
6445
  }
5934
- for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
6446
+ for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
5935
6447
  const lookup = /* @__PURE__ */ new Map();
5936
- for (const s of rows) {
5937
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6448
+ for (const s of visible) {
6449
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
5938
6450
  continue;
5939
6451
  const cid = s.clientMsgId ?? "";
5940
6452
  if (cid && s.text !== null) {
@@ -5943,7 +6455,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5943
6455
  }
5944
6456
  }
5945
6457
  const out = [];
5946
- for (const s of rows) {
6458
+ for (const s of visible) {
6459
+ if (s.envelopeType === "timer_set") {
6460
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6461
+ if (actor !== null && s.timer) {
6462
+ pageTimerFold.ingest({
6463
+ ttlSeconds: s.timer.ttlSeconds,
6464
+ start: s.timer.start,
6465
+ actorUserId: actor,
6466
+ epoch: s.epoch,
6467
+ serverSeq: s.serverSeq,
6468
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
6469
+ });
6470
+ }
6471
+ continue;
6472
+ }
5947
6473
  if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
5948
6474
  continue;
5949
6475
  const clientMsgId = s.clientMsgId ?? "";
@@ -5963,10 +6489,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5963
6489
  edited: false,
5964
6490
  isDeleted: true,
5965
6491
  // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
5966
- mentions: []
6492
+ mentions: [],
6493
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
6494
+ expiresAt: null
5967
6495
  });
5968
6496
  continue;
5969
6497
  }
6498
+ let expiresAt = null;
6499
+ if (s.expiry) {
6500
+ expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
6501
+ } else {
6502
+ const active = pageTimerFold.active();
6503
+ if (active && active.ttlSeconds !== null) {
6504
+ expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
6505
+ }
6506
+ }
5970
6507
  let replyTo = null;
5971
6508
  if (s.replyTo) {
5972
6509
  const ref = {
@@ -5999,7 +6536,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5999
6536
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6000
6537
  edited,
6001
6538
  isDeleted: false,
6002
- mentions
6539
+ mentions,
6540
+ expiresAt
6003
6541
  });
6004
6542
  }
6005
6543
  return out;
@@ -6865,7 +7403,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
6865
7403
  }
6866
7404
 
6867
7405
  // src/version.ts
6868
- var VERSION = "1.5.0";
7406
+ var VERSION = "1.6.0";
6869
7407
 
6870
7408
  // src/internal.ts
6871
7409
  function getRuntime() {