@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.
- package/dist/{analytics-facade-DvsLE_qz.d.cts → analytics-facade-ATGUv2-f.d.cts} +152 -4
- package/dist/{analytics-facade-BQlTfsEm.d.ts → analytics-facade-DhcTP_kw.d.ts} +152 -4
- package/dist/{chunk-MBA2NAKS.js → chunk-EQAHM3ZJ.js} +602 -48
- package/dist/chunk-EQAHM3ZJ.js.map +1 -0
- package/dist/index.cjs +601 -47
- 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 +601 -47
- 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 +601 -47
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +601 -47
- 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-Cl7TeyCR.d.ts → pb-TAUNVyT6.d.ts} +1 -1
- package/dist/{pb-a86jrG9h.d.cts → pb-VzJvX7Gg.d.cts} +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 +2 -2
- package/dist/chunk-MBA2NAKS.js.map +0 -1
package/dist/internal.cjs
CHANGED
|
@@ -2612,6 +2612,47 @@ var PalbeFlags = class {
|
|
|
2612
2612
|
}
|
|
2613
2613
|
};
|
|
2614
2614
|
|
|
2615
|
+
// src/messaging/deadline-calculator.ts
|
|
2616
|
+
function remainingSeconds(args) {
|
|
2617
|
+
const ttl = args.ttlSeconds;
|
|
2618
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
2619
|
+
let elapsed;
|
|
2620
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
2621
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
2622
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
2623
|
+
} else {
|
|
2624
|
+
elapsed = wallDeltaSec;
|
|
2625
|
+
}
|
|
2626
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
2627
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
2628
|
+
}
|
|
2629
|
+
var cachedBootToken = null;
|
|
2630
|
+
var MonotonicClock = {
|
|
2631
|
+
nowMs() {
|
|
2632
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2633
|
+
},
|
|
2634
|
+
nowWallEpochMs() {
|
|
2635
|
+
return Date.now();
|
|
2636
|
+
},
|
|
2637
|
+
bootToken() {
|
|
2638
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
2639
|
+
try {
|
|
2640
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
2641
|
+
if (existing) {
|
|
2642
|
+
cachedBootToken = existing;
|
|
2643
|
+
return existing;
|
|
2644
|
+
}
|
|
2645
|
+
const fresh = crypto.randomUUID();
|
|
2646
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
2647
|
+
cachedBootToken = fresh;
|
|
2648
|
+
return fresh;
|
|
2649
|
+
} catch {
|
|
2650
|
+
cachedBootToken = crypto.randomUUID();
|
|
2651
|
+
return cachedBootToken;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
|
|
2615
2656
|
// src/messaging/delete-fold.ts
|
|
2616
2657
|
var DeleteFold = class {
|
|
2617
2658
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -2624,17 +2665,24 @@ var DeleteFold = class {
|
|
|
2624
2665
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2625
2666
|
held = [];
|
|
2626
2667
|
/**
|
|
2627
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
2628
|
-
*
|
|
2668
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
2669
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
2670
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
2671
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
2672
|
+
* park in pending, never re-attempt).
|
|
2629
2673
|
*/
|
|
2630
2674
|
ingest(e, authorOfTarget) {
|
|
2631
2675
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2632
2676
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2633
2677
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2634
|
-
const
|
|
2635
|
-
if (
|
|
2678
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2679
|
+
if (res.kind === "purged") {
|
|
2680
|
+
this.seen.add(e.eventClientMsgId);
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
if (res.kind === "author") {
|
|
2636
2684
|
this.seen.add(e.eventClientMsgId);
|
|
2637
|
-
if (e.actorUserId === null || e.actorUserId !==
|
|
2685
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
2638
2686
|
this.tombstoned.add(e.targetClientMsgId);
|
|
2639
2687
|
} else if (e.actorUserId !== null) {
|
|
2640
2688
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -2654,12 +2702,13 @@ var DeleteFold = class {
|
|
|
2654
2702
|
* the in-order path.
|
|
2655
2703
|
*/
|
|
2656
2704
|
reevaluatePending(target, author) {
|
|
2705
|
+
const res = author;
|
|
2657
2706
|
const actor = this.pending.get(target);
|
|
2658
2707
|
if (actor !== void 0) {
|
|
2659
|
-
if (
|
|
2660
|
-
this.tombstoned.add(target);
|
|
2708
|
+
if (res.kind === "author") {
|
|
2709
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
2661
2710
|
this.pending.delete(target);
|
|
2662
|
-
} else if (
|
|
2711
|
+
} else if (res.kind === "purged") {
|
|
2663
2712
|
this.pending.delete(target);
|
|
2664
2713
|
}
|
|
2665
2714
|
}
|
|
@@ -2667,7 +2716,7 @@ var DeleteFold = class {
|
|
|
2667
2716
|
const pendingHeld = this.held;
|
|
2668
2717
|
this.held = [];
|
|
2669
2718
|
for (const e of pendingHeld) {
|
|
2670
|
-
this.ingest(e, (t) => t === target ?
|
|
2719
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
2671
2720
|
}
|
|
2672
2721
|
}
|
|
2673
2722
|
heldContains(eventClientMsgId) {
|
|
@@ -2693,15 +2742,23 @@ var EditFold = class {
|
|
|
2693
2742
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
2694
2743
|
editedTargets = /* @__PURE__ */ new Set();
|
|
2695
2744
|
/**
|
|
2696
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
2697
|
-
*
|
|
2745
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
2746
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
2747
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
2748
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
2749
|
+
* and a later author "resolution" cannot resurrect it).
|
|
2698
2750
|
*/
|
|
2699
2751
|
ingest(e, authorOfTarget) {
|
|
2700
|
-
const
|
|
2701
|
-
if (
|
|
2752
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2753
|
+
if (res.kind === "unknown") {
|
|
2702
2754
|
this.holdIfNew(e);
|
|
2703
2755
|
return;
|
|
2704
2756
|
}
|
|
2757
|
+
if (res.kind === "purged") {
|
|
2758
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const author = res.userId;
|
|
2705
2762
|
if (e.editorUserId === null) {
|
|
2706
2763
|
this.holdIfNew(e);
|
|
2707
2764
|
return;
|
|
@@ -2960,14 +3017,46 @@ function encodeEnvelope(args) {
|
|
|
2960
3017
|
length: r.length,
|
|
2961
3018
|
mentioned_user_id: r.mentionedUserId
|
|
2962
3019
|
}))
|
|
3020
|
+
} : {},
|
|
3021
|
+
...args.expiry ? {
|
|
3022
|
+
expiry: {
|
|
3023
|
+
v: args.expiry.v,
|
|
3024
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
3025
|
+
start: args.expiry.start,
|
|
3026
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
3027
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
3028
|
+
}
|
|
2963
3029
|
} : {}
|
|
2964
3030
|
};
|
|
2965
3031
|
return encodeUtf8(JSON.stringify(env));
|
|
2966
3032
|
}
|
|
3033
|
+
function encodeTimerSet(args) {
|
|
3034
|
+
return encodeUtf8(
|
|
3035
|
+
JSON.stringify({
|
|
3036
|
+
v: 1,
|
|
3037
|
+
type: "timer_set",
|
|
3038
|
+
client_msg_id: args.clientMsgId,
|
|
3039
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
3040
|
+
start: args.start
|
|
3041
|
+
})
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
2967
3044
|
function decodeEnvelope(bytes) {
|
|
2968
3045
|
const s = decodeUtf8(bytes);
|
|
2969
3046
|
try {
|
|
2970
3047
|
const o = JSON.parse(s);
|
|
3048
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
3049
|
+
return {
|
|
3050
|
+
type: "timer_set",
|
|
3051
|
+
text: null,
|
|
3052
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
3053
|
+
replyTo: null,
|
|
3054
|
+
timer: {
|
|
3055
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
3056
|
+
start: o.start === "read" ? "read" : "send"
|
|
3057
|
+
}
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
2971
3060
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2972
3061
|
return {
|
|
2973
3062
|
type: "delete",
|
|
@@ -3009,12 +3098,14 @@ function decodeEnvelope(bytes) {
|
|
|
3009
3098
|
}
|
|
3010
3099
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
3011
3100
|
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
3101
|
+
const expiry = decodeExpiry(o.expiry);
|
|
3012
3102
|
return {
|
|
3013
3103
|
type: "text",
|
|
3014
3104
|
text: o.text ?? null,
|
|
3015
3105
|
clientMsgId: o.client_msg_id ?? "",
|
|
3016
3106
|
replyTo: o.reply_to ?? null,
|
|
3017
|
-
...textRanges ? { bodyRanges: textRanges } : {}
|
|
3107
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
3108
|
+
...expiry ? { expiry } : {}
|
|
3018
3109
|
};
|
|
3019
3110
|
}
|
|
3020
3111
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -3026,6 +3117,19 @@ function decodeEnvelope(bytes) {
|
|
|
3026
3117
|
}
|
|
3027
3118
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
3028
3119
|
}
|
|
3120
|
+
function decodeExpiry(raw) {
|
|
3121
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
3122
|
+
const o = raw;
|
|
3123
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
3124
|
+
const start = o.start === "read" ? "read" : "send";
|
|
3125
|
+
return {
|
|
3126
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
3127
|
+
ttlSeconds: o.ttl_seconds,
|
|
3128
|
+
start,
|
|
3129
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
3130
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3029
3133
|
function decodeBodyRanges(raw) {
|
|
3030
3134
|
if (!raw || raw.length === 0) return void 0;
|
|
3031
3135
|
return raw.map((r) => ({
|
|
@@ -3245,9 +3349,9 @@ var GroupMessaging = class {
|
|
|
3245
3349
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3246
3350
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3247
3351
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3248
|
-
async sendText(group, text, replyTo, bodyRanges) {
|
|
3352
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
3249
3353
|
const clientMsgId = mintClientMsgId();
|
|
3250
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
3354
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
3251
3355
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3252
3356
|
const body = {
|
|
3253
3357
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3276,7 +3380,10 @@ var GroupMessaging = class {
|
|
|
3276
3380
|
} : null,
|
|
3277
3381
|
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3278
3382
|
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3279
|
-
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
3383
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
3384
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
3385
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
3386
|
+
...expiry ? { expiry } : {}
|
|
3280
3387
|
};
|
|
3281
3388
|
try {
|
|
3282
3389
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3284,6 +3391,51 @@ var GroupMessaging = class {
|
|
|
3284
3391
|
}
|
|
3285
3392
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
3286
3393
|
}
|
|
3394
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
3395
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
3396
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
3397
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
3398
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
3399
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
3400
|
+
async sendTimerSet(group, args) {
|
|
3401
|
+
const plaintext = encodeTimerSet({
|
|
3402
|
+
clientMsgId: args.clientMsgId,
|
|
3403
|
+
ttlSeconds: args.ttlSeconds,
|
|
3404
|
+
start: args.start
|
|
3405
|
+
});
|
|
3406
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3407
|
+
const body = {
|
|
3408
|
+
ciphertext_b64: toBase64(ct),
|
|
3409
|
+
client_idem_key: randomId()
|
|
3410
|
+
};
|
|
3411
|
+
const wire = await palbeRequest(
|
|
3412
|
+
this.rt,
|
|
3413
|
+
"POST",
|
|
3414
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3415
|
+
{ body }
|
|
3416
|
+
);
|
|
3417
|
+
const stored = {
|
|
3418
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3419
|
+
direction: "outgoing",
|
|
3420
|
+
text: null,
|
|
3421
|
+
senderDeviceId: this.selfDeviceId,
|
|
3422
|
+
epoch: wire.epoch,
|
|
3423
|
+
serverSeq: wire.server_seq,
|
|
3424
|
+
at: Date.now(),
|
|
3425
|
+
clientMsgId: args.clientMsgId,
|
|
3426
|
+
replyTo: null,
|
|
3427
|
+
envelopeType: "timer_set",
|
|
3428
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
3429
|
+
};
|
|
3430
|
+
try {
|
|
3431
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3432
|
+
} catch {
|
|
3433
|
+
}
|
|
3434
|
+
return {
|
|
3435
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3436
|
+
clientMsgId: args.clientMsgId
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3287
3439
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
3288
3440
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
3289
3441
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -3586,6 +3738,43 @@ var ReactionFold = class {
|
|
|
3586
3738
|
}
|
|
3587
3739
|
};
|
|
3588
3740
|
|
|
3741
|
+
// src/messaging/timer-fold.ts
|
|
3742
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3743
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
3744
|
+
return aSeq <= bSeq;
|
|
3745
|
+
}
|
|
3746
|
+
var TimerFold = class {
|
|
3747
|
+
cell = null;
|
|
3748
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
3749
|
+
ingest(e) {
|
|
3750
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
3751
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
3752
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
this.cell = {
|
|
3756
|
+
orderEpoch: e.epoch,
|
|
3757
|
+
orderSeq: e.serverSeq,
|
|
3758
|
+
ttlSeconds: e.ttlSeconds,
|
|
3759
|
+
start: e.start,
|
|
3760
|
+
actor: e.actorUserId
|
|
3761
|
+
};
|
|
3762
|
+
}
|
|
3763
|
+
/**
|
|
3764
|
+
* The active chat default, or null if no timer_set has applied.
|
|
3765
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
3766
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
3767
|
+
*/
|
|
3768
|
+
active() {
|
|
3769
|
+
if (this.cell === null) return null;
|
|
3770
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
3771
|
+
}
|
|
3772
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
3773
|
+
lastActor() {
|
|
3774
|
+
return this.cell?.actor ?? null;
|
|
3775
|
+
}
|
|
3776
|
+
};
|
|
3777
|
+
|
|
3589
3778
|
// src/messaging/chat.ts
|
|
3590
3779
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3591
3780
|
var Chat = class {
|
|
@@ -3612,6 +3801,22 @@ var Chat = class {
|
|
|
3612
3801
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3613
3802
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3614
3803
|
deleteFold = new DeleteFold();
|
|
3804
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
3805
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
3806
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
3807
|
+
* no per-message expiry (disappearing T10). */
|
|
3808
|
+
timerFold = new TimerFold();
|
|
3809
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
3810
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
3811
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
3812
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
3813
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
3814
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
3815
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
3816
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
3817
|
+
* tombstone (disappearing T10). */
|
|
3818
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
3819
|
+
purgedLoaded = false;
|
|
3615
3820
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3616
3821
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3617
3822
|
suppressed = /* @__PURE__ */ new Set();
|
|
@@ -3722,7 +3927,9 @@ var Chat = class {
|
|
|
3722
3927
|
replyTo: null,
|
|
3723
3928
|
edited: false,
|
|
3724
3929
|
isDeleted: true,
|
|
3725
|
-
mentions: []
|
|
3930
|
+
mentions: [],
|
|
3931
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
3932
|
+
expiresAt: null
|
|
3726
3933
|
});
|
|
3727
3934
|
continue;
|
|
3728
3935
|
}
|
|
@@ -3759,9 +3966,21 @@ var Chat = class {
|
|
|
3759
3966
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3760
3967
|
void this.loadSuppressed();
|
|
3761
3968
|
void this.loadElevated();
|
|
3762
|
-
void this.hydrateHistory();
|
|
3969
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
3763
3970
|
void this.refreshMembers();
|
|
3764
3971
|
}
|
|
3972
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
3973
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
3974
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
3975
|
+
async loadPurged() {
|
|
3976
|
+
if (this.purgedLoaded || !this._group) return;
|
|
3977
|
+
this.purgedLoaded = true;
|
|
3978
|
+
try {
|
|
3979
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
3980
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
3981
|
+
} catch {
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3765
3984
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3766
3985
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3767
3986
|
async loadSuppressed() {
|
|
@@ -3818,13 +4037,16 @@ var Chat = class {
|
|
|
3818
4037
|
if (this.seenKeys.has(key)) continue;
|
|
3819
4038
|
this.seenKeys.add(key);
|
|
3820
4039
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3821
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
4040
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
3822
4041
|
}
|
|
3823
4042
|
this.messageList.push(
|
|
3824
4043
|
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3825
4044
|
);
|
|
3826
4045
|
changed = true;
|
|
3827
4046
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
4047
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
4048
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
4049
|
+
}
|
|
3828
4050
|
}
|
|
3829
4051
|
if (changed) {
|
|
3830
4052
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3839,6 +4061,7 @@ var Chat = class {
|
|
|
3839
4061
|
return;
|
|
3840
4062
|
}
|
|
3841
4063
|
if (incoming.serverSeq <= 0) return;
|
|
4064
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
3842
4065
|
const key = this.internalKey(incoming.serverSeq);
|
|
3843
4066
|
if (this.seenKeys.has(key)) return;
|
|
3844
4067
|
this.seenKeys.add(key);
|
|
@@ -3847,6 +4070,20 @@ var Chat = class {
|
|
|
3847
4070
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
3848
4071
|
}
|
|
3849
4072
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
4073
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
4074
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
4075
|
+
if (actorUserId !== null) {
|
|
4076
|
+
this.timerFold.ingest({
|
|
4077
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
4078
|
+
start: incoming.timer.start,
|
|
4079
|
+
actorUserId,
|
|
4080
|
+
epoch: incoming.epoch,
|
|
4081
|
+
serverSeq: incoming.serverSeq,
|
|
4082
|
+
eventClientMsgId: incoming.clientMsgId
|
|
4083
|
+
});
|
|
4084
|
+
}
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
3850
4087
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
3851
4088
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3852
4089
|
if (actorUserId !== null) {
|
|
@@ -3921,7 +4158,10 @@ var Chat = class {
|
|
|
3921
4158
|
edited: false,
|
|
3922
4159
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3923
4160
|
isDeleted: false,
|
|
3924
|
-
mentions
|
|
4161
|
+
mentions,
|
|
4162
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
4163
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
4164
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
3925
4165
|
};
|
|
3926
4166
|
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3927
4167
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
@@ -3933,7 +4173,10 @@ var Chat = class {
|
|
|
3933
4173
|
if (incomingClientMsgId) {
|
|
3934
4174
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3935
4175
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3936
|
-
this.deleteFold.reevaluatePending(
|
|
4176
|
+
this.deleteFold.reevaluatePending(
|
|
4177
|
+
incomingClientMsgId,
|
|
4178
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
4179
|
+
);
|
|
3937
4180
|
}
|
|
3938
4181
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3939
4182
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3942,11 +4185,131 @@ var Chat = class {
|
|
|
3942
4185
|
incoming.serverSeq
|
|
3943
4186
|
);
|
|
3944
4187
|
this.emit();
|
|
4188
|
+
void this.armPurge(
|
|
4189
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
4190
|
+
incoming.serverSeq,
|
|
4191
|
+
incomingClientMsgId
|
|
4192
|
+
);
|
|
3945
4193
|
}
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
*
|
|
3949
|
-
|
|
4194
|
+
// ── Disappearing (TTL — T10) ──
|
|
4195
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
4196
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
4197
|
+
toExpirySpec(e) {
|
|
4198
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
4199
|
+
}
|
|
4200
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
4201
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
4202
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
4203
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
4204
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
4205
|
+
defaultExpiry() {
|
|
4206
|
+
const active = this.timerFold.active();
|
|
4207
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
4208
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
4209
|
+
}
|
|
4210
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
4211
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
4212
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
4213
|
+
* UI countdown baseline. */
|
|
4214
|
+
deadlineFor(expiry) {
|
|
4215
|
+
if (!expiry) return null;
|
|
4216
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
4217
|
+
}
|
|
4218
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
4219
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
4220
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
4221
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
4222
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
4223
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
4224
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
4225
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
4226
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
4227
|
+
const group = this._group;
|
|
4228
|
+
const fresh = {
|
|
4229
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
4230
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4231
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
4232
|
+
};
|
|
4233
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
4234
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
4235
|
+
const result = remainingSeconds({
|
|
4236
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
4237
|
+
anchor: effective,
|
|
4238
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
4239
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4240
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
4241
|
+
});
|
|
4242
|
+
let purgeInSeconds;
|
|
4243
|
+
if (result.kind === "purgeNow") {
|
|
4244
|
+
purgeInSeconds = 0;
|
|
4245
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
4246
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
4247
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
4248
|
+
} else {
|
|
4249
|
+
purgeInSeconds = result.seconds;
|
|
4250
|
+
}
|
|
4251
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4252
|
+
if (prior) clearTimeout(prior);
|
|
4253
|
+
this.purgeTimers.delete(serverSeq);
|
|
4254
|
+
if (purgeInSeconds <= 0) {
|
|
4255
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4256
|
+
return;
|
|
4257
|
+
}
|
|
4258
|
+
const handle = setTimeout(() => {
|
|
4259
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4260
|
+
}, purgeInSeconds * 1e3);
|
|
4261
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4262
|
+
}
|
|
4263
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
4264
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
4265
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
4266
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
4267
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
4268
|
+
if (!this._group) return;
|
|
4269
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
4270
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4271
|
+
if (prior) clearTimeout(prior);
|
|
4272
|
+
this.purgeTimers.delete(serverSeq);
|
|
4273
|
+
if (remainingMs <= 0) {
|
|
4274
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
const handle = setTimeout(() => {
|
|
4278
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4279
|
+
}, remainingMs);
|
|
4280
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4281
|
+
}
|
|
4282
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
4283
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
4284
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
4285
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
4286
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
4287
|
+
async purge(serverSeq, clientMsgId) {
|
|
4288
|
+
if (!this._group) return;
|
|
4289
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4290
|
+
if (prior) clearTimeout(prior);
|
|
4291
|
+
this.purgeTimers.delete(serverSeq);
|
|
4292
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
4293
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
4294
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
4295
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
4296
|
+
this.emit();
|
|
4297
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
4298
|
+
if (clientMsgId) {
|
|
4299
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
4303
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
4304
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
4305
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
4306
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
4307
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
4308
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
4309
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
4310
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
4311
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
4312
|
+
};
|
|
3950
4313
|
// ── Mentions (mentions T6) ──
|
|
3951
4314
|
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3952
4315
|
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
@@ -4232,11 +4595,63 @@ var Chat = class {
|
|
|
4232
4595
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
4233
4596
|
}
|
|
4234
4597
|
const bodyRanges = opts?.mentions ?? null;
|
|
4235
|
-
const
|
|
4236
|
-
|
|
4598
|
+
const effectiveExpiry = this.composeExpiry(opts?.expiresIn);
|
|
4599
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
4600
|
+
group,
|
|
4601
|
+
text,
|
|
4602
|
+
replyRef,
|
|
4603
|
+
bodyRanges,
|
|
4604
|
+
effectiveExpiry
|
|
4605
|
+
);
|
|
4606
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, effectiveExpiry);
|
|
4607
|
+
if (effectiveExpiry) void this.armPurge(effectiveExpiry, receipt.serverSeq, clientMsgId);
|
|
4237
4608
|
return receipt;
|
|
4238
4609
|
}
|
|
4239
|
-
|
|
4610
|
+
/** Resolve a send's effective per-message expiry at COMPOSE TIME: the caller's explicit
|
|
4611
|
+
* `expiresIn` if present, ELSE the chat's active default timer stamped onto the message
|
|
4612
|
+
* NOW (the durable record per spec §"Compose-time stamping"). A `send`-anchored expiry
|
|
4613
|
+
* (explicit or default-inherited) stamps `senderSendTs` = the sender's compose epoch
|
|
4614
|
+
* seconds; a `read`-anchored one carries none (the deadline is the recipient's local
|
|
4615
|
+
* first-read). Returns null when there is neither an explicit expiry nor an active
|
|
4616
|
+
* default (a plain, non-disappearing send). Mirrors the iOS compose-time stamping. */
|
|
4617
|
+
composeExpiry(explicit) {
|
|
4618
|
+
const base = explicit ? {
|
|
4619
|
+
v: 1,
|
|
4620
|
+
ttlSeconds: explicit.ttlSeconds,
|
|
4621
|
+
start: explicit.start ?? "send",
|
|
4622
|
+
senderSendTs: null
|
|
4623
|
+
} : this.defaultExpiry();
|
|
4624
|
+
if (!base) return null;
|
|
4625
|
+
return {
|
|
4626
|
+
...base,
|
|
4627
|
+
senderSendTs: base.start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
4628
|
+
};
|
|
4629
|
+
}
|
|
4630
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
4631
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
4632
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
4633
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
4634
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
4635
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
4636
|
+
async setDisappearing(opts) {
|
|
4637
|
+
const group = await this.materializeIfNeeded();
|
|
4638
|
+
const clientMsgId = mintClientMsgId();
|
|
4639
|
+
const start = opts.start ?? "send";
|
|
4640
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
4641
|
+
clientMsgId,
|
|
4642
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4643
|
+
start
|
|
4644
|
+
});
|
|
4645
|
+
this.timerFold.ingest({
|
|
4646
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4647
|
+
start,
|
|
4648
|
+
actorUserId: this.backend.selfUserId,
|
|
4649
|
+
epoch: receipt.epoch,
|
|
4650
|
+
serverSeq: receipt.serverSeq,
|
|
4651
|
+
eventClientMsgId: clientMsgId
|
|
4652
|
+
});
|
|
4653
|
+
}
|
|
4654
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, expiry) {
|
|
4240
4655
|
if (receipt.serverSeq <= 0) return;
|
|
4241
4656
|
const key = this.internalKey(receipt.serverSeq);
|
|
4242
4657
|
if (this.seenKeys.has(key)) return;
|
|
@@ -4264,7 +4679,12 @@ var Chat = class {
|
|
|
4264
4679
|
isDeleted: false,
|
|
4265
4680
|
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4266
4681
|
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4267
|
-
mentions: this.resolveMentions(text, bodyRanges)
|
|
4682
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
4683
|
+
// Disappearing T10: the surfaced deadline reflects the message's effective expiry
|
|
4684
|
+
// (explicit `expiresIn` OR the chat default stamped at compose time). null only when
|
|
4685
|
+
// this send is non-disappearing. armPurge re-derives the durable monotonic deadline;
|
|
4686
|
+
// this is the immediate UI countdown baseline (own sender and receiver are symmetric).
|
|
4687
|
+
expiresAt: this.deadlineFor(expiry ?? null)
|
|
4268
4688
|
});
|
|
4269
4689
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
4270
4690
|
this.emit();
|
|
@@ -4619,6 +5039,7 @@ var MessageDeliverySource = class {
|
|
|
4619
5039
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4620
5040
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4621
5041
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
5042
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
4622
5043
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4623
5044
|
const stored = {
|
|
4624
5045
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4676,7 +5097,18 @@ var MessageDeliverySource = class {
|
|
|
4676
5097
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4677
5098
|
scope: decoded.delete.scope
|
|
4678
5099
|
}
|
|
4679
|
-
} : {}
|
|
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 } : {}
|
|
4680
5112
|
};
|
|
4681
5113
|
try {
|
|
4682
5114
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -4699,7 +5131,12 @@ var MessageDeliverySource = class {
|
|
|
4699
5131
|
delete: isDelete ? decoded.delete : null,
|
|
4700
5132
|
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4701
5133
|
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4702
|
-
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
|
|
4703
5140
|
});
|
|
4704
5141
|
return true;
|
|
4705
5142
|
}
|
|
@@ -4778,6 +5215,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
4778
5215
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
4779
5216
|
}
|
|
4780
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
|
+
|
|
4781
5279
|
// src/messaging/history.ts
|
|
4782
5280
|
var MessageStore = class {
|
|
4783
5281
|
constructor(kv) {
|
|
@@ -6592,6 +7090,7 @@ var MessagingCoordinator = class {
|
|
|
6592
7090
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6593
7091
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6594
7092
|
this.elevationStore = new MentionElevationStore(this.kv);
|
|
7093
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
6595
7094
|
this.registry.attachChatList(
|
|
6596
7095
|
(chats) => {
|
|
6597
7096
|
this.chatList = chats;
|
|
@@ -6608,6 +7107,7 @@ var MessagingCoordinator = class {
|
|
|
6608
7107
|
kpStore;
|
|
6609
7108
|
suppressionStore;
|
|
6610
7109
|
elevationStore;
|
|
7110
|
+
disappearingStore;
|
|
6611
7111
|
registry = new GroupRegistry();
|
|
6612
7112
|
resolved = null;
|
|
6613
7113
|
resolvePromise = null;
|
|
@@ -6779,6 +7279,10 @@ var MessagingCoordinator = class {
|
|
|
6779
7279
|
const r = await this.resolve();
|
|
6780
7280
|
return r.groups.sendDelete(group, args);
|
|
6781
7281
|
}
|
|
7282
|
+
async sendTimerSet(group, args) {
|
|
7283
|
+
const r = await this.resolve();
|
|
7284
|
+
return r.groups.sendTimerSet(group, args);
|
|
7285
|
+
}
|
|
6782
7286
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6783
7287
|
loadSuppressed(group) {
|
|
6784
7288
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -6795,10 +7299,28 @@ var MessagingCoordinator = class {
|
|
|
6795
7299
|
saveElevated(group, keys) {
|
|
6796
7300
|
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6797
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
|
+
}
|
|
6798
7318
|
async history(group, limit, before) {
|
|
6799
7319
|
const r = await this.resolve();
|
|
6800
7320
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
6801
|
-
|
|
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);
|
|
6802
7324
|
}
|
|
6803
7325
|
async members(group) {
|
|
6804
7326
|
const r = await this.resolve();
|
|
@@ -6875,9 +7397,10 @@ var MessagingCoordinator = class {
|
|
|
6875
7397
|
return res.devices.map((d) => d.device_id);
|
|
6876
7398
|
}
|
|
6877
7399
|
};
|
|
6878
|
-
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));
|
|
6879
7402
|
const fold = new ReactionFold();
|
|
6880
|
-
for (const s of
|
|
7403
|
+
for (const s of visible) {
|
|
6881
7404
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
6882
7405
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6883
7406
|
if (actor === null) continue;
|
|
@@ -6893,17 +7416,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6893
7416
|
}
|
|
6894
7417
|
const editFold = new EditFold();
|
|
6895
7418
|
const deleteFold = new DeleteFold();
|
|
7419
|
+
const pageTimerFold = new TimerFold();
|
|
6896
7420
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6897
|
-
for (const s of
|
|
6898
|
-
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")
|
|
6899
7423
|
continue;
|
|
6900
7424
|
const cid = s.clientMsgId ?? "";
|
|
6901
7425
|
if (!cid) continue;
|
|
6902
7426
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6903
7427
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6904
7428
|
}
|
|
6905
|
-
const authorOfTarget = (cid) =>
|
|
6906
|
-
|
|
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) {
|
|
6907
7435
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6908
7436
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6909
7437
|
editFold.ingest(
|
|
@@ -6922,7 +7450,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6922
7450
|
);
|
|
6923
7451
|
}
|
|
6924
7452
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6925
|
-
for (const s of
|
|
7453
|
+
for (const s of visible) {
|
|
6926
7454
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6927
7455
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6928
7456
|
deleteFold.ingest(
|
|
@@ -6936,10 +7464,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6936
7464
|
authorOfTarget
|
|
6937
7465
|
);
|
|
6938
7466
|
}
|
|
6939
|
-
for (const [cid
|
|
7467
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
6940
7468
|
const lookup = /* @__PURE__ */ new Map();
|
|
6941
|
-
for (const s of
|
|
6942
|
-
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")
|
|
6943
7471
|
continue;
|
|
6944
7472
|
const cid = s.clientMsgId ?? "";
|
|
6945
7473
|
if (cid && s.text !== null) {
|
|
@@ -6948,7 +7476,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6948
7476
|
}
|
|
6949
7477
|
}
|
|
6950
7478
|
const out = [];
|
|
6951
|
-
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
|
+
}
|
|
6952
7494
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6953
7495
|
continue;
|
|
6954
7496
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -6968,10 +7510,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6968
7510
|
edited: false,
|
|
6969
7511
|
isDeleted: true,
|
|
6970
7512
|
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6971
|
-
mentions: []
|
|
7513
|
+
mentions: [],
|
|
7514
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
7515
|
+
expiresAt: null
|
|
6972
7516
|
});
|
|
6973
7517
|
continue;
|
|
6974
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
|
+
}
|
|
6975
7528
|
let replyTo = null;
|
|
6976
7529
|
if (s.replyTo) {
|
|
6977
7530
|
const ref = {
|
|
@@ -7004,7 +7557,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
7004
7557
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
7005
7558
|
edited,
|
|
7006
7559
|
isDeleted: false,
|
|
7007
|
-
mentions
|
|
7560
|
+
mentions,
|
|
7561
|
+
expiresAt
|
|
7008
7562
|
});
|
|
7009
7563
|
}
|
|
7010
7564
|
return out;
|
|
@@ -7751,7 +8305,7 @@ function defaultSessionStorage(key) {
|
|
|
7751
8305
|
}
|
|
7752
8306
|
|
|
7753
8307
|
// src/version.ts
|
|
7754
|
-
var VERSION = "1.
|
|
8308
|
+
var VERSION = "1.6.1";
|
|
7755
8309
|
|
|
7756
8310
|
// src/runtime.ts
|
|
7757
8311
|
function buildRuntime(config) {
|