@palbase/web 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{analytics-facade-DvsLE_qz.d.cts → analytics-facade-ATGUv2-f.d.cts} +152 -4
- package/dist/{analytics-facade-BQlTfsEm.d.ts → analytics-facade-DhcTP_kw.d.ts} +152 -4
- package/dist/{chunk-MBA2NAKS.js → chunk-EQAHM3ZJ.js} +602 -48
- package/dist/chunk-EQAHM3ZJ.js.map +1 -0
- package/dist/index.cjs +601 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/internal.cjs +601 -47
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +3 -3
- package/dist/internal.d.ts +3 -3
- package/dist/internal.js +1 -1
- package/dist/next/client.cjs +601 -47
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +601 -47
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +2 -2
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/{pb-Cl7TeyCR.d.ts → pb-TAUNVyT6.d.ts} +1 -1
- package/dist/{pb-a86jrG9h.d.cts → pb-VzJvX7Gg.d.cts} +1 -1
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-MBA2NAKS.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1607,6 +1607,47 @@ var PalbeFlags = class {
|
|
|
1607
1607
|
}
|
|
1608
1608
|
};
|
|
1609
1609
|
|
|
1610
|
+
// src/messaging/deadline-calculator.ts
|
|
1611
|
+
function remainingSeconds(args) {
|
|
1612
|
+
const ttl = args.ttlSeconds;
|
|
1613
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
1614
|
+
let elapsed;
|
|
1615
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
1616
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
1617
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
1618
|
+
} else {
|
|
1619
|
+
elapsed = wallDeltaSec;
|
|
1620
|
+
}
|
|
1621
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
1622
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
1623
|
+
}
|
|
1624
|
+
var cachedBootToken = null;
|
|
1625
|
+
var MonotonicClock = {
|
|
1626
|
+
nowMs() {
|
|
1627
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1628
|
+
},
|
|
1629
|
+
nowWallEpochMs() {
|
|
1630
|
+
return Date.now();
|
|
1631
|
+
},
|
|
1632
|
+
bootToken() {
|
|
1633
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
1634
|
+
try {
|
|
1635
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
1636
|
+
if (existing) {
|
|
1637
|
+
cachedBootToken = existing;
|
|
1638
|
+
return existing;
|
|
1639
|
+
}
|
|
1640
|
+
const fresh = crypto.randomUUID();
|
|
1641
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
1642
|
+
cachedBootToken = fresh;
|
|
1643
|
+
return fresh;
|
|
1644
|
+
} catch {
|
|
1645
|
+
cachedBootToken = crypto.randomUUID();
|
|
1646
|
+
return cachedBootToken;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1610
1651
|
// src/messaging/delete-fold.ts
|
|
1611
1652
|
var DeleteFold = class {
|
|
1612
1653
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -1619,17 +1660,24 @@ var DeleteFold = class {
|
|
|
1619
1660
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
1620
1661
|
held = [];
|
|
1621
1662
|
/**
|
|
1622
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
1623
|
-
*
|
|
1663
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
1664
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
1665
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
1666
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
1667
|
+
* park in pending, never re-attempt).
|
|
1624
1668
|
*/
|
|
1625
1669
|
ingest(e, authorOfTarget) {
|
|
1626
1670
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
1627
1671
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
1628
1672
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
1629
|
-
const
|
|
1630
|
-
if (
|
|
1673
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
1674
|
+
if (res.kind === "purged") {
|
|
1675
|
+
this.seen.add(e.eventClientMsgId);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (res.kind === "author") {
|
|
1631
1679
|
this.seen.add(e.eventClientMsgId);
|
|
1632
|
-
if (e.actorUserId === null || e.actorUserId !==
|
|
1680
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
1633
1681
|
this.tombstoned.add(e.targetClientMsgId);
|
|
1634
1682
|
} else if (e.actorUserId !== null) {
|
|
1635
1683
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -1649,12 +1697,13 @@ var DeleteFold = class {
|
|
|
1649
1697
|
* the in-order path.
|
|
1650
1698
|
*/
|
|
1651
1699
|
reevaluatePending(target, author) {
|
|
1700
|
+
const res = author;
|
|
1652
1701
|
const actor = this.pending.get(target);
|
|
1653
1702
|
if (actor !== void 0) {
|
|
1654
|
-
if (
|
|
1655
|
-
this.tombstoned.add(target);
|
|
1703
|
+
if (res.kind === "author") {
|
|
1704
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
1656
1705
|
this.pending.delete(target);
|
|
1657
|
-
} else if (
|
|
1706
|
+
} else if (res.kind === "purged") {
|
|
1658
1707
|
this.pending.delete(target);
|
|
1659
1708
|
}
|
|
1660
1709
|
}
|
|
@@ -1662,7 +1711,7 @@ var DeleteFold = class {
|
|
|
1662
1711
|
const pendingHeld = this.held;
|
|
1663
1712
|
this.held = [];
|
|
1664
1713
|
for (const e of pendingHeld) {
|
|
1665
|
-
this.ingest(e, (t) => t === target ?
|
|
1714
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
1666
1715
|
}
|
|
1667
1716
|
}
|
|
1668
1717
|
heldContains(eventClientMsgId) {
|
|
@@ -1688,15 +1737,23 @@ var EditFold = class {
|
|
|
1688
1737
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
1689
1738
|
editedTargets = /* @__PURE__ */ new Set();
|
|
1690
1739
|
/**
|
|
1691
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
1692
|
-
*
|
|
1740
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
1741
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
1742
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
1743
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
1744
|
+
* and a later author "resolution" cannot resurrect it).
|
|
1693
1745
|
*/
|
|
1694
1746
|
ingest(e, authorOfTarget) {
|
|
1695
|
-
const
|
|
1696
|
-
if (
|
|
1747
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
1748
|
+
if (res.kind === "unknown") {
|
|
1697
1749
|
this.holdIfNew(e);
|
|
1698
1750
|
return;
|
|
1699
1751
|
}
|
|
1752
|
+
if (res.kind === "purged") {
|
|
1753
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const author = res.userId;
|
|
1700
1757
|
if (e.editorUserId === null) {
|
|
1701
1758
|
this.holdIfNew(e);
|
|
1702
1759
|
return;
|
|
@@ -1955,14 +2012,46 @@ function encodeEnvelope(args) {
|
|
|
1955
2012
|
length: r.length,
|
|
1956
2013
|
mentioned_user_id: r.mentionedUserId
|
|
1957
2014
|
}))
|
|
2015
|
+
} : {},
|
|
2016
|
+
...args.expiry ? {
|
|
2017
|
+
expiry: {
|
|
2018
|
+
v: args.expiry.v,
|
|
2019
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
2020
|
+
start: args.expiry.start,
|
|
2021
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
2022
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
2023
|
+
}
|
|
1958
2024
|
} : {}
|
|
1959
2025
|
};
|
|
1960
2026
|
return encodeUtf8(JSON.stringify(env));
|
|
1961
2027
|
}
|
|
2028
|
+
function encodeTimerSet(args) {
|
|
2029
|
+
return encodeUtf8(
|
|
2030
|
+
JSON.stringify({
|
|
2031
|
+
v: 1,
|
|
2032
|
+
type: "timer_set",
|
|
2033
|
+
client_msg_id: args.clientMsgId,
|
|
2034
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
2035
|
+
start: args.start
|
|
2036
|
+
})
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
1962
2039
|
function decodeEnvelope(bytes) {
|
|
1963
2040
|
const s = decodeUtf8(bytes);
|
|
1964
2041
|
try {
|
|
1965
2042
|
const o = JSON.parse(s);
|
|
2043
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
2044
|
+
return {
|
|
2045
|
+
type: "timer_set",
|
|
2046
|
+
text: null,
|
|
2047
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2048
|
+
replyTo: null,
|
|
2049
|
+
timer: {
|
|
2050
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
2051
|
+
start: o.start === "read" ? "read" : "send"
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
1966
2055
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
1967
2056
|
return {
|
|
1968
2057
|
type: "delete",
|
|
@@ -2004,12 +2093,14 @@ function decodeEnvelope(bytes) {
|
|
|
2004
2093
|
}
|
|
2005
2094
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2006
2095
|
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2096
|
+
const expiry = decodeExpiry(o.expiry);
|
|
2007
2097
|
return {
|
|
2008
2098
|
type: "text",
|
|
2009
2099
|
text: o.text ?? null,
|
|
2010
2100
|
clientMsgId: o.client_msg_id ?? "",
|
|
2011
2101
|
replyTo: o.reply_to ?? null,
|
|
2012
|
-
...textRanges ? { bodyRanges: textRanges } : {}
|
|
2102
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
2103
|
+
...expiry ? { expiry } : {}
|
|
2013
2104
|
};
|
|
2014
2105
|
}
|
|
2015
2106
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2021,6 +2112,19 @@ function decodeEnvelope(bytes) {
|
|
|
2021
2112
|
}
|
|
2022
2113
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2023
2114
|
}
|
|
2115
|
+
function decodeExpiry(raw) {
|
|
2116
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
2117
|
+
const o = raw;
|
|
2118
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
2119
|
+
const start = o.start === "read" ? "read" : "send";
|
|
2120
|
+
return {
|
|
2121
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
2122
|
+
ttlSeconds: o.ttl_seconds,
|
|
2123
|
+
start,
|
|
2124
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
2125
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2024
2128
|
function decodeBodyRanges(raw) {
|
|
2025
2129
|
if (!raw || raw.length === 0) return void 0;
|
|
2026
2130
|
return raw.map((r) => ({
|
|
@@ -2240,9 +2344,9 @@ var GroupMessaging = class {
|
|
|
2240
2344
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
2241
2345
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
2242
2346
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
2243
|
-
async sendText(group, text, replyTo, bodyRanges) {
|
|
2347
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
2244
2348
|
const clientMsgId = mintClientMsgId();
|
|
2245
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
2349
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
2246
2350
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2247
2351
|
const body = {
|
|
2248
2352
|
ciphertext_b64: toBase64(ct),
|
|
@@ -2271,7 +2375,10 @@ var GroupMessaging = class {
|
|
|
2271
2375
|
} : null,
|
|
2272
2376
|
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
2273
2377
|
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
2274
|
-
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
2378
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
2379
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
2380
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
2381
|
+
...expiry ? { expiry } : {}
|
|
2275
2382
|
};
|
|
2276
2383
|
try {
|
|
2277
2384
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -2279,6 +2386,51 @@ var GroupMessaging = class {
|
|
|
2279
2386
|
}
|
|
2280
2387
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
2281
2388
|
}
|
|
2389
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
2390
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
2391
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
2392
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
2393
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
2394
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
2395
|
+
async sendTimerSet(group, args) {
|
|
2396
|
+
const plaintext = encodeTimerSet({
|
|
2397
|
+
clientMsgId: args.clientMsgId,
|
|
2398
|
+
ttlSeconds: args.ttlSeconds,
|
|
2399
|
+
start: args.start
|
|
2400
|
+
});
|
|
2401
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2402
|
+
const body = {
|
|
2403
|
+
ciphertext_b64: toBase64(ct),
|
|
2404
|
+
client_idem_key: randomId()
|
|
2405
|
+
};
|
|
2406
|
+
const wire = await palbeRequest(
|
|
2407
|
+
this.rt,
|
|
2408
|
+
"POST",
|
|
2409
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
2410
|
+
{ body }
|
|
2411
|
+
);
|
|
2412
|
+
const stored = {
|
|
2413
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
2414
|
+
direction: "outgoing",
|
|
2415
|
+
text: null,
|
|
2416
|
+
senderDeviceId: this.selfDeviceId,
|
|
2417
|
+
epoch: wire.epoch,
|
|
2418
|
+
serverSeq: wire.server_seq,
|
|
2419
|
+
at: Date.now(),
|
|
2420
|
+
clientMsgId: args.clientMsgId,
|
|
2421
|
+
replyTo: null,
|
|
2422
|
+
envelopeType: "timer_set",
|
|
2423
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
2424
|
+
};
|
|
2425
|
+
try {
|
|
2426
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
2427
|
+
} catch {
|
|
2428
|
+
}
|
|
2429
|
+
return {
|
|
2430
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
2431
|
+
clientMsgId: args.clientMsgId
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2282
2434
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
2283
2435
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
2284
2436
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -2581,6 +2733,43 @@ var ReactionFold = class {
|
|
|
2581
2733
|
}
|
|
2582
2734
|
};
|
|
2583
2735
|
|
|
2736
|
+
// src/messaging/timer-fold.ts
|
|
2737
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2738
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
2739
|
+
return aSeq <= bSeq;
|
|
2740
|
+
}
|
|
2741
|
+
var TimerFold = class {
|
|
2742
|
+
cell = null;
|
|
2743
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
2744
|
+
ingest(e) {
|
|
2745
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
2746
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2747
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
this.cell = {
|
|
2751
|
+
orderEpoch: e.epoch,
|
|
2752
|
+
orderSeq: e.serverSeq,
|
|
2753
|
+
ttlSeconds: e.ttlSeconds,
|
|
2754
|
+
start: e.start,
|
|
2755
|
+
actor: e.actorUserId
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* The active chat default, or null if no timer_set has applied.
|
|
2760
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
2761
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
2762
|
+
*/
|
|
2763
|
+
active() {
|
|
2764
|
+
if (this.cell === null) return null;
|
|
2765
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
2766
|
+
}
|
|
2767
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
2768
|
+
lastActor() {
|
|
2769
|
+
return this.cell?.actor ?? null;
|
|
2770
|
+
}
|
|
2771
|
+
};
|
|
2772
|
+
|
|
2584
2773
|
// src/messaging/chat.ts
|
|
2585
2774
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
2586
2775
|
var Chat = class {
|
|
@@ -2607,6 +2796,22 @@ var Chat = class {
|
|
|
2607
2796
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
2608
2797
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
2609
2798
|
deleteFold = new DeleteFold();
|
|
2799
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
2800
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
2801
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
2802
|
+
* no per-message expiry (disappearing T10). */
|
|
2803
|
+
timerFold = new TimerFold();
|
|
2804
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
2805
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
2806
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
2807
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
2808
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
2809
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
2810
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
2811
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
2812
|
+
* tombstone (disappearing T10). */
|
|
2813
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
2814
|
+
purgedLoaded = false;
|
|
2610
2815
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
2611
2816
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
2612
2817
|
suppressed = /* @__PURE__ */ new Set();
|
|
@@ -2717,7 +2922,9 @@ var Chat = class {
|
|
|
2717
2922
|
replyTo: null,
|
|
2718
2923
|
edited: false,
|
|
2719
2924
|
isDeleted: true,
|
|
2720
|
-
mentions: []
|
|
2925
|
+
mentions: [],
|
|
2926
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
2927
|
+
expiresAt: null
|
|
2721
2928
|
});
|
|
2722
2929
|
continue;
|
|
2723
2930
|
}
|
|
@@ -2754,9 +2961,21 @@ var Chat = class {
|
|
|
2754
2961
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
2755
2962
|
void this.loadSuppressed();
|
|
2756
2963
|
void this.loadElevated();
|
|
2757
|
-
void this.hydrateHistory();
|
|
2964
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
2758
2965
|
void this.refreshMembers();
|
|
2759
2966
|
}
|
|
2967
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
2968
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
2969
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
2970
|
+
async loadPurged() {
|
|
2971
|
+
if (this.purgedLoaded || !this._group) return;
|
|
2972
|
+
this.purgedLoaded = true;
|
|
2973
|
+
try {
|
|
2974
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
2975
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
2976
|
+
} catch {
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2760
2979
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
2761
2980
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
2762
2981
|
async loadSuppressed() {
|
|
@@ -2813,13 +3032,16 @@ var Chat = class {
|
|
|
2813
3032
|
if (this.seenKeys.has(key)) continue;
|
|
2814
3033
|
this.seenKeys.add(key);
|
|
2815
3034
|
if (m.clientMsgId && !m.isDeleted) {
|
|
2816
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
3035
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
2817
3036
|
}
|
|
2818
3037
|
this.messageList.push(
|
|
2819
3038
|
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
2820
3039
|
);
|
|
2821
3040
|
changed = true;
|
|
2822
3041
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3042
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
3043
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
3044
|
+
}
|
|
2823
3045
|
}
|
|
2824
3046
|
if (changed) {
|
|
2825
3047
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -2834,6 +3056,7 @@ var Chat = class {
|
|
|
2834
3056
|
return;
|
|
2835
3057
|
}
|
|
2836
3058
|
if (incoming.serverSeq <= 0) return;
|
|
3059
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
2837
3060
|
const key = this.internalKey(incoming.serverSeq);
|
|
2838
3061
|
if (this.seenKeys.has(key)) return;
|
|
2839
3062
|
this.seenKeys.add(key);
|
|
@@ -2842,6 +3065,20 @@ var Chat = class {
|
|
|
2842
3065
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
2843
3066
|
}
|
|
2844
3067
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
3068
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
3069
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3070
|
+
if (actorUserId !== null) {
|
|
3071
|
+
this.timerFold.ingest({
|
|
3072
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
3073
|
+
start: incoming.timer.start,
|
|
3074
|
+
actorUserId,
|
|
3075
|
+
epoch: incoming.epoch,
|
|
3076
|
+
serverSeq: incoming.serverSeq,
|
|
3077
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
2845
3082
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
2846
3083
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
2847
3084
|
if (actorUserId !== null) {
|
|
@@ -2916,7 +3153,10 @@ var Chat = class {
|
|
|
2916
3153
|
edited: false,
|
|
2917
3154
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
2918
3155
|
isDeleted: false,
|
|
2919
|
-
mentions
|
|
3156
|
+
mentions,
|
|
3157
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
3158
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
3159
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
2920
3160
|
};
|
|
2921
3161
|
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
2922
3162
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
@@ -2928,7 +3168,10 @@ var Chat = class {
|
|
|
2928
3168
|
if (incomingClientMsgId) {
|
|
2929
3169
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
2930
3170
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2931
|
-
this.deleteFold.reevaluatePending(
|
|
3171
|
+
this.deleteFold.reevaluatePending(
|
|
3172
|
+
incomingClientMsgId,
|
|
3173
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
3174
|
+
);
|
|
2932
3175
|
}
|
|
2933
3176
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
2934
3177
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -2937,11 +3180,131 @@ var Chat = class {
|
|
|
2937
3180
|
incoming.serverSeq
|
|
2938
3181
|
);
|
|
2939
3182
|
this.emit();
|
|
3183
|
+
void this.armPurge(
|
|
3184
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
3185
|
+
incoming.serverSeq,
|
|
3186
|
+
incomingClientMsgId
|
|
3187
|
+
);
|
|
2940
3188
|
}
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
*
|
|
2944
|
-
|
|
3189
|
+
// ── Disappearing (TTL — T10) ──
|
|
3190
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
3191
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
3192
|
+
toExpirySpec(e) {
|
|
3193
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
3194
|
+
}
|
|
3195
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
3196
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
3197
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
3198
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
3199
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
3200
|
+
defaultExpiry() {
|
|
3201
|
+
const active = this.timerFold.active();
|
|
3202
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
3203
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
3204
|
+
}
|
|
3205
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
3206
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
3207
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
3208
|
+
* UI countdown baseline. */
|
|
3209
|
+
deadlineFor(expiry) {
|
|
3210
|
+
if (!expiry) return null;
|
|
3211
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
3212
|
+
}
|
|
3213
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
3214
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
3215
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
3216
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
3217
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
3218
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
3219
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
3220
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
3221
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
3222
|
+
const group = this._group;
|
|
3223
|
+
const fresh = {
|
|
3224
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
3225
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
3226
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
3227
|
+
};
|
|
3228
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
3229
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
3230
|
+
const result = remainingSeconds({
|
|
3231
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
3232
|
+
anchor: effective,
|
|
3233
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
3234
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
3235
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
3236
|
+
});
|
|
3237
|
+
let purgeInSeconds;
|
|
3238
|
+
if (result.kind === "purgeNow") {
|
|
3239
|
+
purgeInSeconds = 0;
|
|
3240
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
3241
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
3242
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
3243
|
+
} else {
|
|
3244
|
+
purgeInSeconds = result.seconds;
|
|
3245
|
+
}
|
|
3246
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
3247
|
+
if (prior) clearTimeout(prior);
|
|
3248
|
+
this.purgeTimers.delete(serverSeq);
|
|
3249
|
+
if (purgeInSeconds <= 0) {
|
|
3250
|
+
await this.purge(serverSeq, clientMsgId);
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
const handle = setTimeout(() => {
|
|
3254
|
+
void this.purge(serverSeq, clientMsgId);
|
|
3255
|
+
}, purgeInSeconds * 1e3);
|
|
3256
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
3257
|
+
}
|
|
3258
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
3259
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
3260
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
3261
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
3262
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
3263
|
+
if (!this._group) return;
|
|
3264
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
3265
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
3266
|
+
if (prior) clearTimeout(prior);
|
|
3267
|
+
this.purgeTimers.delete(serverSeq);
|
|
3268
|
+
if (remainingMs <= 0) {
|
|
3269
|
+
await this.purge(serverSeq, clientMsgId);
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
const handle = setTimeout(() => {
|
|
3273
|
+
void this.purge(serverSeq, clientMsgId);
|
|
3274
|
+
}, remainingMs);
|
|
3275
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
3276
|
+
}
|
|
3277
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
3278
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
3279
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
3280
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
3281
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
3282
|
+
async purge(serverSeq, clientMsgId) {
|
|
3283
|
+
if (!this._group) return;
|
|
3284
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
3285
|
+
if (prior) clearTimeout(prior);
|
|
3286
|
+
this.purgeTimers.delete(serverSeq);
|
|
3287
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
3288
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
3289
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
3290
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
3291
|
+
this.emit();
|
|
3292
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3293
|
+
if (clientMsgId) {
|
|
3294
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
3298
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
3299
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
3300
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
3301
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
3302
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
3303
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
3304
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
3305
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
3306
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
3307
|
+
};
|
|
2945
3308
|
// ── Mentions (mentions T6) ──
|
|
2946
3309
|
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
2947
3310
|
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
@@ -3227,11 +3590,63 @@ var Chat = class {
|
|
|
3227
3590
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3228
3591
|
}
|
|
3229
3592
|
const bodyRanges = opts?.mentions ?? null;
|
|
3230
|
-
const
|
|
3231
|
-
|
|
3593
|
+
const effectiveExpiry = this.composeExpiry(opts?.expiresIn);
|
|
3594
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
3595
|
+
group,
|
|
3596
|
+
text,
|
|
3597
|
+
replyRef,
|
|
3598
|
+
bodyRanges,
|
|
3599
|
+
effectiveExpiry
|
|
3600
|
+
);
|
|
3601
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, effectiveExpiry);
|
|
3602
|
+
if (effectiveExpiry) void this.armPurge(effectiveExpiry, receipt.serverSeq, clientMsgId);
|
|
3232
3603
|
return receipt;
|
|
3233
3604
|
}
|
|
3234
|
-
|
|
3605
|
+
/** Resolve a send's effective per-message expiry at COMPOSE TIME: the caller's explicit
|
|
3606
|
+
* `expiresIn` if present, ELSE the chat's active default timer stamped onto the message
|
|
3607
|
+
* NOW (the durable record per spec §"Compose-time stamping"). A `send`-anchored expiry
|
|
3608
|
+
* (explicit or default-inherited) stamps `senderSendTs` = the sender's compose epoch
|
|
3609
|
+
* seconds; a `read`-anchored one carries none (the deadline is the recipient's local
|
|
3610
|
+
* first-read). Returns null when there is neither an explicit expiry nor an active
|
|
3611
|
+
* default (a plain, non-disappearing send). Mirrors the iOS compose-time stamping. */
|
|
3612
|
+
composeExpiry(explicit) {
|
|
3613
|
+
const base = explicit ? {
|
|
3614
|
+
v: 1,
|
|
3615
|
+
ttlSeconds: explicit.ttlSeconds,
|
|
3616
|
+
start: explicit.start ?? "send",
|
|
3617
|
+
senderSendTs: null
|
|
3618
|
+
} : this.defaultExpiry();
|
|
3619
|
+
if (!base) return null;
|
|
3620
|
+
return {
|
|
3621
|
+
...base,
|
|
3622
|
+
senderSendTs: base.start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
3623
|
+
};
|
|
3624
|
+
}
|
|
3625
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
3626
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
3627
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
3628
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
3629
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
3630
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
3631
|
+
async setDisappearing(opts) {
|
|
3632
|
+
const group = await this.materializeIfNeeded();
|
|
3633
|
+
const clientMsgId = mintClientMsgId();
|
|
3634
|
+
const start = opts.start ?? "send";
|
|
3635
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
3636
|
+
clientMsgId,
|
|
3637
|
+
ttlSeconds: opts.ttlSeconds,
|
|
3638
|
+
start
|
|
3639
|
+
});
|
|
3640
|
+
this.timerFold.ingest({
|
|
3641
|
+
ttlSeconds: opts.ttlSeconds,
|
|
3642
|
+
start,
|
|
3643
|
+
actorUserId: this.backend.selfUserId,
|
|
3644
|
+
epoch: receipt.epoch,
|
|
3645
|
+
serverSeq: receipt.serverSeq,
|
|
3646
|
+
eventClientMsgId: clientMsgId
|
|
3647
|
+
});
|
|
3648
|
+
}
|
|
3649
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges, expiry) {
|
|
3235
3650
|
if (receipt.serverSeq <= 0) return;
|
|
3236
3651
|
const key = this.internalKey(receipt.serverSeq);
|
|
3237
3652
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3259,7 +3674,12 @@ var Chat = class {
|
|
|
3259
3674
|
isDeleted: false,
|
|
3260
3675
|
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
3261
3676
|
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
3262
|
-
mentions: this.resolveMentions(text, bodyRanges)
|
|
3677
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
3678
|
+
// Disappearing T10: the surfaced deadline reflects the message's effective expiry
|
|
3679
|
+
// (explicit `expiresIn` OR the chat default stamped at compose time). null only when
|
|
3680
|
+
// this send is non-disappearing. armPurge re-derives the durable monotonic deadline;
|
|
3681
|
+
// this is the immediate UI countdown baseline (own sender and receiver are symmetric).
|
|
3682
|
+
expiresAt: this.deadlineFor(expiry ?? null)
|
|
3263
3683
|
});
|
|
3264
3684
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3265
3685
|
this.emit();
|
|
@@ -3614,6 +4034,7 @@ var MessageDeliverySource = class {
|
|
|
3614
4034
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
3615
4035
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
3616
4036
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
4037
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
3617
4038
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3618
4039
|
const stored = {
|
|
3619
4040
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3671,7 +4092,18 @@ var MessageDeliverySource = class {
|
|
|
3671
4092
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
3672
4093
|
scope: decoded.delete.scope
|
|
3673
4094
|
}
|
|
3674
|
-
} : {}
|
|
4095
|
+
} : {},
|
|
4096
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
4097
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
4098
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
4099
|
+
...isTimerSet && decoded.timer ? {
|
|
4100
|
+
envelopeType: "timer_set",
|
|
4101
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
4102
|
+
} : {},
|
|
4103
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
4104
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
4105
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
4106
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
3675
4107
|
};
|
|
3676
4108
|
try {
|
|
3677
4109
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3694,7 +4126,12 @@ var MessageDeliverySource = class {
|
|
|
3694
4126
|
delete: isDelete ? decoded.delete : null,
|
|
3695
4127
|
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
3696
4128
|
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
3697
|
-
bodyRanges: decoded.bodyRanges ?? null
|
|
4129
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
4130
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
4131
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
4132
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
4133
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
4134
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
3698
4135
|
});
|
|
3699
4136
|
return true;
|
|
3700
4137
|
}
|
|
@@ -3773,6 +4210,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
3773
4210
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
3774
4211
|
}
|
|
3775
4212
|
|
|
4213
|
+
// src/messaging/disappearing.ts
|
|
4214
|
+
var DisappearingStore = class {
|
|
4215
|
+
constructor(kv) {
|
|
4216
|
+
this.kv = kv;
|
|
4217
|
+
}
|
|
4218
|
+
kv;
|
|
4219
|
+
key(rfc) {
|
|
4220
|
+
return `disappear:${rfc}`;
|
|
4221
|
+
}
|
|
4222
|
+
async load(rfc) {
|
|
4223
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
4224
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
4225
|
+
try {
|
|
4226
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
4227
|
+
return {
|
|
4228
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
4229
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
4230
|
+
anchors: r.anchors ?? {}
|
|
4231
|
+
};
|
|
4232
|
+
} catch {
|
|
4233
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
async save(rfc, rec) {
|
|
4237
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
4238
|
+
}
|
|
4239
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
4240
|
+
async tombstonedSeqs(rfc) {
|
|
4241
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
4242
|
+
}
|
|
4243
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
4244
|
+
async purgedClientMsgIds(rfc) {
|
|
4245
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
4246
|
+
}
|
|
4247
|
+
/**
|
|
4248
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
4249
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
4250
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
4251
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
4252
|
+
*/
|
|
4253
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
4254
|
+
const rec = await this.load(rfc);
|
|
4255
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
4256
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
4257
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
4258
|
+
}
|
|
4259
|
+
await this.save(rfc, rec);
|
|
4260
|
+
}
|
|
4261
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
4262
|
+
async anchor(rfc, clientMsgId) {
|
|
4263
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
4264
|
+
}
|
|
4265
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
4266
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
4267
|
+
const rec = await this.load(rfc);
|
|
4268
|
+
if (rec.anchors[clientMsgId]) return;
|
|
4269
|
+
rec.anchors[clientMsgId] = a;
|
|
4270
|
+
await this.save(rfc, rec);
|
|
4271
|
+
}
|
|
4272
|
+
};
|
|
4273
|
+
|
|
3776
4274
|
// src/messaging/history.ts
|
|
3777
4275
|
var MessageStore = class {
|
|
3778
4276
|
constructor(kv) {
|
|
@@ -5587,6 +6085,7 @@ var MessagingCoordinator = class {
|
|
|
5587
6085
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
5588
6086
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5589
6087
|
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6088
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
5590
6089
|
this.registry.attachChatList(
|
|
5591
6090
|
(chats) => {
|
|
5592
6091
|
this.chatList = chats;
|
|
@@ -5603,6 +6102,7 @@ var MessagingCoordinator = class {
|
|
|
5603
6102
|
kpStore;
|
|
5604
6103
|
suppressionStore;
|
|
5605
6104
|
elevationStore;
|
|
6105
|
+
disappearingStore;
|
|
5606
6106
|
registry = new GroupRegistry();
|
|
5607
6107
|
resolved = null;
|
|
5608
6108
|
resolvePromise = null;
|
|
@@ -5774,6 +6274,10 @@ var MessagingCoordinator = class {
|
|
|
5774
6274
|
const r = await this.resolve();
|
|
5775
6275
|
return r.groups.sendDelete(group, args);
|
|
5776
6276
|
}
|
|
6277
|
+
async sendTimerSet(group, args) {
|
|
6278
|
+
const r = await this.resolve();
|
|
6279
|
+
return r.groups.sendTimerSet(group, args);
|
|
6280
|
+
}
|
|
5777
6281
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
5778
6282
|
loadSuppressed(group) {
|
|
5779
6283
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -5790,10 +6294,28 @@ var MessagingCoordinator = class {
|
|
|
5790
6294
|
saveElevated(group, keys) {
|
|
5791
6295
|
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
5792
6296
|
}
|
|
6297
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
6298
|
+
tombstonedSeqs(group) {
|
|
6299
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
6300
|
+
}
|
|
6301
|
+
purgedClientMsgIds(group) {
|
|
6302
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
6303
|
+
}
|
|
6304
|
+
anchor(group, clientMsgId) {
|
|
6305
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
6306
|
+
}
|
|
6307
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
6308
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
6309
|
+
}
|
|
6310
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
6311
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
6312
|
+
}
|
|
5793
6313
|
async history(group, limit, before) {
|
|
5794
6314
|
const r = await this.resolve();
|
|
5795
6315
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
5796
|
-
|
|
6316
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
6317
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
6318
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
5797
6319
|
}
|
|
5798
6320
|
async members(group) {
|
|
5799
6321
|
const r = await this.resolve();
|
|
@@ -5870,9 +6392,10 @@ var MessagingCoordinator = class {
|
|
|
5870
6392
|
return res.devices.map((d) => d.device_id);
|
|
5871
6393
|
}
|
|
5872
6394
|
};
|
|
5873
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
6395
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
6396
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
5874
6397
|
const fold = new ReactionFold();
|
|
5875
|
-
for (const s of
|
|
6398
|
+
for (const s of visible) {
|
|
5876
6399
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
5877
6400
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5878
6401
|
if (actor === null) continue;
|
|
@@ -5888,17 +6411,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5888
6411
|
}
|
|
5889
6412
|
const editFold = new EditFold();
|
|
5890
6413
|
const deleteFold = new DeleteFold();
|
|
6414
|
+
const pageTimerFold = new TimerFold();
|
|
5891
6415
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
5892
|
-
for (const s of
|
|
5893
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6416
|
+
for (const s of visible) {
|
|
6417
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
5894
6418
|
continue;
|
|
5895
6419
|
const cid = s.clientMsgId ?? "";
|
|
5896
6420
|
if (!cid) continue;
|
|
5897
6421
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
5898
6422
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
5899
6423
|
}
|
|
5900
|
-
const authorOfTarget = (cid) =>
|
|
5901
|
-
|
|
6424
|
+
const authorOfTarget = (cid) => {
|
|
6425
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
6426
|
+
const a = authorByClientMsgId.get(cid);
|
|
6427
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
6428
|
+
};
|
|
6429
|
+
for (const s of visible) {
|
|
5902
6430
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
5903
6431
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5904
6432
|
editFold.ingest(
|
|
@@ -5917,7 +6445,7 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5917
6445
|
);
|
|
5918
6446
|
}
|
|
5919
6447
|
editFold.reevaluateHeld(authorOfTarget);
|
|
5920
|
-
for (const s of
|
|
6448
|
+
for (const s of visible) {
|
|
5921
6449
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
5922
6450
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5923
6451
|
deleteFold.ingest(
|
|
@@ -5931,10 +6459,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5931
6459
|
authorOfTarget
|
|
5932
6460
|
);
|
|
5933
6461
|
}
|
|
5934
|
-
for (const [cid
|
|
6462
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
5935
6463
|
const lookup = /* @__PURE__ */ new Map();
|
|
5936
|
-
for (const s of
|
|
5937
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6464
|
+
for (const s of visible) {
|
|
6465
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
5938
6466
|
continue;
|
|
5939
6467
|
const cid = s.clientMsgId ?? "";
|
|
5940
6468
|
if (cid && s.text !== null) {
|
|
@@ -5943,7 +6471,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5943
6471
|
}
|
|
5944
6472
|
}
|
|
5945
6473
|
const out = [];
|
|
5946
|
-
for (const s of
|
|
6474
|
+
for (const s of visible) {
|
|
6475
|
+
if (s.envelopeType === "timer_set") {
|
|
6476
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6477
|
+
if (actor !== null && s.timer) {
|
|
6478
|
+
pageTimerFold.ingest({
|
|
6479
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
6480
|
+
start: s.timer.start,
|
|
6481
|
+
actorUserId: actor,
|
|
6482
|
+
epoch: s.epoch,
|
|
6483
|
+
serverSeq: s.serverSeq,
|
|
6484
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6485
|
+
});
|
|
6486
|
+
}
|
|
6487
|
+
continue;
|
|
6488
|
+
}
|
|
5947
6489
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5948
6490
|
continue;
|
|
5949
6491
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -5963,10 +6505,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5963
6505
|
edited: false,
|
|
5964
6506
|
isDeleted: true,
|
|
5965
6507
|
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
5966
|
-
mentions: []
|
|
6508
|
+
mentions: [],
|
|
6509
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
6510
|
+
expiresAt: null
|
|
5967
6511
|
});
|
|
5968
6512
|
continue;
|
|
5969
6513
|
}
|
|
6514
|
+
let expiresAt = null;
|
|
6515
|
+
if (s.expiry) {
|
|
6516
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
6517
|
+
} else {
|
|
6518
|
+
const active = pageTimerFold.active();
|
|
6519
|
+
if (active && active.ttlSeconds !== null) {
|
|
6520
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
6521
|
+
}
|
|
6522
|
+
}
|
|
5970
6523
|
let replyTo = null;
|
|
5971
6524
|
if (s.replyTo) {
|
|
5972
6525
|
const ref = {
|
|
@@ -5999,7 +6552,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5999
6552
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6000
6553
|
edited,
|
|
6001
6554
|
isDeleted: false,
|
|
6002
|
-
mentions
|
|
6555
|
+
mentions,
|
|
6556
|
+
expiresAt
|
|
6003
6557
|
});
|
|
6004
6558
|
}
|
|
6005
6559
|
return out;
|
|
@@ -6865,7 +7419,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
|
|
|
6865
7419
|
}
|
|
6866
7420
|
|
|
6867
7421
|
// src/version.ts
|
|
6868
|
-
var VERSION = "1.
|
|
7422
|
+
var VERSION = "1.6.1";
|
|
6869
7423
|
|
|
6870
7424
|
// src/internal.ts
|
|
6871
7425
|
function getRuntime() {
|