@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
|
@@ -2591,6 +2591,47 @@ var PalbeFlags = class {
|
|
|
2591
2591
|
}
|
|
2592
2592
|
};
|
|
2593
2593
|
|
|
2594
|
+
// src/messaging/deadline-calculator.ts
|
|
2595
|
+
function remainingSeconds(args) {
|
|
2596
|
+
const ttl = args.ttlSeconds;
|
|
2597
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
2598
|
+
let elapsed;
|
|
2599
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
2600
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
2601
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
2602
|
+
} else {
|
|
2603
|
+
elapsed = wallDeltaSec;
|
|
2604
|
+
}
|
|
2605
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
2606
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
2607
|
+
}
|
|
2608
|
+
var cachedBootToken = null;
|
|
2609
|
+
var MonotonicClock = {
|
|
2610
|
+
nowMs() {
|
|
2611
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2612
|
+
},
|
|
2613
|
+
nowWallEpochMs() {
|
|
2614
|
+
return Date.now();
|
|
2615
|
+
},
|
|
2616
|
+
bootToken() {
|
|
2617
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
2618
|
+
try {
|
|
2619
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
2620
|
+
if (existing) {
|
|
2621
|
+
cachedBootToken = existing;
|
|
2622
|
+
return existing;
|
|
2623
|
+
}
|
|
2624
|
+
const fresh = crypto.randomUUID();
|
|
2625
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
2626
|
+
cachedBootToken = fresh;
|
|
2627
|
+
return fresh;
|
|
2628
|
+
} catch {
|
|
2629
|
+
cachedBootToken = crypto.randomUUID();
|
|
2630
|
+
return cachedBootToken;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
|
|
2594
2635
|
// src/messaging/delete-fold.ts
|
|
2595
2636
|
var DeleteFold = class {
|
|
2596
2637
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -2603,17 +2644,24 @@ var DeleteFold = class {
|
|
|
2603
2644
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2604
2645
|
held = [];
|
|
2605
2646
|
/**
|
|
2606
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
2607
|
-
*
|
|
2647
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
2648
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
2649
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
2650
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
2651
|
+
* park in pending, never re-attempt).
|
|
2608
2652
|
*/
|
|
2609
2653
|
ingest(e, authorOfTarget) {
|
|
2610
2654
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2611
2655
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2612
2656
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2613
|
-
const
|
|
2614
|
-
if (
|
|
2657
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2658
|
+
if (res.kind === "purged") {
|
|
2659
|
+
this.seen.add(e.eventClientMsgId);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
if (res.kind === "author") {
|
|
2615
2663
|
this.seen.add(e.eventClientMsgId);
|
|
2616
|
-
if (e.actorUserId === null || e.actorUserId !==
|
|
2664
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
2617
2665
|
this.tombstoned.add(e.targetClientMsgId);
|
|
2618
2666
|
} else if (e.actorUserId !== null) {
|
|
2619
2667
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -2633,12 +2681,13 @@ var DeleteFold = class {
|
|
|
2633
2681
|
* the in-order path.
|
|
2634
2682
|
*/
|
|
2635
2683
|
reevaluatePending(target, author) {
|
|
2684
|
+
const res = author;
|
|
2636
2685
|
const actor = this.pending.get(target);
|
|
2637
2686
|
if (actor !== void 0) {
|
|
2638
|
-
if (
|
|
2639
|
-
this.tombstoned.add(target);
|
|
2687
|
+
if (res.kind === "author") {
|
|
2688
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
2640
2689
|
this.pending.delete(target);
|
|
2641
|
-
} else if (
|
|
2690
|
+
} else if (res.kind === "purged") {
|
|
2642
2691
|
this.pending.delete(target);
|
|
2643
2692
|
}
|
|
2644
2693
|
}
|
|
@@ -2646,7 +2695,7 @@ var DeleteFold = class {
|
|
|
2646
2695
|
const pendingHeld = this.held;
|
|
2647
2696
|
this.held = [];
|
|
2648
2697
|
for (const e of pendingHeld) {
|
|
2649
|
-
this.ingest(e, (t) => t === target ?
|
|
2698
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
2650
2699
|
}
|
|
2651
2700
|
}
|
|
2652
2701
|
heldContains(eventClientMsgId) {
|
|
@@ -2672,15 +2721,23 @@ var EditFold = class {
|
|
|
2672
2721
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
2673
2722
|
editedTargets = /* @__PURE__ */ new Set();
|
|
2674
2723
|
/**
|
|
2675
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
2676
|
-
*
|
|
2724
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
2725
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
2726
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
2727
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
2728
|
+
* and a later author "resolution" cannot resurrect it).
|
|
2677
2729
|
*/
|
|
2678
2730
|
ingest(e, authorOfTarget) {
|
|
2679
|
-
const
|
|
2680
|
-
if (
|
|
2731
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2732
|
+
if (res.kind === "unknown") {
|
|
2681
2733
|
this.holdIfNew(e);
|
|
2682
2734
|
return;
|
|
2683
2735
|
}
|
|
2736
|
+
if (res.kind === "purged") {
|
|
2737
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
const author = res.userId;
|
|
2684
2741
|
if (e.editorUserId === null) {
|
|
2685
2742
|
this.holdIfNew(e);
|
|
2686
2743
|
return;
|
|
@@ -2939,14 +2996,46 @@ function encodeEnvelope(args) {
|
|
|
2939
2996
|
length: r.length,
|
|
2940
2997
|
mentioned_user_id: r.mentionedUserId
|
|
2941
2998
|
}))
|
|
2999
|
+
} : {},
|
|
3000
|
+
...args.expiry ? {
|
|
3001
|
+
expiry: {
|
|
3002
|
+
v: args.expiry.v,
|
|
3003
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
3004
|
+
start: args.expiry.start,
|
|
3005
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
3006
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
3007
|
+
}
|
|
2942
3008
|
} : {}
|
|
2943
3009
|
};
|
|
2944
3010
|
return encodeUtf8(JSON.stringify(env));
|
|
2945
3011
|
}
|
|
3012
|
+
function encodeTimerSet(args) {
|
|
3013
|
+
return encodeUtf8(
|
|
3014
|
+
JSON.stringify({
|
|
3015
|
+
v: 1,
|
|
3016
|
+
type: "timer_set",
|
|
3017
|
+
client_msg_id: args.clientMsgId,
|
|
3018
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
3019
|
+
start: args.start
|
|
3020
|
+
})
|
|
3021
|
+
);
|
|
3022
|
+
}
|
|
2946
3023
|
function decodeEnvelope(bytes) {
|
|
2947
3024
|
const s = decodeUtf8(bytes);
|
|
2948
3025
|
try {
|
|
2949
3026
|
const o = JSON.parse(s);
|
|
3027
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
3028
|
+
return {
|
|
3029
|
+
type: "timer_set",
|
|
3030
|
+
text: null,
|
|
3031
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
3032
|
+
replyTo: null,
|
|
3033
|
+
timer: {
|
|
3034
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
3035
|
+
start: o.start === "read" ? "read" : "send"
|
|
3036
|
+
}
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
2950
3039
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2951
3040
|
return {
|
|
2952
3041
|
type: "delete",
|
|
@@ -2988,12 +3077,14 @@ function decodeEnvelope(bytes) {
|
|
|
2988
3077
|
}
|
|
2989
3078
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2990
3079
|
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
3080
|
+
const expiry = decodeExpiry(o.expiry);
|
|
2991
3081
|
return {
|
|
2992
3082
|
type: "text",
|
|
2993
3083
|
text: o.text ?? null,
|
|
2994
3084
|
clientMsgId: o.client_msg_id ?? "",
|
|
2995
3085
|
replyTo: o.reply_to ?? null,
|
|
2996
|
-
...textRanges ? { bodyRanges: textRanges } : {}
|
|
3086
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
3087
|
+
...expiry ? { expiry } : {}
|
|
2997
3088
|
};
|
|
2998
3089
|
}
|
|
2999
3090
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -3005,6 +3096,19 @@ function decodeEnvelope(bytes) {
|
|
|
3005
3096
|
}
|
|
3006
3097
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
3007
3098
|
}
|
|
3099
|
+
function decodeExpiry(raw) {
|
|
3100
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
3101
|
+
const o = raw;
|
|
3102
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
3103
|
+
const start = o.start === "read" ? "read" : "send";
|
|
3104
|
+
return {
|
|
3105
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
3106
|
+
ttlSeconds: o.ttl_seconds,
|
|
3107
|
+
start,
|
|
3108
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
3109
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3008
3112
|
function decodeBodyRanges(raw) {
|
|
3009
3113
|
if (!raw || raw.length === 0) return void 0;
|
|
3010
3114
|
return raw.map((r) => ({
|
|
@@ -3224,9 +3328,9 @@ var GroupMessaging = class {
|
|
|
3224
3328
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3225
3329
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3226
3330
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3227
|
-
async sendText(group, text, replyTo, bodyRanges) {
|
|
3331
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
3228
3332
|
const clientMsgId = mintClientMsgId();
|
|
3229
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
3333
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
3230
3334
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3231
3335
|
const body = {
|
|
3232
3336
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3255,7 +3359,10 @@ var GroupMessaging = class {
|
|
|
3255
3359
|
} : null,
|
|
3256
3360
|
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3257
3361
|
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3258
|
-
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
3362
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
3363
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
3364
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
3365
|
+
...expiry ? { expiry } : {}
|
|
3259
3366
|
};
|
|
3260
3367
|
try {
|
|
3261
3368
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3263,6 +3370,51 @@ var GroupMessaging = class {
|
|
|
3263
3370
|
}
|
|
3264
3371
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
3265
3372
|
}
|
|
3373
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
3374
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
3375
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
3376
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
3377
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
3378
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
3379
|
+
async sendTimerSet(group, args) {
|
|
3380
|
+
const plaintext = encodeTimerSet({
|
|
3381
|
+
clientMsgId: args.clientMsgId,
|
|
3382
|
+
ttlSeconds: args.ttlSeconds,
|
|
3383
|
+
start: args.start
|
|
3384
|
+
});
|
|
3385
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3386
|
+
const body = {
|
|
3387
|
+
ciphertext_b64: toBase64(ct),
|
|
3388
|
+
client_idem_key: randomId()
|
|
3389
|
+
};
|
|
3390
|
+
const wire = await palbeRequest(
|
|
3391
|
+
this.rt,
|
|
3392
|
+
"POST",
|
|
3393
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3394
|
+
{ body }
|
|
3395
|
+
);
|
|
3396
|
+
const stored = {
|
|
3397
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3398
|
+
direction: "outgoing",
|
|
3399
|
+
text: null,
|
|
3400
|
+
senderDeviceId: this.selfDeviceId,
|
|
3401
|
+
epoch: wire.epoch,
|
|
3402
|
+
serverSeq: wire.server_seq,
|
|
3403
|
+
at: Date.now(),
|
|
3404
|
+
clientMsgId: args.clientMsgId,
|
|
3405
|
+
replyTo: null,
|
|
3406
|
+
envelopeType: "timer_set",
|
|
3407
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
3408
|
+
};
|
|
3409
|
+
try {
|
|
3410
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3411
|
+
} catch {
|
|
3412
|
+
}
|
|
3413
|
+
return {
|
|
3414
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3415
|
+
clientMsgId: args.clientMsgId
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3266
3418
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
3267
3419
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
3268
3420
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -3565,6 +3717,43 @@ var ReactionFold = class {
|
|
|
3565
3717
|
}
|
|
3566
3718
|
};
|
|
3567
3719
|
|
|
3720
|
+
// src/messaging/timer-fold.ts
|
|
3721
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3722
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
3723
|
+
return aSeq <= bSeq;
|
|
3724
|
+
}
|
|
3725
|
+
var TimerFold = class {
|
|
3726
|
+
cell = null;
|
|
3727
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
3728
|
+
ingest(e) {
|
|
3729
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
3730
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
3731
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
this.cell = {
|
|
3735
|
+
orderEpoch: e.epoch,
|
|
3736
|
+
orderSeq: e.serverSeq,
|
|
3737
|
+
ttlSeconds: e.ttlSeconds,
|
|
3738
|
+
start: e.start,
|
|
3739
|
+
actor: e.actorUserId
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
/**
|
|
3743
|
+
* The active chat default, or null if no timer_set has applied.
|
|
3744
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
3745
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
3746
|
+
*/
|
|
3747
|
+
active() {
|
|
3748
|
+
if (this.cell === null) return null;
|
|
3749
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
3750
|
+
}
|
|
3751
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
3752
|
+
lastActor() {
|
|
3753
|
+
return this.cell?.actor ?? null;
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
|
|
3568
3757
|
// src/messaging/chat.ts
|
|
3569
3758
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3570
3759
|
var Chat = class {
|
|
@@ -3591,6 +3780,22 @@ var Chat = class {
|
|
|
3591
3780
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3592
3781
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3593
3782
|
deleteFold = new DeleteFold();
|
|
3783
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
3784
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
3785
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
3786
|
+
* no per-message expiry (disappearing T10). */
|
|
3787
|
+
timerFold = new TimerFold();
|
|
3788
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
3789
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
3790
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
3791
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
3792
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
3793
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
3794
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
3795
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
3796
|
+
* tombstone (disappearing T10). */
|
|
3797
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
3798
|
+
purgedLoaded = false;
|
|
3594
3799
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3595
3800
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3596
3801
|
suppressed = /* @__PURE__ */ new Set();
|
|
@@ -3701,7 +3906,9 @@ var Chat = class {
|
|
|
3701
3906
|
replyTo: null,
|
|
3702
3907
|
edited: false,
|
|
3703
3908
|
isDeleted: true,
|
|
3704
|
-
mentions: []
|
|
3909
|
+
mentions: [],
|
|
3910
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
3911
|
+
expiresAt: null
|
|
3705
3912
|
});
|
|
3706
3913
|
continue;
|
|
3707
3914
|
}
|
|
@@ -3738,9 +3945,21 @@ var Chat = class {
|
|
|
3738
3945
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3739
3946
|
void this.loadSuppressed();
|
|
3740
3947
|
void this.loadElevated();
|
|
3741
|
-
void this.hydrateHistory();
|
|
3948
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
3742
3949
|
void this.refreshMembers();
|
|
3743
3950
|
}
|
|
3951
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
3952
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
3953
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
3954
|
+
async loadPurged() {
|
|
3955
|
+
if (this.purgedLoaded || !this._group) return;
|
|
3956
|
+
this.purgedLoaded = true;
|
|
3957
|
+
try {
|
|
3958
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
3959
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
3960
|
+
} catch {
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3744
3963
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3745
3964
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3746
3965
|
async loadSuppressed() {
|
|
@@ -3797,13 +4016,16 @@ var Chat = class {
|
|
|
3797
4016
|
if (this.seenKeys.has(key)) continue;
|
|
3798
4017
|
this.seenKeys.add(key);
|
|
3799
4018
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3800
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
4019
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
3801
4020
|
}
|
|
3802
4021
|
this.messageList.push(
|
|
3803
4022
|
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3804
4023
|
);
|
|
3805
4024
|
changed = true;
|
|
3806
4025
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
4026
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
4027
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
4028
|
+
}
|
|
3807
4029
|
}
|
|
3808
4030
|
if (changed) {
|
|
3809
4031
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3818,6 +4040,7 @@ var Chat = class {
|
|
|
3818
4040
|
return;
|
|
3819
4041
|
}
|
|
3820
4042
|
if (incoming.serverSeq <= 0) return;
|
|
4043
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
3821
4044
|
const key = this.internalKey(incoming.serverSeq);
|
|
3822
4045
|
if (this.seenKeys.has(key)) return;
|
|
3823
4046
|
this.seenKeys.add(key);
|
|
@@ -3826,6 +4049,20 @@ var Chat = class {
|
|
|
3826
4049
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
3827
4050
|
}
|
|
3828
4051
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
4052
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
4053
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
4054
|
+
if (actorUserId !== null) {
|
|
4055
|
+
this.timerFold.ingest({
|
|
4056
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
4057
|
+
start: incoming.timer.start,
|
|
4058
|
+
actorUserId,
|
|
4059
|
+
epoch: incoming.epoch,
|
|
4060
|
+
serverSeq: incoming.serverSeq,
|
|
4061
|
+
eventClientMsgId: incoming.clientMsgId
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
4064
|
+
return;
|
|
4065
|
+
}
|
|
3829
4066
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
3830
4067
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3831
4068
|
if (actorUserId !== null) {
|
|
@@ -3900,7 +4137,10 @@ var Chat = class {
|
|
|
3900
4137
|
edited: false,
|
|
3901
4138
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3902
4139
|
isDeleted: false,
|
|
3903
|
-
mentions
|
|
4140
|
+
mentions,
|
|
4141
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
4142
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
4143
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
3904
4144
|
};
|
|
3905
4145
|
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3906
4146
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
@@ -3912,7 +4152,10 @@ var Chat = class {
|
|
|
3912
4152
|
if (incomingClientMsgId) {
|
|
3913
4153
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3914
4154
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3915
|
-
this.deleteFold.reevaluatePending(
|
|
4155
|
+
this.deleteFold.reevaluatePending(
|
|
4156
|
+
incomingClientMsgId,
|
|
4157
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
4158
|
+
);
|
|
3916
4159
|
}
|
|
3917
4160
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3918
4161
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3921,11 +4164,131 @@ var Chat = class {
|
|
|
3921
4164
|
incoming.serverSeq
|
|
3922
4165
|
);
|
|
3923
4166
|
this.emit();
|
|
4167
|
+
void this.armPurge(
|
|
4168
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
4169
|
+
incoming.serverSeq,
|
|
4170
|
+
incomingClientMsgId
|
|
4171
|
+
);
|
|
3924
4172
|
}
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
*
|
|
3928
|
-
|
|
4173
|
+
// ── Disappearing (TTL — T10) ──
|
|
4174
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
4175
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
4176
|
+
toExpirySpec(e) {
|
|
4177
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
4178
|
+
}
|
|
4179
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
4180
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
4181
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
4182
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
4183
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
4184
|
+
defaultExpiry() {
|
|
4185
|
+
const active = this.timerFold.active();
|
|
4186
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
4187
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
4188
|
+
}
|
|
4189
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
4190
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
4191
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
4192
|
+
* UI countdown baseline. */
|
|
4193
|
+
deadlineFor(expiry) {
|
|
4194
|
+
if (!expiry) return null;
|
|
4195
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
4196
|
+
}
|
|
4197
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
4198
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
4199
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
4200
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
4201
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
4202
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
4203
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
4204
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
4205
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
4206
|
+
const group = this._group;
|
|
4207
|
+
const fresh = {
|
|
4208
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
4209
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4210
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
4211
|
+
};
|
|
4212
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
4213
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
4214
|
+
const result = remainingSeconds({
|
|
4215
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
4216
|
+
anchor: effective,
|
|
4217
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
4218
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4219
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
4220
|
+
});
|
|
4221
|
+
let purgeInSeconds;
|
|
4222
|
+
if (result.kind === "purgeNow") {
|
|
4223
|
+
purgeInSeconds = 0;
|
|
4224
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
4225
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
4226
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
4227
|
+
} else {
|
|
4228
|
+
purgeInSeconds = result.seconds;
|
|
4229
|
+
}
|
|
4230
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4231
|
+
if (prior) clearTimeout(prior);
|
|
4232
|
+
this.purgeTimers.delete(serverSeq);
|
|
4233
|
+
if (purgeInSeconds <= 0) {
|
|
4234
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
const handle = setTimeout(() => {
|
|
4238
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4239
|
+
}, purgeInSeconds * 1e3);
|
|
4240
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4241
|
+
}
|
|
4242
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
4243
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
4244
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
4245
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
4246
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
4247
|
+
if (!this._group) return;
|
|
4248
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
4249
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4250
|
+
if (prior) clearTimeout(prior);
|
|
4251
|
+
this.purgeTimers.delete(serverSeq);
|
|
4252
|
+
if (remainingMs <= 0) {
|
|
4253
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4254
|
+
return;
|
|
4255
|
+
}
|
|
4256
|
+
const handle = setTimeout(() => {
|
|
4257
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4258
|
+
}, remainingMs);
|
|
4259
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4260
|
+
}
|
|
4261
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
4262
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
4263
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
4264
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
4265
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
4266
|
+
async purge(serverSeq, clientMsgId) {
|
|
4267
|
+
if (!this._group) return;
|
|
4268
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4269
|
+
if (prior) clearTimeout(prior);
|
|
4270
|
+
this.purgeTimers.delete(serverSeq);
|
|
4271
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
4272
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
4273
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
4274
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
4275
|
+
this.emit();
|
|
4276
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
4277
|
+
if (clientMsgId) {
|
|
4278
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
4282
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
4283
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
4284
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
4285
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
4286
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
4287
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
4288
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
4289
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
4290
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
4291
|
+
};
|
|
3929
4292
|
// ── Mentions (mentions T6) ──
|
|
3930
4293
|
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3931
4294
|
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
@@ -4211,11 +4574,63 @@ var Chat = class {
|
|
|
4211
4574
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
4212
4575
|
}
|
|
4213
4576
|
const bodyRanges = opts?.mentions ?? null;
|
|
4214
|
-
const
|
|
4215
|
-
|
|
4577
|
+
const effectiveExpiry = this.composeExpiry(opts?.expiresIn);
|
|
4578
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
4579
|
+
group,
|
|
4580
|
+
text,
|
|
4581
|
+
replyRef,
|
|
4582
|
+
bodyRanges,
|
|
4583
|
+
effectiveExpiry
|
|
4584
|
+
);
|
|
4585
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, effectiveExpiry);
|
|
4586
|
+
if (effectiveExpiry) void this.armPurge(effectiveExpiry, receipt.serverSeq, clientMsgId);
|
|
4216
4587
|
return receipt;
|
|
4217
4588
|
}
|
|
4218
|
-
|
|
4589
|
+
/** Resolve a send's effective per-message expiry at COMPOSE TIME: the caller's explicit
|
|
4590
|
+
* `expiresIn` if present, ELSE the chat's active default timer stamped onto the message
|
|
4591
|
+
* NOW (the durable record per spec §"Compose-time stamping"). A `send`-anchored expiry
|
|
4592
|
+
* (explicit or default-inherited) stamps `senderSendTs` = the sender's compose epoch
|
|
4593
|
+
* seconds; a `read`-anchored one carries none (the deadline is the recipient's local
|
|
4594
|
+
* first-read). Returns null when there is neither an explicit expiry nor an active
|
|
4595
|
+
* default (a plain, non-disappearing send). Mirrors the iOS compose-time stamping. */
|
|
4596
|
+
composeExpiry(explicit) {
|
|
4597
|
+
const base = explicit ? {
|
|
4598
|
+
v: 1,
|
|
4599
|
+
ttlSeconds: explicit.ttlSeconds,
|
|
4600
|
+
start: explicit.start ?? "send",
|
|
4601
|
+
senderSendTs: null
|
|
4602
|
+
} : this.defaultExpiry();
|
|
4603
|
+
if (!base) return null;
|
|
4604
|
+
return {
|
|
4605
|
+
...base,
|
|
4606
|
+
senderSendTs: base.start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
4607
|
+
};
|
|
4608
|
+
}
|
|
4609
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
4610
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
4611
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
4612
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
4613
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
4614
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
4615
|
+
async setDisappearing(opts) {
|
|
4616
|
+
const group = await this.materializeIfNeeded();
|
|
4617
|
+
const clientMsgId = mintClientMsgId();
|
|
4618
|
+
const start = opts.start ?? "send";
|
|
4619
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
4620
|
+
clientMsgId,
|
|
4621
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4622
|
+
start
|
|
4623
|
+
});
|
|
4624
|
+
this.timerFold.ingest({
|
|
4625
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4626
|
+
start,
|
|
4627
|
+
actorUserId: this.backend.selfUserId,
|
|
4628
|
+
epoch: receipt.epoch,
|
|
4629
|
+
serverSeq: receipt.serverSeq,
|
|
4630
|
+
eventClientMsgId: clientMsgId
|
|
4631
|
+
});
|
|
4632
|
+
}
|
|
4633
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, expiry) {
|
|
4219
4634
|
if (receipt.serverSeq <= 0) return;
|
|
4220
4635
|
const key = this.internalKey(receipt.serverSeq);
|
|
4221
4636
|
if (this.seenKeys.has(key)) return;
|
|
@@ -4243,7 +4658,12 @@ var Chat = class {
|
|
|
4243
4658
|
isDeleted: false,
|
|
4244
4659
|
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4245
4660
|
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4246
|
-
mentions: this.resolveMentions(text, bodyRanges)
|
|
4661
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
4662
|
+
// Disappearing T10: the surfaced deadline reflects the message's effective expiry
|
|
4663
|
+
// (explicit `expiresIn` OR the chat default stamped at compose time). null only when
|
|
4664
|
+
// this send is non-disappearing. armPurge re-derives the durable monotonic deadline;
|
|
4665
|
+
// this is the immediate UI countdown baseline (own sender and receiver are symmetric).
|
|
4666
|
+
expiresAt: this.deadlineFor(expiry ?? null)
|
|
4247
4667
|
});
|
|
4248
4668
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
4249
4669
|
this.emit();
|
|
@@ -4598,6 +5018,7 @@ var MessageDeliverySource = class {
|
|
|
4598
5018
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4599
5019
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4600
5020
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
5021
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
4601
5022
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4602
5023
|
const stored = {
|
|
4603
5024
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4655,7 +5076,18 @@ var MessageDeliverySource = class {
|
|
|
4655
5076
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4656
5077
|
scope: decoded.delete.scope
|
|
4657
5078
|
}
|
|
4658
|
-
} : {}
|
|
5079
|
+
} : {},
|
|
5080
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
5081
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
5082
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
5083
|
+
...isTimerSet && decoded.timer ? {
|
|
5084
|
+
envelopeType: "timer_set",
|
|
5085
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
5086
|
+
} : {},
|
|
5087
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
5088
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
5089
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
5090
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
4659
5091
|
};
|
|
4660
5092
|
try {
|
|
4661
5093
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -4678,7 +5110,12 @@ var MessageDeliverySource = class {
|
|
|
4678
5110
|
delete: isDelete ? decoded.delete : null,
|
|
4679
5111
|
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4680
5112
|
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4681
|
-
bodyRanges: decoded.bodyRanges ?? null
|
|
5113
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
5114
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
5115
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
5116
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
5117
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
5118
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
4682
5119
|
});
|
|
4683
5120
|
return true;
|
|
4684
5121
|
}
|
|
@@ -4757,6 +5194,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
4757
5194
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
4758
5195
|
}
|
|
4759
5196
|
|
|
5197
|
+
// src/messaging/disappearing.ts
|
|
5198
|
+
var DisappearingStore = class {
|
|
5199
|
+
constructor(kv) {
|
|
5200
|
+
this.kv = kv;
|
|
5201
|
+
}
|
|
5202
|
+
kv;
|
|
5203
|
+
key(rfc) {
|
|
5204
|
+
return `disappear:${rfc}`;
|
|
5205
|
+
}
|
|
5206
|
+
async load(rfc) {
|
|
5207
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
5208
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5209
|
+
try {
|
|
5210
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
5211
|
+
return {
|
|
5212
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
5213
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
5214
|
+
anchors: r.anchors ?? {}
|
|
5215
|
+
};
|
|
5216
|
+
} catch {
|
|
5217
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
async save(rfc, rec) {
|
|
5221
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
5222
|
+
}
|
|
5223
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
5224
|
+
async tombstonedSeqs(rfc) {
|
|
5225
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
5226
|
+
}
|
|
5227
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
5228
|
+
async purgedClientMsgIds(rfc) {
|
|
5229
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
5230
|
+
}
|
|
5231
|
+
/**
|
|
5232
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
5233
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
5234
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
5235
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
5236
|
+
*/
|
|
5237
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
5238
|
+
const rec = await this.load(rfc);
|
|
5239
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
5240
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
5241
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
5242
|
+
}
|
|
5243
|
+
await this.save(rfc, rec);
|
|
5244
|
+
}
|
|
5245
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
5246
|
+
async anchor(rfc, clientMsgId) {
|
|
5247
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
5248
|
+
}
|
|
5249
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
5250
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
5251
|
+
const rec = await this.load(rfc);
|
|
5252
|
+
if (rec.anchors[clientMsgId]) return;
|
|
5253
|
+
rec.anchors[clientMsgId] = a;
|
|
5254
|
+
await this.save(rfc, rec);
|
|
5255
|
+
}
|
|
5256
|
+
};
|
|
5257
|
+
|
|
4760
5258
|
// src/messaging/history.ts
|
|
4761
5259
|
var MessageStore = class {
|
|
4762
5260
|
constructor(kv) {
|
|
@@ -6570,6 +7068,7 @@ var MessagingCoordinator = class {
|
|
|
6570
7068
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6571
7069
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6572
7070
|
this.elevationStore = new MentionElevationStore(this.kv);
|
|
7071
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
6573
7072
|
this.registry.attachChatList(
|
|
6574
7073
|
(chats) => {
|
|
6575
7074
|
this.chatList = chats;
|
|
@@ -6586,6 +7085,7 @@ var MessagingCoordinator = class {
|
|
|
6586
7085
|
kpStore;
|
|
6587
7086
|
suppressionStore;
|
|
6588
7087
|
elevationStore;
|
|
7088
|
+
disappearingStore;
|
|
6589
7089
|
registry = new GroupRegistry();
|
|
6590
7090
|
resolved = null;
|
|
6591
7091
|
resolvePromise = null;
|
|
@@ -6757,6 +7257,10 @@ var MessagingCoordinator = class {
|
|
|
6757
7257
|
const r = await this.resolve();
|
|
6758
7258
|
return r.groups.sendDelete(group, args);
|
|
6759
7259
|
}
|
|
7260
|
+
async sendTimerSet(group, args) {
|
|
7261
|
+
const r = await this.resolve();
|
|
7262
|
+
return r.groups.sendTimerSet(group, args);
|
|
7263
|
+
}
|
|
6760
7264
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6761
7265
|
loadSuppressed(group) {
|
|
6762
7266
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -6773,10 +7277,28 @@ var MessagingCoordinator = class {
|
|
|
6773
7277
|
saveElevated(group, keys) {
|
|
6774
7278
|
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6775
7279
|
}
|
|
7280
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
7281
|
+
tombstonedSeqs(group) {
|
|
7282
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7283
|
+
}
|
|
7284
|
+
purgedClientMsgIds(group) {
|
|
7285
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7286
|
+
}
|
|
7287
|
+
anchor(group, clientMsgId) {
|
|
7288
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
7289
|
+
}
|
|
7290
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
7291
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
7292
|
+
}
|
|
7293
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
7294
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
7295
|
+
}
|
|
6776
7296
|
async history(group, limit, before) {
|
|
6777
7297
|
const r = await this.resolve();
|
|
6778
7298
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
6779
|
-
|
|
7299
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7300
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7301
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
6780
7302
|
}
|
|
6781
7303
|
async members(group) {
|
|
6782
7304
|
const r = await this.resolve();
|
|
@@ -6853,9 +7375,10 @@ var MessagingCoordinator = class {
|
|
|
6853
7375
|
return res.devices.map((d) => d.device_id);
|
|
6854
7376
|
}
|
|
6855
7377
|
};
|
|
6856
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
7378
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
7379
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
6857
7380
|
const fold = new ReactionFold();
|
|
6858
|
-
for (const s of
|
|
7381
|
+
for (const s of visible) {
|
|
6859
7382
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
6860
7383
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6861
7384
|
if (actor === null) continue;
|
|
@@ -6871,17 +7394,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6871
7394
|
}
|
|
6872
7395
|
const editFold = new EditFold();
|
|
6873
7396
|
const deleteFold = new DeleteFold();
|
|
7397
|
+
const pageTimerFold = new TimerFold();
|
|
6874
7398
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6875
|
-
for (const s of
|
|
6876
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7399
|
+
for (const s of visible) {
|
|
7400
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6877
7401
|
continue;
|
|
6878
7402
|
const cid = s.clientMsgId ?? "";
|
|
6879
7403
|
if (!cid) continue;
|
|
6880
7404
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6881
7405
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6882
7406
|
}
|
|
6883
|
-
const authorOfTarget = (cid) =>
|
|
6884
|
-
|
|
7407
|
+
const authorOfTarget = (cid) => {
|
|
7408
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
7409
|
+
const a = authorByClientMsgId.get(cid);
|
|
7410
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
7411
|
+
};
|
|
7412
|
+
for (const s of visible) {
|
|
6885
7413
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6886
7414
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6887
7415
|
editFold.ingest(
|
|
@@ -6900,7 +7428,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6900
7428
|
);
|
|
6901
7429
|
}
|
|
6902
7430
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6903
|
-
for (const s of
|
|
7431
|
+
for (const s of visible) {
|
|
6904
7432
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6905
7433
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6906
7434
|
deleteFold.ingest(
|
|
@@ -6914,10 +7442,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6914
7442
|
authorOfTarget
|
|
6915
7443
|
);
|
|
6916
7444
|
}
|
|
6917
|
-
for (const [cid
|
|
7445
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
6918
7446
|
const lookup = /* @__PURE__ */ new Map();
|
|
6919
|
-
for (const s of
|
|
6920
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7447
|
+
for (const s of visible) {
|
|
7448
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6921
7449
|
continue;
|
|
6922
7450
|
const cid = s.clientMsgId ?? "";
|
|
6923
7451
|
if (cid && s.text !== null) {
|
|
@@ -6926,7 +7454,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6926
7454
|
}
|
|
6927
7455
|
}
|
|
6928
7456
|
const out = [];
|
|
6929
|
-
for (const s of
|
|
7457
|
+
for (const s of visible) {
|
|
7458
|
+
if (s.envelopeType === "timer_set") {
|
|
7459
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
7460
|
+
if (actor !== null && s.timer) {
|
|
7461
|
+
pageTimerFold.ingest({
|
|
7462
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
7463
|
+
start: s.timer.start,
|
|
7464
|
+
actorUserId: actor,
|
|
7465
|
+
epoch: s.epoch,
|
|
7466
|
+
serverSeq: s.serverSeq,
|
|
7467
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
7468
|
+
});
|
|
7469
|
+
}
|
|
7470
|
+
continue;
|
|
7471
|
+
}
|
|
6930
7472
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6931
7473
|
continue;
|
|
6932
7474
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -6946,10 +7488,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6946
7488
|
edited: false,
|
|
6947
7489
|
isDeleted: true,
|
|
6948
7490
|
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6949
|
-
mentions: []
|
|
7491
|
+
mentions: [],
|
|
7492
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
7493
|
+
expiresAt: null
|
|
6950
7494
|
});
|
|
6951
7495
|
continue;
|
|
6952
7496
|
}
|
|
7497
|
+
let expiresAt = null;
|
|
7498
|
+
if (s.expiry) {
|
|
7499
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
7500
|
+
} else {
|
|
7501
|
+
const active = pageTimerFold.active();
|
|
7502
|
+
if (active && active.ttlSeconds !== null) {
|
|
7503
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
7504
|
+
}
|
|
7505
|
+
}
|
|
6953
7506
|
let replyTo = null;
|
|
6954
7507
|
if (s.replyTo) {
|
|
6955
7508
|
const ref = {
|
|
@@ -6982,7 +7535,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6982
7535
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6983
7536
|
edited,
|
|
6984
7537
|
isDeleted: false,
|
|
6985
|
-
mentions
|
|
7538
|
+
mentions,
|
|
7539
|
+
expiresAt
|
|
6986
7540
|
});
|
|
6987
7541
|
}
|
|
6988
7542
|
return out;
|
|
@@ -7729,7 +8283,7 @@ function defaultSessionStorage(key) {
|
|
|
7729
8283
|
}
|
|
7730
8284
|
|
|
7731
8285
|
// src/version.ts
|
|
7732
|
-
var VERSION = "1.
|
|
8286
|
+
var VERSION = "1.6.1";
|
|
7733
8287
|
|
|
7734
8288
|
// src/runtime.ts
|
|
7735
8289
|
function buildRuntime(config) {
|
|
@@ -8089,4 +8643,4 @@ export {
|
|
|
8089
8643
|
pb,
|
|
8090
8644
|
createBoundClient
|
|
8091
8645
|
};
|
|
8092
|
-
//# sourceMappingURL=chunk-
|
|
8646
|
+
//# sourceMappingURL=chunk-EQAHM3ZJ.js.map
|