@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.
@@ -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
+ );
3924
4172
  }
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;
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);
4241
+ }
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,11 +4574,63 @@ 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);
4215
- this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
4577
+ const effectiveExpiry = this.composeExpiry(opts?.expiresIn);
4578
+ const { receipt, clientMsgId } = await this.backend.sendText(
4579
+ group,
4580
+ text,
4581
+ replyRef,
4582
+ bodyRanges,
4583
+ effectiveExpiry
4584
+ );
4585
+ this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, effectiveExpiry);
4586
+ if (effectiveExpiry) void this.armPurge(effectiveExpiry, receipt.serverSeq, clientMsgId);
4216
4587
  return receipt;
4217
4588
  }
4218
- appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
4589
+ /** Resolve a send's effective per-message expiry at COMPOSE TIME: the caller's explicit
4590
+ * `expiresIn` if present, ELSE the chat's active default timer stamped onto the message
4591
+ * NOW (the durable record per spec §"Compose-time stamping"). A `send`-anchored expiry
4592
+ * (explicit or default-inherited) stamps `senderSendTs` = the sender's compose epoch
4593
+ * seconds; a `read`-anchored one carries none (the deadline is the recipient's local
4594
+ * first-read). Returns null when there is neither an explicit expiry nor an active
4595
+ * default (a plain, non-disappearing send). Mirrors the iOS compose-time stamping. */
4596
+ composeExpiry(explicit) {
4597
+ const base = explicit ? {
4598
+ v: 1,
4599
+ ttlSeconds: explicit.ttlSeconds,
4600
+ start: explicit.start ?? "send",
4601
+ senderSendTs: null
4602
+ } : this.defaultExpiry();
4603
+ if (!base) return null;
4604
+ return {
4605
+ ...base,
4606
+ senderSendTs: base.start === "send" ? Math.floor(Date.now() / 1e3) : null
4607
+ };
4608
+ }
4609
+ /** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
4610
+ * envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
4611
+ * own-set locally so the default applies immediately to subsequent sends that carry no
4612
+ * per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
4613
+ * ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
4614
+ * Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
4615
+ async setDisappearing(opts) {
4616
+ const group = await this.materializeIfNeeded();
4617
+ const clientMsgId = mintClientMsgId();
4618
+ const start = opts.start ?? "send";
4619
+ const { receipt } = await this.backend.sendTimerSet(group, {
4620
+ clientMsgId,
4621
+ ttlSeconds: opts.ttlSeconds,
4622
+ start
4623
+ });
4624
+ this.timerFold.ingest({
4625
+ ttlSeconds: opts.ttlSeconds,
4626
+ start,
4627
+ actorUserId: this.backend.selfUserId,
4628
+ epoch: receipt.epoch,
4629
+ serverSeq: receipt.serverSeq,
4630
+ eventClientMsgId: clientMsgId
4631
+ });
4632
+ }
4633
+ appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, expiry) {
4219
4634
  if (receipt.serverSeq <= 0) return;
4220
4635
  const key = this.internalKey(receipt.serverSeq);
4221
4636
  if (this.seenKeys.has(key)) return;
@@ -4243,7 +4658,12 @@ var Chat = class {
4243
4658
  isDeleted: false,
4244
4659
  // Mentions T6: resolve the own-sent bubble's mentions for its own render (the
4245
4660
  // sender never gets a wire echo of its own message — this is the only local copy).
4246
- mentions: this.resolveMentions(text, bodyRanges)
4661
+ mentions: this.resolveMentions(text, bodyRanges),
4662
+ // Disappearing T10: the surfaced deadline reflects the message's effective expiry
4663
+ // (explicit `expiresIn` OR the chat default stamped at compose time). null only when
4664
+ // this send is non-disappearing. armPurge re-derives the durable monotonic deadline;
4665
+ // this is the immediate UI countdown baseline (own sender and receiver are symmetric).
4666
+ expiresAt: this.deadlineFor(expiry ?? null)
4247
4667
  });
4248
4668
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
4249
4669
  this.emit();
@@ -4598,6 +5018,7 @@ var MessageDeliverySource = class {
4598
5018
  const isReaction = decoded.type === "reaction" && decoded.reaction != null;
4599
5019
  const isEdit = decoded.type === "edit" && decoded.edit != null;
4600
5020
  const isDelete = decoded.type === "delete" && decoded.delete != null;
5021
+ const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
4601
5022
  const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
4602
5023
  const stored = {
4603
5024
  id: `${group.rfcGroupId}#${row.server_seq}`,
@@ -4655,7 +5076,18 @@ var MessageDeliverySource = class {
4655
5076
  targetClientMsgId: decoded.delete.targetClientMsgId,
4656
5077
  scope: decoded.delete.scope
4657
5078
  }
4658
- } : {}
5079
+ } : {},
5080
+ // Disappearing T10: thread the timer_set discriminator + payload through the
5081
+ // persisted row so the chat default re-folds on cold launch (the page-local
5082
+ // TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
5083
+ ...isTimerSet && decoded.timer ? {
5084
+ envelopeType: "timer_set",
5085
+ timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
5086
+ } : {},
5087
+ // Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
5088
+ // row so the message re-arms its purge on cold launch (the projection derives the
5089
+ // deadline from this). Only on a text bubble; omitted when absent (backward-compat).
5090
+ ...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
4659
5091
  };
4660
5092
  try {
4661
5093
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -4678,7 +5110,12 @@ var MessageDeliverySource = class {
4678
5110
  delete: isDelete ? decoded.delete : null,
4679
5111
  // The raw mention ranges (text bubble or the edit's replacement ranges); the
4680
5112
  // Chat normalizes + resolves names → ChatMessage.mentions (T6).
4681
- bodyRanges: decoded.bodyRanges ?? null
5113
+ bodyRanges: decoded.bodyRanges ?? null,
5114
+ // Disappearing T10: the decoded timer_set payload (chat default control) + a text
5115
+ // bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
5116
+ // arms a bubble's purge from the expiry (or the active default).
5117
+ timer: isTimerSet ? decoded.timer : null,
5118
+ expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
4682
5119
  });
4683
5120
  return true;
4684
5121
  }
@@ -4757,6 +5194,67 @@ function isOwnEchoOrConsumed(e) {
4757
5194
  return msg.includes("message from self") || msg.includes("key not available, invalid generation");
4758
5195
  }
4759
5196
 
5197
+ // src/messaging/disappearing.ts
5198
+ var DisappearingStore = class {
5199
+ constructor(kv) {
5200
+ this.kv = kv;
5201
+ }
5202
+ kv;
5203
+ key(rfc) {
5204
+ return `disappear:${rfc}`;
5205
+ }
5206
+ async load(rfc) {
5207
+ const raw = await this.kv.get(this.key(rfc));
5208
+ if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5209
+ try {
5210
+ const r = JSON.parse(decodeUtf8(raw));
5211
+ return {
5212
+ tombstonedSeqs: r.tombstonedSeqs ?? [],
5213
+ purgedClientMsgIds: r.purgedClientMsgIds ?? [],
5214
+ anchors: r.anchors ?? {}
5215
+ };
5216
+ } catch {
5217
+ return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
5218
+ }
5219
+ }
5220
+ async save(rfc, rec) {
5221
+ await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
5222
+ }
5223
+ /** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
5224
+ async tombstonedSeqs(rfc) {
5225
+ return new Set((await this.load(rfc)).tombstonedSeqs);
5226
+ }
5227
+ /** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
5228
+ async purgedClientMsgIds(rfc) {
5229
+ return new Set((await this.load(rfc)).purgedClientMsgIds);
5230
+ }
5231
+ /**
5232
+ * Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
5233
+ * written together in ONE durable record. Idempotent (re-tombstoning the same seq /
5234
+ * id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
5235
+ * from the store excludes the seq and a redelivery is dropped, even across a crash.
5236
+ */
5237
+ async tombstone(rfc, serverSeq, clientMsgId) {
5238
+ const rec = await this.load(rfc);
5239
+ if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
5240
+ if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
5241
+ rec.purgedClientMsgIds.push(clientMsgId);
5242
+ }
5243
+ await this.save(rfc, rec);
5244
+ }
5245
+ /** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
5246
+ async anchor(rfc, clientMsgId) {
5247
+ return (await this.load(rfc)).anchors[clientMsgId] ?? null;
5248
+ }
5249
+ /** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
5250
+ async writeAnchorOnce(rfc, clientMsgId, a) {
5251
+ const rec = await this.load(rfc);
5252
+ if (rec.anchors[clientMsgId]) return;
5253
+ rec.anchors[clientMsgId] = a;
5254
+ await this.save(rfc, rec);
5255
+ }
5256
+ };
5257
+
4760
5258
  // src/messaging/history.ts
4761
5259
  var MessageStore = class {
4762
5260
  constructor(kv) {
@@ -6570,6 +7068,7 @@ var MessagingCoordinator = class {
6570
7068
  this.kpStore = new KeyPackageStorage(this.kv);
6571
7069
  this.suppressionStore = new SuppressionStore(this.kv);
6572
7070
  this.elevationStore = new MentionElevationStore(this.kv);
7071
+ this.disappearingStore = new DisappearingStore(this.kv);
6573
7072
  this.registry.attachChatList(
6574
7073
  (chats) => {
6575
7074
  this.chatList = chats;
@@ -6586,6 +7085,7 @@ var MessagingCoordinator = class {
6586
7085
  kpStore;
6587
7086
  suppressionStore;
6588
7087
  elevationStore;
7088
+ disappearingStore;
6589
7089
  registry = new GroupRegistry();
6590
7090
  resolved = null;
6591
7091
  resolvePromise = null;
@@ -6757,6 +7257,10 @@ var MessagingCoordinator = class {
6757
7257
  const r = await this.resolve();
6758
7258
  return r.groups.sendDelete(group, args);
6759
7259
  }
7260
+ async sendTimerSet(group, args) {
7261
+ const r = await this.resolve();
7262
+ return r.groups.sendTimerSet(group, args);
7263
+ }
6760
7264
  /** Load this chat's persisted delete-for-me suppression keys (durable-only). */
