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