@palbase/web 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{analytics-facade-BQlTfsEm.d.ts → analytics-facade-DYNLUW0L.d.ts} +144 -4
- package/dist/{analytics-facade-DvsLE_qz.d.cts → analytics-facade-iAWinswQ.d.cts} +144 -4
- package/dist/{chunk-MBA2NAKS.js → chunk-A5WIPBGQ.js} +584 -46
- package/dist/chunk-A5WIPBGQ.js.map +1 -0
- package/dist/index.cjs +583 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/internal.cjs +583 -45
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +3 -3
- package/dist/internal.d.ts +3 -3
- package/dist/internal.js +1 -1
- package/dist/next/client.cjs +583 -45
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +583 -45
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +2 -2
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/{pb-a86jrG9h.d.cts → pb-Blj85_cg.d.cts} +1 -1
- package/dist/{pb-Cl7TeyCR.d.ts → pb-DPPpThNg.d.ts} +1 -1
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-MBA2NAKS.js.map +0 -1
package/dist/next/client.cjs
CHANGED
|
@@ -2413,6 +2413,47 @@ var PalbeFlags = class {
|
|
|
2413
2413
|
}
|
|
2414
2414
|
};
|
|
2415
2415
|
|
|
2416
|
+
// src/messaging/deadline-calculator.ts
|
|
2417
|
+
function remainingSeconds(args) {
|
|
2418
|
+
const ttl = args.ttlSeconds;
|
|
2419
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
2420
|
+
let elapsed;
|
|
2421
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
2422
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
2423
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
2424
|
+
} else {
|
|
2425
|
+
elapsed = wallDeltaSec;
|
|
2426
|
+
}
|
|
2427
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
2428
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
2429
|
+
}
|
|
2430
|
+
var cachedBootToken = null;
|
|
2431
|
+
var MonotonicClock = {
|
|
2432
|
+
nowMs() {
|
|
2433
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2434
|
+
},
|
|
2435
|
+
nowWallEpochMs() {
|
|
2436
|
+
return Date.now();
|
|
2437
|
+
},
|
|
2438
|
+
bootToken() {
|
|
2439
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
2440
|
+
try {
|
|
2441
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
2442
|
+
if (existing) {
|
|
2443
|
+
cachedBootToken = existing;
|
|
2444
|
+
return existing;
|
|
2445
|
+
}
|
|
2446
|
+
const fresh = crypto.randomUUID();
|
|
2447
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
2448
|
+
cachedBootToken = fresh;
|
|
2449
|
+
return fresh;
|
|
2450
|
+
} catch {
|
|
2451
|
+
cachedBootToken = crypto.randomUUID();
|
|
2452
|
+
return cachedBootToken;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2416
2457
|
// src/messaging/delete-fold.ts
|
|
2417
2458
|
var DeleteFold = class {
|
|
2418
2459
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -2425,17 +2466,24 @@ var DeleteFold = class {
|
|
|
2425
2466
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2426
2467
|
held = [];
|
|
2427
2468
|
/**
|
|
2428
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
2429
|
-
*
|
|
2469
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
2470
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
2471
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
2472
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
2473
|
+
* park in pending, never re-attempt).
|
|
2430
2474
|
*/
|
|
2431
2475
|
ingest(e, authorOfTarget) {
|
|
2432
2476
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2433
2477
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2434
2478
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2435
|
-
const
|
|
2436
|
-
if (
|
|
2479
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2480
|
+
if (res.kind === "purged") {
|
|
2481
|
+
this.seen.add(e.eventClientMsgId);
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
if (res.kind === "author") {
|
|
2437
2485
|
this.seen.add(e.eventClientMsgId);
|
|
2438
|
-
if (e.actorUserId === null || e.actorUserId !==
|
|
2486
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
2439
2487
|
this.tombstoned.add(e.targetClientMsgId);
|
|
2440
2488
|
} else if (e.actorUserId !== null) {
|
|
2441
2489
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -2455,12 +2503,13 @@ var DeleteFold = class {
|
|
|
2455
2503
|
* the in-order path.
|
|
2456
2504
|
*/
|
|
2457
2505
|
reevaluatePending(target, author) {
|
|
2506
|
+
const res = author;
|
|
2458
2507
|
const actor = this.pending.get(target);
|
|
2459
2508
|
if (actor !== void 0) {
|
|
2460
|
-
if (
|
|
2461
|
-
this.tombstoned.add(target);
|
|
2509
|
+
if (res.kind === "author") {
|
|
2510
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
2462
2511
|
this.pending.delete(target);
|
|
2463
|
-
} else if (
|
|
2512
|
+
} else if (res.kind === "purged") {
|
|
2464
2513
|
this.pending.delete(target);
|
|
2465
2514
|
}
|
|
2466
2515
|
}
|
|
@@ -2468,7 +2517,7 @@ var DeleteFold = class {
|
|
|
2468
2517
|
const pendingHeld = this.held;
|
|
2469
2518
|
this.held = [];
|
|
2470
2519
|
for (const e of pendingHeld) {
|
|
2471
|
-
this.ingest(e, (t) => t === target ?
|
|
2520
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
2472
2521
|
}
|
|
2473
2522
|
}
|
|
2474
2523
|
heldContains(eventClientMsgId) {
|
|
@@ -2494,15 +2543,23 @@ var EditFold = class {
|
|
|
2494
2543
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
2495
2544
|
editedTargets = /* @__PURE__ */ new Set();
|
|
2496
2545
|
/**
|
|
2497
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
2498
|
-
*
|
|
2546
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
2547
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
2548
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
2549
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
2550
|
+
* and a later author "resolution" cannot resurrect it).
|
|
2499
2551
|
*/
|
|
2500
2552
|
ingest(e, authorOfTarget) {
|
|
2501
|
-
const
|
|
2502
|
-
if (
|
|
2553
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2554
|
+
if (res.kind === "unknown") {
|
|
2503
2555
|
this.holdIfNew(e);
|
|
2504
2556
|
return;
|
|
2505
2557
|
}
|
|
2558
|
+
if (res.kind === "purged") {
|
|
2559
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
const author = res.userId;
|
|
2506
2563
|
if (e.editorUserId === null) {
|
|
2507
2564
|
this.holdIfNew(e);
|
|
2508
2565
|
return;
|
|
@@ -2761,14 +2818,46 @@ function encodeEnvelope(args) {
|
|
|
2761
2818
|
length: r.length,
|
|
2762
2819
|
mentioned_user_id: r.mentionedUserId
|
|
2763
2820
|
}))
|
|
2821
|
+
} : {},
|
|
2822
|
+
...args.expiry ? {
|
|
2823
|
+
expiry: {
|
|
2824
|
+
v: args.expiry.v,
|
|
2825
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
2826
|
+
start: args.expiry.start,
|
|
2827
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
2828
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
2829
|
+
}
|
|
2764
2830
|
} : {}
|
|
2765
2831
|
};
|
|
2766
2832
|
return encodeUtf8(JSON.stringify(env));
|
|
2767
2833
|
}
|
|
2834
|
+
function encodeTimerSet(args) {
|
|
2835
|
+
return encodeUtf8(
|
|
2836
|
+
JSON.stringify({
|
|
2837
|
+
v: 1,
|
|
2838
|
+
type: "timer_set",
|
|
2839
|
+
client_msg_id: args.clientMsgId,
|
|
2840
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
2841
|
+
start: args.start
|
|
2842
|
+
})
|
|
2843
|
+
);
|
|
2844
|
+
}
|
|
2768
2845
|
function decodeEnvelope(bytes) {
|
|
2769
2846
|
const s = decodeUtf8(bytes);
|
|
2770
2847
|
try {
|
|
2771
2848
|
const o = JSON.parse(s);
|
|
2849
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
2850
|
+
return {
|
|
2851
|
+
type: "timer_set",
|
|
2852
|
+
text: null,
|
|
2853
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2854
|
+
replyTo: null,
|
|
2855
|
+
timer: {
|
|
2856
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
2857
|
+
start: o.start === "read" ? "read" : "send"
|
|
2858
|
+
}
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2772
2861
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2773
2862
|
return {
|
|
2774
2863
|
type: "delete",
|
|
@@ -2810,12 +2899,14 @@ function decodeEnvelope(bytes) {
|
|
|
2810
2899
|
}
|
|
2811
2900
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2812
2901
|
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2902
|
+
const expiry = decodeExpiry(o.expiry);
|
|
2813
2903
|
return {
|
|
2814
2904
|
type: "text",
|
|
2815
2905
|
text: o.text ?? null,
|
|
2816
2906
|
clientMsgId: o.client_msg_id ?? "",
|
|
2817
2907
|
replyTo: o.reply_to ?? null,
|
|
2818
|
-
...textRanges ? { bodyRanges: textRanges } : {}
|
|
2908
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
2909
|
+
...expiry ? { expiry } : {}
|
|
2819
2910
|
};
|
|
2820
2911
|
}
|
|
2821
2912
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2827,6 +2918,19 @@ function decodeEnvelope(bytes) {
|
|
|
2827
2918
|
}
|
|
2828
2919
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2829
2920
|
}
|
|
2921
|
+
function decodeExpiry(raw) {
|
|
2922
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
2923
|
+
const o = raw;
|
|
2924
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
2925
|
+
const start = o.start === "read" ? "read" : "send";
|
|
2926
|
+
return {
|
|
2927
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
2928
|
+
ttlSeconds: o.ttl_seconds,
|
|
2929
|
+
start,
|
|
2930
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
2931
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2830
2934
|
function decodeBodyRanges(raw) {
|
|
2831
2935
|
if (!raw || raw.length === 0) return void 0;
|
|
2832
2936
|
return raw.map((r) => ({
|
|
@@ -3046,9 +3150,9 @@ var GroupMessaging = class {
|
|
|
3046
3150
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3047
3151
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3048
3152
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3049
|
-
async sendText(group, text, replyTo, bodyRanges) {
|
|
3153
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
3050
3154
|
const clientMsgId = mintClientMsgId();
|
|
3051
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
3155
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
3052
3156
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3053
3157
|
const body = {
|
|
3054
3158
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3077,7 +3181,10 @@ var GroupMessaging = class {
|
|
|
3077
3181
|
} : null,
|
|
3078
3182
|
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3079
3183
|
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3080
|
-
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
3184
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
3185
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
3186
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
3187
|
+
...expiry ? { expiry } : {}
|
|
3081
3188
|
};
|
|
3082
3189
|
try {
|
|
3083
3190
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3085,6 +3192,51 @@ var GroupMessaging = class {
|
|
|
3085
3192
|
}
|
|
3086
3193
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
3087
3194
|
}
|
|
3195
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
3196
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
3197
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
3198
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
3199
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
3200
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
3201
|
+
async sendTimerSet(group, args) {
|
|
3202
|
+
const plaintext = encodeTimerSet({
|
|
3203
|
+
clientMsgId: args.clientMsgId,
|
|
3204
|
+
ttlSeconds: args.ttlSeconds,
|
|
3205
|
+
start: args.start
|
|
3206
|
+
});
|
|
3207
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3208
|
+
const body = {
|
|
3209
|
+
ciphertext_b64: toBase64(ct),
|
|
3210
|
+
client_idem_key: randomId()
|
|
3211
|
+
};
|
|
3212
|
+
const wire = await palbeRequest(
|
|
3213
|
+
this.rt,
|
|
3214
|
+
"POST",
|
|
3215
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3216
|
+
{ body }
|
|
3217
|
+
);
|
|
3218
|
+
const stored = {
|
|
3219
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3220
|
+
direction: "outgoing",
|
|
3221
|
+
text: null,
|
|
3222
|
+
senderDeviceId: this.selfDeviceId,
|
|
3223
|
+
epoch: wire.epoch,
|
|
3224
|
+
serverSeq: wire.server_seq,
|
|
3225
|
+
at: Date.now(),
|
|
3226
|
+
clientMsgId: args.clientMsgId,
|
|
3227
|
+
replyTo: null,
|
|
3228
|
+
envelopeType: "timer_set",
|
|
3229
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
3230
|
+
};
|
|
3231
|
+
try {
|
|
3232
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3233
|
+
} catch {
|
|
3234
|
+
}
|
|
3235
|
+
return {
|
|
3236
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3237
|
+
clientMsgId: args.clientMsgId
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3088
3240
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
3089
3241
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
3090
3242
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -3387,6 +3539,43 @@ var ReactionFold = class {
|
|
|
3387
3539
|
}
|
|
3388
3540
|
};
|
|
3389
3541
|
|
|
3542
|
+
// src/messaging/timer-fold.ts
|
|
3543
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3544
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
3545
|
+
return aSeq <= bSeq;
|
|
3546
|
+
}
|
|
3547
|
+
var TimerFold = class {
|
|
3548
|
+
cell = null;
|
|
3549
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
3550
|
+
ingest(e) {
|
|
3551
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
3552
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
3553
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
this.cell = {
|
|
3557
|
+
orderEpoch: e.epoch,
|
|
3558
|
+
orderSeq: e.serverSeq,
|
|
3559
|
+
ttlSeconds: e.ttlSeconds,
|
|
3560
|
+
start: e.start,
|
|
3561
|
+
actor: e.actorUserId
|
|
3562
|
+
};
|
|
3563
|
+
}
|
|
3564
|
+
/**
|
|
3565
|
+
* The active chat default, or null if no timer_set has applied.
|
|
3566
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
3567
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
3568
|
+
*/
|
|
3569
|
+
active() {
|
|
3570
|
+
if (this.cell === null) return null;
|
|
3571
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
3572
|
+
}
|
|
3573
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
3574
|
+
lastActor() {
|
|
3575
|
+
return this.cell?.actor ?? null;
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
|
|
3390
3579
|
// src/messaging/chat.ts
|
|
3391
3580
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3392
3581
|
var Chat = class {
|
|
@@ -3413,6 +3602,22 @@ var Chat = class {
|
|
|
3413
3602
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3414
3603
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3415
3604
|
deleteFold = new DeleteFold();
|
|
3605
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
3606
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
3607
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
3608
|
+
* no per-message expiry (disappearing T10). */
|
|
3609
|
+
timerFold = new TimerFold();
|
|
3610
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
3611
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
3612
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
3613
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
3614
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
3615
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
3616
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
3617
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
3618
|
+
* tombstone (disappearing T10). */
|
|
3619
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
3620
|
+
purgedLoaded = false;
|
|
3416
3621
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3417
3622
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3418
3623
|
suppressed = /* @__PURE__ */ new Set();
|
|
@@ -3523,7 +3728,9 @@ var Chat = class {
|
|
|
3523
3728
|
replyTo: null,
|
|
3524
3729
|
edited: false,
|
|
3525
3730
|
isDeleted: true,
|
|
3526
|
-
mentions: []
|
|
3731
|
+
mentions: [],
|
|
3732
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
3733
|
+
expiresAt: null
|
|
3527
3734
|
});
|
|
3528
3735
|
continue;
|
|
3529
3736
|
}
|
|
@@ -3560,9 +3767,21 @@ var Chat = class {
|
|
|
3560
3767
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3561
3768
|
void this.loadSuppressed();
|
|
3562
3769
|
void this.loadElevated();
|
|
3563
|
-
void this.hydrateHistory();
|
|
3770
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
3564
3771
|
void this.refreshMembers();
|
|
3565
3772
|
}
|
|
3773
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
3774
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
3775
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
3776
|
+
async loadPurged() {
|
|
3777
|
+
if (this.purgedLoaded || !this._group) return;
|
|
3778
|
+
this.purgedLoaded = true;
|
|
3779
|
+
try {
|
|
3780
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
3781
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
3782
|
+
} catch {
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3566
3785
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3567
3786
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3568
3787
|
async loadSuppressed() {
|
|
@@ -3619,13 +3838,16 @@ var Chat = class {
|
|
|
3619
3838
|
if (this.seenKeys.has(key)) continue;
|
|
3620
3839
|
this.seenKeys.add(key);
|
|
3621
3840
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3622
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
3841
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
3623
3842
|
}
|
|
3624
3843
|
this.messageList.push(
|
|
3625
3844
|
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3626
3845
|
);
|
|
3627
3846
|
changed = true;
|
|
3628
3847
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3848
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
3849
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
3850
|
+
}
|
|
3629
3851
|
}
|
|
3630
3852
|
if (changed) {
|
|
3631
3853
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3640,6 +3862,7 @@ var Chat = class {
|
|
|
3640
3862
|
return;
|
|
3641
3863
|
}
|
|
3642
3864
|
if (incoming.serverSeq <= 0) return;
|
|
3865
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
3643
3866
|
const key = this.internalKey(incoming.serverSeq);
|
|
3644
3867
|
if (this.seenKeys.has(key)) return;
|
|
3645
3868
|
this.seenKeys.add(key);
|
|
@@ -3648,6 +3871,20 @@ var Chat = class {
|
|
|
3648
3871
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
3649
3872
|
}
|
|
3650
3873
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
3874
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
3875
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3876
|
+
if (actorUserId !== null) {
|
|
3877
|
+
this.timerFold.ingest({
|
|
3878
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
3879
|
+
start: incoming.timer.start,
|
|
3880
|
+
actorUserId,
|
|
3881
|
+
epoch: incoming.epoch,
|
|
3882
|
+
serverSeq: incoming.serverSeq,
|
|
3883
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
return;
|
|
3887
|
+
}
|
|
3651
3888
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
3652
3889
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3653
3890
|
if (actorUserId !== null) {
|
|
@@ -3722,7 +3959,10 @@ var Chat = class {
|
|
|
3722
3959
|
edited: false,
|
|
3723
3960
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3724
3961
|
isDeleted: false,
|
|
3725
|
-
mentions
|
|
3962
|
+
mentions,
|
|
3963
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
3964
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
3965
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
3726
3966
|
};
|
|
3727
3967
|
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3728
3968
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
@@ -3734,7 +3974,10 @@ var Chat = class {
|
|
|
3734
3974
|
if (incomingClientMsgId) {
|
|
3735
3975
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3736
3976
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3737
|
-
this.deleteFold.reevaluatePending(
|
|
3977
|
+
this.deleteFold.reevaluatePending(
|
|
3978
|
+
incomingClientMsgId,
|
|
3979
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
3980
|
+
);
|
|
3738
3981
|
}
|
|
3739
3982
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3740
3983
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3743,11 +3986,131 @@ var Chat = class {
|
|
|
3743
3986
|
incoming.serverSeq
|
|
3744
3987
|
);
|
|
3745
3988
|
this.emit();
|
|
3989
|
+
void this.armPurge(
|
|
3990
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
3991
|
+
incoming.serverSeq,
|
|
3992
|
+
incomingClientMsgId
|
|
3993
|
+
);
|
|
3994
|
+
}
|
|
3995
|
+
// ── Disappearing (TTL — T10) ──
|
|
3996
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
3997
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
3998
|
+
toExpirySpec(e) {
|
|
3999
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
4000
|
+
}
|
|
4001
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
4002
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
4003
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
4004
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
4005
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
4006
|
+
defaultExpiry() {
|
|
4007
|
+
const active = this.timerFold.active();
|
|
4008
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
4009
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
4010
|
+
}
|
|
4011
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
4012
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
4013
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
4014
|
+
* UI countdown baseline. */
|
|
4015
|
+
deadlineFor(expiry) {
|
|
4016
|
+
if (!expiry) return null;
|
|
4017
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
4018
|
+
}
|
|
4019
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
4020
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
4021
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
4022
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
4023
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
4024
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
4025
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
4026
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
4027
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
4028
|
+
const group = this._group;
|
|
4029
|
+
const fresh = {
|
|
4030
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
4031
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4032
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
4033
|
+
};
|
|
4034
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
4035
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
4036
|
+
const result = remainingSeconds({
|
|
4037
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
4038
|
+
anchor: effective,
|
|
4039
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
4040
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4041
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
4042
|
+
});
|
|
4043
|
+
let purgeInSeconds;
|
|
4044
|
+
if (result.kind === "purgeNow") {
|
|
4045
|
+
purgeInSeconds = 0;
|
|
4046
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
4047
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
4048
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
4049
|
+
} else {
|
|
4050
|
+
purgeInSeconds = result.seconds;
|
|
4051
|
+
}
|
|
4052
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4053
|
+
if (prior) clearTimeout(prior);
|
|
4054
|
+
this.purgeTimers.delete(serverSeq);
|
|
4055
|
+
if (purgeInSeconds <= 0) {
|
|
4056
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
const handle = setTimeout(() => {
|
|
4060
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4061
|
+
}, purgeInSeconds * 1e3);
|
|
4062
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
3746
4063
|
}
|
|
3747
|
-
/**
|
|
3748
|
-
*
|
|
3749
|
-
*
|
|
3750
|
-
|
|
4064
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
4065
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
4066
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
4067
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
4068
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
4069
|
+
if (!this._group) return;
|
|
4070
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
4071
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4072
|
+
if (prior) clearTimeout(prior);
|
|
4073
|
+
this.purgeTimers.delete(serverSeq);
|
|
4074
|
+
if (remainingMs <= 0) {
|
|
4075
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
const handle = setTimeout(() => {
|
|
4079
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4080
|
+
}, remainingMs);
|
|
4081
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4082
|
+
}
|
|
4083
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
4084
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
4085
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
4086
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
4087
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
4088
|
+
async purge(serverSeq, clientMsgId) {
|
|
4089
|
+
if (!this._group) return;
|
|
4090
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4091
|
+
if (prior) clearTimeout(prior);
|
|
4092
|
+
this.purgeTimers.delete(serverSeq);
|
|
4093
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
4094
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
4095
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
4096
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
4097
|
+
this.emit();
|
|
4098
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
4099
|
+
if (clientMsgId) {
|
|
4100
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
4104
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
4105
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
4106
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
4107
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
4108
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
4109
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
4110
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
4111
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
4112
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
4113
|
+
};
|
|
3751
4114
|
// ── Mentions (mentions T6) ──
|
|
3752
4115
|
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3753
4116
|
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
@@ -4033,10 +4396,48 @@ var Chat = class {
|
|
|
4033
4396
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
4034
4397
|
}
|
|
4035
4398
|
const bodyRanges = opts?.mentions ?? null;
|
|
4036
|
-
const
|
|
4399
|
+
const start = opts?.expiresIn?.start ?? "send";
|
|
4400
|
+
const expiry = opts?.expiresIn ? {
|
|
4401
|
+
v: 1,
|
|
4402
|
+
ttlSeconds: opts.expiresIn.ttlSeconds,
|
|
4403
|
+
start,
|
|
4404
|
+
senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
4405
|
+
} : null;
|
|
4406
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
4407
|
+
group,
|
|
4408
|
+
text,
|
|
4409
|
+
replyRef,
|
|
4410
|
+
bodyRanges,
|
|
4411
|
+
expiry
|
|
4412
|
+
);
|
|
4037
4413
|
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
4414
|
+
if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
|
|
4038
4415
|
return receipt;
|
|
4039
4416
|
}
|
|
4417
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
4418
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
4419
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
4420
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
4421
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
4422
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
4423
|
+
async setDisappearing(opts) {
|
|
4424
|
+
const group = await this.materializeIfNeeded();
|
|
4425
|
+
const clientMsgId = mintClientMsgId();
|
|
4426
|
+
const start = opts.start ?? "send";
|
|
4427
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
4428
|
+
clientMsgId,
|
|
4429
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4430
|
+
start
|
|
4431
|
+
});
|
|
4432
|
+
this.timerFold.ingest({
|
|
4433
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4434
|
+
start,
|
|
4435
|
+
actorUserId: this.backend.selfUserId,
|
|
4436
|
+
epoch: receipt.epoch,
|
|
4437
|
+
serverSeq: receipt.serverSeq,
|
|
4438
|
+
eventClientMsgId: clientMsgId
|
|
4439
|
+
});
|
|
4440
|
+
}
|
|
4040
4441
|
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
4041
4442
|
if (receipt.serverSeq <= 0) return;
|
|
4042
4443
|
const key = this.internalKey(receipt.serverSeq);
|
|
@@ -4065,7 +4466,10 @@ var Chat = class {
|
|
|
4065
4466
|
isDeleted: false,
|
|
4066
4467
|
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4067
4468
|
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4068
|
-
mentions: this.resolveMentions(text, bodyRanges)
|
|
4469
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
4470
|
+
// Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
|
|
4471
|
+
// via the messageList overlay; default null here (a plain own-send has no deadline).
|
|
4472
|
+
expiresAt: null
|
|
4069
4473
|
});
|
|
4070
4474
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
4071
4475
|
this.emit();
|
|
@@ -4420,6 +4824,7 @@ var MessageDeliverySource = class {
|
|
|
4420
4824
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4421
4825
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4422
4826
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
4827
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
4423
4828
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4424
4829
|
const stored = {
|
|
4425
4830
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4477,7 +4882,18 @@ var MessageDeliverySource = class {
|
|
|
4477
4882
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4478
4883
|
scope: decoded.delete.scope
|
|
4479
4884
|
}
|
|
4480
|
-
} : {}
|
|
4885
|
+
} : {},
|
|
4886
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
4887
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
4888
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
4889
|
+
...isTimerSet && decoded.timer ? {
|
|
4890
|
+
envelopeType: "timer_set",
|
|
4891
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
4892
|
+
} : {},
|
|
4893
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
4894
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
4895
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
4896
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
4481
4897
|
};
|
|
4482
4898
|
try {
|
|
4483
4899
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -4500,7 +4916,12 @@ var MessageDeliverySource = class {
|
|
|
4500
4916
|
delete: isDelete ? decoded.delete : null,
|
|
4501
4917
|
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4502
4918
|
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4503
|
-
bodyRanges: decoded.bodyRanges ?? null
|
|
4919
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
4920
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
4921
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
4922
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
4923
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
4924
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
4504
4925
|
});
|
|
4505
4926
|
return true;
|
|
4506
4927
|
}
|
|
@@ -4579,6 +5000,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
4579
5000
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
4580
5001
|
}
|
|
4581
5002
|
|
|
5003
|
+
// src/messaging/disappearing.ts
|
|
5004
|
+
var DisappearingStore = class {
|
|
5005
|
+
constructor(kv) {
|
|
5006
|
+
this.kv = kv;
|
|
5007
|
+
}
|
|
5008
|
+
kv;
|
|
5009
|
+
key(rfc) {
|
|
5010
|
+
return `disappear:${rfc}`;
|
|
5011
|
+
}
|
|
5012
|
+
async load(rfc) {
|
|
5013
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
5014
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5015
|
+
try {
|
|
5016
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
5017
|
+
return {
|
|
5018
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
5019
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
5020
|
+
anchors: r.anchors ?? {}
|
|
5021
|
+
};
|
|
5022
|
+
} catch {
|
|
5023
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5024
|
+
}
|
|
5025
|
+
}
|
|
5026
|
+
async save(rfc, rec) {
|
|
5027
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
5028
|
+
}
|
|
5029
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
5030
|
+
async tombstonedSeqs(rfc) {
|
|
5031
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
5032
|
+
}
|
|
5033
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
5034
|
+
async purgedClientMsgIds(rfc) {
|
|
5035
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
5036
|
+
}
|
|
5037
|
+
/**
|
|
5038
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
5039
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
5040
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
5041
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
5042
|
+
*/
|
|
5043
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
5044
|
+
const rec = await this.load(rfc);
|
|
5045
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
5046
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
5047
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
5048
|
+
}
|
|
5049
|
+
await this.save(rfc, rec);
|
|
5050
|
+
}
|
|
5051
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
5052
|
+
async anchor(rfc, clientMsgId) {
|
|
5053
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
5054
|
+
}
|
|
5055
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
5056
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
5057
|
+
const rec = await this.load(rfc);
|
|
5058
|
+
if (rec.anchors[clientMsgId]) return;
|
|
5059
|
+
rec.anchors[clientMsgId] = a;
|
|
5060
|
+
await this.save(rfc, rec);
|
|
5061
|
+
}
|
|
5062
|
+
};
|
|
5063
|
+
|
|
4582
5064
|
// src/messaging/history.ts
|
|
4583
5065
|
var MessageStore = class {
|
|
4584
5066
|
constructor(kv) {
|
|
@@ -6393,6 +6875,7 @@ var MessagingCoordinator = class {
|
|
|
6393
6875
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6394
6876
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6395
6877
|
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6878
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
6396
6879
|
this.registry.attachChatList(
|
|
6397
6880
|
(chats) => {
|
|
6398
6881
|
this.chatList = chats;
|
|
@@ -6409,6 +6892,7 @@ var MessagingCoordinator = class {
|
|
|
6409
6892
|
kpStore;
|
|
6410
6893
|
suppressionStore;
|
|
6411
6894
|
elevationStore;
|
|
6895
|
+
disappearingStore;
|
|
6412
6896
|
registry = new GroupRegistry();
|
|
6413
6897
|
resolved = null;
|
|
6414
6898
|
resolvePromise = null;
|
|
@@ -6580,6 +7064,10 @@ var MessagingCoordinator = class {
|
|
|
6580
7064
|
const r = await this.resolve();
|
|
6581
7065
|
return r.groups.sendDelete(group, args);
|
|
6582
7066
|
}
|
|
7067
|
+
async sendTimerSet(group, args) {
|
|
7068
|
+
const r = await this.resolve();
|
|
7069
|
+
return r.groups.sendTimerSet(group, args);
|
|
7070
|
+
}
|
|
6583
7071
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6584
7072
|
loadSuppressed(group) {
|
|
6585
7073
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -6596,10 +7084,28 @@ var MessagingCoordinator = class {
|
|
|
6596
7084
|
saveElevated(group, keys) {
|
|
6597
7085
|
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6598
7086
|
}
|
|
7087
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
7088
|
+
tombstonedSeqs(group) {
|
|
7089
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7090
|
+
}
|
|
7091
|
+
purgedClientMsgIds(group) {
|
|
7092
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7093
|
+
}
|
|
7094
|
+
anchor(group, clientMsgId) {
|
|
7095
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
7096
|
+
}
|
|
7097
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
7098
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
7099
|
+
}
|
|
7100
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
7101
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
7102
|
+
}
|
|
6599
7103
|
async history(group, limit, before) {
|
|
6600
7104
|
const r = await this.resolve();
|
|
6601
7105
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
6602
|
-
|
|
7106
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7107
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7108
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
6603
7109
|
}
|
|
6604
7110
|
async members(group) {
|
|
6605
7111
|
const r = await this.resolve();
|
|
@@ -6676,9 +7182,10 @@ var MessagingCoordinator = class {
|
|
|
6676
7182
|
return res.devices.map((d) => d.device_id);
|
|
6677
7183
|
}
|
|
6678
7184
|
};
|
|
6679
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
7185
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
7186
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
6680
7187
|
const fold = new ReactionFold();
|
|
6681
|
-
for (const s of
|
|
7188
|
+
for (const s of visible) {
|
|
6682
7189
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
6683
7190
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6684
7191
|
if (actor === null) continue;
|
|
@@ -6694,17 +7201,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6694
7201
|
}
|
|
6695
7202
|
const editFold = new EditFold();
|
|
6696
7203
|
const deleteFold = new DeleteFold();
|
|
7204
|
+
const pageTimerFold = new TimerFold();
|
|
6697
7205
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6698
|
-
for (const s of
|
|
6699
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7206
|
+
for (const s of visible) {
|
|
7207
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6700
7208
|
continue;
|
|
6701
7209
|
const cid = s.clientMsgId ?? "";
|
|
6702
7210
|
if (!cid) continue;
|
|
6703
7211
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6704
7212
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6705
7213
|
}
|
|
6706
|
-
const authorOfTarget = (cid) =>
|
|
6707
|
-
|
|
7214
|
+
const authorOfTarget = (cid) => {
|
|
7215
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
7216
|
+
const a = authorByClientMsgId.get(cid);
|
|
7217
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
7218
|
+
};
|
|
7219
|
+
for (const s of visible) {
|
|
6708
7220
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6709
7221
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6710
7222
|
editFold.ingest(
|
|
@@ -6723,7 +7235,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6723
7235
|
);
|
|
6724
7236
|
}
|
|
6725
7237
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6726
|
-
for (const s of
|
|
7238
|
+
for (const s of visible) {
|
|
6727
7239
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6728
7240
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6729
7241
|
deleteFold.ingest(
|
|
@@ -6737,10 +7249,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6737
7249
|
authorOfTarget
|
|
6738
7250
|
);
|
|
6739
7251
|
}
|
|
6740
|
-
for (const [cid
|
|
7252
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
6741
7253
|
const lookup = /* @__PURE__ */ new Map();
|
|
6742
|
-
for (const s of
|
|
6743
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7254
|
+
for (const s of visible) {
|
|
7255
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6744
7256
|
continue;
|
|
6745
7257
|
const cid = s.clientMsgId ?? "";
|
|
6746
7258
|
if (cid && s.text !== null) {
|
|
@@ -6749,7 +7261,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6749
7261
|
}
|
|
6750
7262
|
}
|
|
6751
7263
|
const out = [];
|
|
6752
|
-
for (const s of
|
|
7264
|
+
for (const s of visible) {
|
|
7265
|
+
if (s.envelopeType === "timer_set") {
|
|
7266
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
7267
|
+
if (actor !== null && s.timer) {
|
|
7268
|
+
pageTimerFold.ingest({
|
|
7269
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
7270
|
+
start: s.timer.start,
|
|
7271
|
+
actorUserId: actor,
|
|
7272
|
+
epoch: s.epoch,
|
|
7273
|
+
serverSeq: s.serverSeq,
|
|
7274
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
7275
|
+
});
|
|
7276
|
+
}
|
|
7277
|
+
continue;
|
|
7278
|
+
}
|
|
6753
7279
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6754
7280
|
continue;
|
|
6755
7281
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -6769,10 +7295,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6769
7295
|
edited: false,
|
|
6770
7296
|
isDeleted: true,
|
|
6771
7297
|
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6772
|
-
mentions: []
|
|
7298
|
+
mentions: [],
|
|
7299
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
7300
|
+
expiresAt: null
|
|
6773
7301
|
});
|
|
6774
7302
|
continue;
|
|
6775
7303
|
}
|
|
7304
|
+
let expiresAt = null;
|
|
7305
|
+
if (s.expiry) {
|
|
7306
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
7307
|
+
} else {
|
|
7308
|
+
const active = pageTimerFold.active();
|
|
7309
|
+
if (active && active.ttlSeconds !== null) {
|
|
7310
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
7311
|
+
}
|
|
7312
|
+
}
|
|
6776
7313
|
let replyTo = null;
|
|
6777
7314
|
if (s.replyTo) {
|
|
6778
7315
|
const ref = {
|
|
@@ -6805,7 +7342,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6805
7342
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6806
7343
|
edited,
|
|
6807
7344
|
isDeleted: false,
|
|
6808
|
-
mentions
|
|
7345
|
+
mentions,
|
|
7346
|
+
expiresAt
|
|
6809
7347
|
});
|
|
6810
7348
|
}
|
|
6811
7349
|
return out;
|
|
@@ -7552,7 +8090,7 @@ function defaultSessionStorage(key) {
|
|
|
7552
8090
|
}
|
|
7553
8091
|
|
|
7554
8092
|
// src/version.ts
|
|
7555
|
-
var VERSION = "1.
|
|
8093
|
+
var VERSION = "1.6.0";
|
|
7556
8094
|
|
|
7557
8095
|
// src/runtime.ts
|
|
7558
8096
|
function buildRuntime(config) {
|