6761
7265
  loadSuppressed(group) {
6762
7266
  return this.suppressionStore.load(group.rfcGroupId);
@@ -6773,10 +7277,28 @@ var MessagingCoordinator = class {
6773
7277
  saveElevated(group, keys) {
6774
7278
  return this.elevationStore.save(group.rfcGroupId, keys);
6775
7279
  }
7280
+ // ── Disappearing / TTL seam (durable-only, no wire) ──
7281
+ tombstonedSeqs(group) {
7282
+ return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7283
+ }
7284
+ purgedClientMsgIds(group) {
7285
+ return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7286
+ }
7287
+ anchor(group, clientMsgId) {
7288
+ return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
7289
+ }
7290
+ writeAnchorOnce(group, clientMsgId, a) {
7291
+ return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
7292
+ }
7293
+ tombstone(group, serverSeq, clientMsgId) {
7294
+ return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
7295
+ }
6776
7296
  async history(group, limit, before) {
6777
7297
  const r = await this.resolve();
6778
7298
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
6779
- return projectHistory(group.displayId, rows, this.selfUserId);
7299
+ const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
7300
+ const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
7301
+ return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
6780
7302
  }
6781
7303
  async members(group) {
6782
7304
  const r = await this.resolve();
@@ -6853,9 +7375,10 @@ var MessagingCoordinator = class {
6853
7375
  return res.devices.map((d) => d.device_id);
6854
7376
  }
6855
7377
  };
6856
- function projectHistory(displayId, rows, selfUserId, resolveActor) {
7378
+ function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
7379
+ const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
6857
7380
  const fold = new ReactionFold();
6858
- for (const s of rows) {
7381
+ for (const s of visible) {
6859
7382
  if (s.envelopeType !== "reaction" || !s.reaction) continue;
6860
7383
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6861
7384
  if (actor === null) continue;
@@ -6871,17 +7394,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6871
7394
  }
6872
7395
  const editFold = new EditFold();
6873
7396
  const deleteFold = new DeleteFold();
7397
+ const pageTimerFold = new TimerFold();
6874
7398
  const authorByClientMsgId = /* @__PURE__ */ new Map();
6875
- for (const s of rows) {
6876
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7399
+ for (const s of visible) {
7400
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6877
7401
  continue;
6878
7402
  const cid = s.clientMsgId ?? "";
6879
7403
  if (!cid) continue;
6880
7404
  const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
6881
7405
  if (author != null) authorByClientMsgId.set(cid, author);
6882
7406
  }
6883
- const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
6884
- for (const s of rows) {
7407
+ const authorOfTarget = (cid) => {
7408
+ if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
7409
+ const a = authorByClientMsgId.get(cid);
7410
+ return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
7411
+ };
7412
+ for (const s of visible) {
6885
7413
  if (s.envelopeType !== "edit" || !s.edit) continue;
6886
7414
  const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6887
7415
  editFold.ingest(
@@ -6900,7 +7428,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6900
7428
  );
6901
7429
  }
6902
7430
  editFold.reevaluateHeld(authorOfTarget);
6903
- for (const s of rows) {
7431
+ for (const s of visible) {
6904
7432
  if (s.envelopeType !== "delete" || !s.delete) continue;
6905
7433
  const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
6906
7434
  deleteFold.ingest(
@@ -6914,10 +7442,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6914
7442
  authorOfTarget
6915
7443
  );
6916
7444
  }
6917
- for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
7445
+ for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
6918
7446
  const lookup = /* @__PURE__ */ new Map();
6919
- for (const s of rows) {
6920
- if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
7447
+ for (const s of visible) {
7448
+ if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
6921
7449
  continue;
6922
7450
  const cid = s.clientMsgId ?? "";
6923
7451
  if (cid && s.text !== null) {
@@ -6926,7 +7454,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6926
7454
  }
6927
7455
  }
6928
7456
  const out = [];
6929
- for (const s of rows) {
7457
+ for (const s of visible) {
7458
+ if (s.envelopeType === "timer_set") {
7459
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
7460
+ if (actor !== null && s.timer) {
7461
+ pageTimerFold.ingest({
7462
+ ttlSeconds: s.timer.ttlSeconds,
7463
+ start: s.timer.start,
7464
+ actorUserId: actor,
7465
+ epoch: s.epoch,
7466
+ serverSeq: s.serverSeq,
7467
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
7468
+ });
7469
+ }
7470
+ continue;
7471
+ }
6930
7472
  if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
6931
7473
  continue;
6932
7474
  const clientMsgId = s.clientMsgId ?? "";
@@ -6946,10 +7488,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6946
7488
  edited: false,
6947
7489
  isDeleted: true,
6948
7490
  // Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
6949
- mentions: []
7491
+ mentions: [],
7492
+ // delete DOMINATES disappearing too — a tombstoned message carries no deadline.
7493
+ expiresAt: null
6950
7494
  });
6951
7495
  continue;
6952
7496
  }
7497
+ let expiresAt = null;
7498
+ if (s.expiry) {
7499
+ expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
7500
+ } else {
7501
+ const active = pageTimerFold.active();
7502
+ if (active && active.ttlSeconds !== null) {
7503
+ expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
7504
+ }
7505
+ }
6953
7506
  let replyTo = null;
6954
7507
  if (s.replyTo) {
6955
7508
  const ref = {
@@ -6982,7 +7535,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
6982
7535
  reactions: clientMsgId ? fold.tally(clientMsgId) : {},
6983
7536
  edited,
6984
7537
  isDeleted: false,
6985
- mentions
7538
+ mentions,
7539
+ expiresAt
6986
7540
  });
6987
7541
  }
6988
7542
  return out;
@@ -7729,7 +8283,7 @@ function defaultSessionStorage(key) {
7729
8283
  }
7730
8284
 
7731
8285
  // src/version.ts
7732
- var VERSION = "1.5.0";
8286
+ var VERSION = "1.6.1";
7733
8287
 
7734
8288
  // src/runtime.ts
7735
8289
  function buildRuntime(config) {
@@ -8089,4 +8643,4 @@ export {
8089
8643
  pb,
8090
8644
  createBoundClient
8091
8645
  };
8092
- //# sourceMappingURL=chunk-MBA2NAKS.js.map
8646
+ //# sourceMappingURL=chunk-EQAHM3ZJ.js.map