@palbase/web 1.4.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-CbXprYrS.d.ts → analytics-facade-DYNLUW0L.d.ts} +239 -7
- package/dist/{analytics-facade-DA9TGKLF.d.cts → analytics-facade-iAWinswQ.d.cts} +239 -7
- package/dist/{chunk-3EVGYJ5F.js → chunk-A5WIPBGQ.js} +893 -70
- package/dist/chunk-A5WIPBGQ.js.map +1 -0
- package/dist/index.cjs +892 -69
- 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 +892 -69
- 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 +892 -69
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +892 -69
- 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-SVs7vTOp.d.cts → pb-Blj85_cg.d.cts} +1 -1
- package/dist/{pb-CLfTLQzH.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 +2 -2
- package/dist/chunk-3EVGYJ5F.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") {
|
|
1631
1675
|
this.seen.add(e.eventClientMsgId);
|
|
1632
|
-
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (res.kind === "author") {
|
|
1679
|
+
this.seen.add(e.eventClientMsgId);
|
|
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;
|
|
@@ -1715,7 +1772,8 @@ var EditFold = class {
|
|
|
1715
1772
|
orderEpoch: e.epoch,
|
|
1716
1773
|
orderSeq: e.serverSeq,
|
|
1717
1774
|
lastEventId: e.eventClientMsgId,
|
|
1718
|
-
text: e.newText
|
|
1775
|
+
text: e.newText,
|
|
1776
|
+
bodyRanges: e.bodyRanges ?? null
|
|
1719
1777
|
});
|
|
1720
1778
|
this.editedTargets.add(e.targetClientMsgId);
|
|
1721
1779
|
}
|
|
@@ -1736,6 +1794,15 @@ var EditFold = class {
|
|
|
1736
1794
|
isEdited(targetClientMsgId) {
|
|
1737
1795
|
return this.editedTargets.has(targetClientMsgId);
|
|
1738
1796
|
}
|
|
1797
|
+
/**
|
|
1798
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
1799
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
1800
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
1801
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
1802
|
+
*/
|
|
1803
|
+
bodyRanges(targetClientMsgId) {
|
|
1804
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
1805
|
+
}
|
|
1739
1806
|
/**
|
|
1740
1807
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
1741
1808
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -1909,7 +1976,14 @@ function encodeEdit(args) {
|
|
|
1909
1976
|
type: "edit",
|
|
1910
1977
|
client_msg_id: args.clientMsgId,
|
|
1911
1978
|
target_client_msg_id: args.targetClientMsgId,
|
|
1912
|
-
new_text: args.newText
|
|
1979
|
+
new_text: args.newText,
|
|
1980
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
1981
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
1982
|
+
start: r.start,
|
|
1983
|
+
length: r.length,
|
|
1984
|
+
mentioned_user_id: r.mentionedUserId
|
|
1985
|
+
}))
|
|
1986
|
+
} : {}
|
|
1913
1987
|
})
|
|
1914
1988
|
);
|
|
1915
1989
|
}
|
|
@@ -1931,14 +2005,53 @@ function encodeEnvelope(args) {
|
|
|
1931
2005
|
type: "text",
|
|
1932
2006
|
client_msg_id: args.clientMsgId,
|
|
1933
2007
|
text: args.text,
|
|
1934
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
2008
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
2009
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2010
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2011
|
+
start: r.start,
|
|
2012
|
+
length: r.length,
|
|
2013
|
+
mentioned_user_id: r.mentionedUserId
|
|
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
|
+
}
|
|
2024
|
+
} : {}
|
|
1935
2025
|
};
|
|
1936
2026
|
return encodeUtf8(JSON.stringify(env));
|
|
1937
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
|
+
}
|
|
1938
2039
|
function decodeEnvelope(bytes) {
|
|
1939
2040
|
const s = decodeUtf8(bytes);
|
|
1940
2041
|
try {
|
|
1941
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
|
+
}
|
|
1942
2055
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
1943
2056
|
return {
|
|
1944
2057
|
type: "delete",
|
|
@@ -1965,6 +2078,7 @@ function decodeEnvelope(bytes) {
|
|
|
1965
2078
|
};
|
|
1966
2079
|
}
|
|
1967
2080
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2081
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
1968
2082
|
return {
|
|
1969
2083
|
type: "edit",
|
|
1970
2084
|
text: null,
|
|
@@ -1973,15 +2087,20 @@ function decodeEnvelope(bytes) {
|
|
|
1973
2087
|
edit: {
|
|
1974
2088
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
1975
2089
|
newText: o.new_text ?? ""
|
|
1976
|
-
}
|
|
2090
|
+
},
|
|
2091
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
1977
2092
|
};
|
|
1978
2093
|
}
|
|
1979
2094
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2095
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2096
|
+
const expiry = decodeExpiry(o.expiry);
|
|
1980
2097
|
return {
|
|
1981
2098
|
type: "text",
|
|
1982
2099
|
text: o.text ?? null,
|
|
1983
2100
|
clientMsgId: o.client_msg_id ?? "",
|
|
1984
|
-
replyTo: o.reply_to ?? null
|
|
2101
|
+
replyTo: o.reply_to ?? null,
|
|
2102
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
2103
|
+
...expiry ? { expiry } : {}
|
|
1985
2104
|
};
|
|
1986
2105
|
}
|
|
1987
2106
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -1993,6 +2112,27 @@ function decodeEnvelope(bytes) {
|
|
|
1993
2112
|
}
|
|
1994
2113
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
1995
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
|
+
}
|
|
2128
|
+
function decodeBodyRanges(raw) {
|
|
2129
|
+
if (!raw || raw.length === 0) return void 0;
|
|
2130
|
+
return raw.map((r) => ({
|
|
2131
|
+
start: r.start,
|
|
2132
|
+
length: r.length,
|
|
2133
|
+
mentionedUserId: r.mentioned_user_id
|
|
2134
|
+
}));
|
|
2135
|
+
}
|
|
1996
2136
|
function resolveReply(ref, lookup) {
|
|
1997
2137
|
const parent = lookup(ref.client_msg_id);
|
|
1998
2138
|
if (parent !== null) {
|
|
@@ -2204,9 +2344,9 @@ var GroupMessaging = class {
|
|
|
2204
2344
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
2205
2345
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
2206
2346
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
2207
|
-
async sendText(group, text, replyTo) {
|
|
2347
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
2208
2348
|
const clientMsgId = mintClientMsgId();
|
|
2209
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
2349
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
2210
2350
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2211
2351
|
const body = {
|
|
2212
2352
|
ciphertext_b64: toBase64(ct),
|
|
@@ -2232,7 +2372,13 @@ var GroupMessaging = class {
|
|
|
2232
2372
|
previewBody: replyTo.preview?.body ?? null,
|
|
2233
2373
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
2234
2374
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
2235
|
-
} : null
|
|
2375
|
+
} : null,
|
|
2376
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
2377
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
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 } : {}
|
|
2236
2382
|
};
|
|
2237
2383
|
try {
|
|
2238
2384
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -2240,6 +2386,51 @@ var GroupMessaging = class {
|
|
|
2240
2386
|
}
|
|
2241
2387
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
2242
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
|
+
}
|
|
2243
2434
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
2244
2435
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
2245
2436
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -2300,7 +2491,8 @@ var GroupMessaging = class {
|
|
|
2300
2491
|
const plaintext = encodeEdit({
|
|
2301
2492
|
clientMsgId: args.clientMsgId,
|
|
2302
2493
|
targetClientMsgId: args.targetClientMsgId,
|
|
2303
|
-
newText: args.newText
|
|
2494
|
+
newText: args.newText,
|
|
2495
|
+
bodyRanges: args.bodyRanges
|
|
2304
2496
|
});
|
|
2305
2497
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2306
2498
|
const body = {
|
|
@@ -2326,7 +2518,10 @@ var GroupMessaging = class {
|
|
|
2326
2518
|
envelopeType: "edit",
|
|
2327
2519
|
edit: {
|
|
2328
2520
|
targetClientMsgId: args.targetClientMsgId,
|
|
2329
|
-
newText: args.newText
|
|
2521
|
+
newText: args.newText,
|
|
2522
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
2523
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
2524
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
2330
2525
|
}
|
|
2331
2526
|
};
|
|
2332
2527
|
try {
|
|
@@ -2448,6 +2643,41 @@ var GroupMessaging = class {
|
|
|
2448
2643
|
}
|
|
2449
2644
|
};
|
|
2450
2645
|
|
|
2646
|
+
// src/messaging/mention-ranges.ts
|
|
2647
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
2648
|
+
const n = text.length;
|
|
2649
|
+
function splitsSurrogatePair(index) {
|
|
2650
|
+
if (index <= 0 || index >= n) return false;
|
|
2651
|
+
const before = text.charCodeAt(index - 1);
|
|
2652
|
+
const at = text.charCodeAt(index);
|
|
2653
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
2654
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
2655
|
+
return beforeIsHigh && atIsLow;
|
|
2656
|
+
}
|
|
2657
|
+
const survivors = [];
|
|
2658
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
2659
|
+
const r = ranges[idx];
|
|
2660
|
+
if (r === void 0) continue;
|
|
2661
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
2662
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
2663
|
+
survivors.push({ idx, range: r });
|
|
2664
|
+
}
|
|
2665
|
+
survivors.sort((lhs, rhs) => {
|
|
2666
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
2667
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
2668
|
+
return lhs.idx - rhs.idx;
|
|
2669
|
+
});
|
|
2670
|
+
const kept = [];
|
|
2671
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
2672
|
+
for (const s of survivors) {
|
|
2673
|
+
if (s.range.start >= prevEnd) {
|
|
2674
|
+
kept.push(s.range);
|
|
2675
|
+
prevEnd = s.range.start + s.range.length;
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
return kept;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2451
2681
|
// src/messaging/reaction-fold.ts
|
|
2452
2682
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2453
2683
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -2503,6 +2733,43 @@ var ReactionFold = class {
|
|
|
2503
2733
|
}
|
|
2504
2734
|
};
|
|
2505
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
|
+
|
|
2506
2773
|
// src/messaging/chat.ts
|
|
2507
2774
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
2508
2775
|
var Chat = class {
|
|
@@ -2529,12 +2796,35 @@ var Chat = class {
|
|
|
2529
2796
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
2530
2797
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
2531
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;
|
|
2532
2815
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
2533
2816
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
2534
2817
|
suppressed = /* @__PURE__ */ new Set();
|
|
2535
2818
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
2536
2819
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
2537
2820
|
suppressedLoaded = false;
|
|
2821
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
2822
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
2823
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
2824
|
+
elevated = /* @__PURE__ */ new Set();
|
|
2825
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
2826
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
2827
|
+
elevatedLoaded = false;
|
|
2538
2828
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
2539
2829
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
2540
2830
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -2546,6 +2836,14 @@ var Chat = class {
|
|
|
2546
2836
|
wired = false;
|
|
2547
2837
|
liveUnsub = null;
|
|
2548
2838
|
listeners = /* @__PURE__ */ new Set();
|
|
2839
|
+
/**
|
|
2840
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
2841
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
2842
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
2843
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
2844
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
2845
|
+
*/
|
|
2846
|
+
onMentionElevation;
|
|
2549
2847
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
2550
2848
|
constructor(args) {
|
|
2551
2849
|
this.backend = args.backend;
|
|
@@ -2623,7 +2921,10 @@ var Chat = class {
|
|
|
2623
2921
|
reactions: {},
|
|
2624
2922
|
replyTo: null,
|
|
2625
2923
|
edited: false,
|
|
2626
|
-
isDeleted: true
|
|
2924
|
+
isDeleted: true,
|
|
2925
|
+
mentions: [],
|
|
2926
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
2927
|
+
expiresAt: null
|
|
2627
2928
|
});
|
|
2628
2929
|
continue;
|
|
2629
2930
|
}
|
|
@@ -2659,9 +2960,22 @@ var Chat = class {
|
|
|
2659
2960
|
this.wired = true;
|
|
2660
2961
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
2661
2962
|
void this.loadSuppressed();
|
|
2662
|
-
void this.
|
|
2963
|
+
void this.loadElevated();
|
|
2964
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
2663
2965
|
void this.refreshMembers();
|
|
2664
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
|
+
}
|
|
2665
2979
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
2666
2980
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
2667
2981
|
async loadSuppressed() {
|
|
@@ -2680,6 +2994,17 @@ var Chat = class {
|
|
|
2680
2994
|
} catch {
|
|
2681
2995
|
}
|
|
2682
2996
|
}
|
|
2997
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
2998
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
2999
|
+
async loadElevated() {
|
|
3000
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
3001
|
+
this.elevatedLoaded = true;
|
|
3002
|
+
try {
|
|
3003
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
3004
|
+
for (const k of keys) this.elevated.add(k);
|
|
3005
|
+
} catch {
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
2683
3008
|
async hydrateHistory() {
|
|
2684
3009
|
if (this.historyLoaded || !this._group) return;
|
|
2685
3010
|
this.historyLoaded = true;
|
|
@@ -2707,11 +3032,16 @@ var Chat = class {
|
|
|
2707
3032
|
if (this.seenKeys.has(key)) continue;
|
|
2708
3033
|
this.seenKeys.add(key);
|
|
2709
3034
|
if (m.clientMsgId && !m.isDeleted) {
|
|
2710
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
3035
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
2711
3036
|
}
|
|
2712
|
-
this.messageList.push(
|
|
3037
|
+
this.messageList.push(
|
|
3038
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3039
|
+
);
|
|
2713
3040
|
changed = true;
|
|
2714
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
|
+
}
|
|
2715
3045
|
}
|
|
2716
3046
|
if (changed) {
|
|
2717
3047
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -2726,6 +3056,7 @@ var Chat = class {
|
|
|
2726
3056
|
return;
|
|
2727
3057
|
}
|
|
2728
3058
|
if (incoming.serverSeq <= 0) return;
|
|
3059
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
2729
3060
|
const key = this.internalKey(incoming.serverSeq);
|
|
2730
3061
|
if (this.seenKeys.has(key)) return;
|
|
2731
3062
|
this.seenKeys.add(key);
|
|
@@ -2734,6 +3065,20 @@ var Chat = class {
|
|
|
2734
3065
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
2735
3066
|
}
|
|
2736
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
|
+
}
|
|
2737
3082
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
2738
3083
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
2739
3084
|
if (actorUserId !== null) {
|
|
@@ -2759,7 +3104,10 @@ var Chat = class {
|
|
|
2759
3104
|
newText: incoming.edit.newText,
|
|
2760
3105
|
epoch: incoming.epoch,
|
|
2761
3106
|
serverSeq: incoming.serverSeq,
|
|
2762
|
-
eventClientMsgId: incoming.clientMsgId
|
|
3107
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
3108
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
3109
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
3110
|
+
bodyRanges: incoming.bodyRanges
|
|
2763
3111
|
},
|
|
2764
3112
|
this.authorOfTarget
|
|
2765
3113
|
);
|
|
@@ -2787,6 +3135,7 @@ var Chat = class {
|
|
|
2787
3135
|
if (incomingReplyRef) {
|
|
2788
3136
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
2789
3137
|
}
|
|
3138
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
2790
3139
|
const msg = {
|
|
2791
3140
|
id: this.publicId(incoming.serverSeq),
|
|
2792
3141
|
kind: this.kindOf(incoming),
|
|
@@ -2803,8 +3152,13 @@ var Chat = class {
|
|
|
2803
3152
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
2804
3153
|
edited: false,
|
|
2805
3154
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
2806
|
-
isDeleted: false
|
|
3155
|
+
isDeleted: false,
|
|
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())
|
|
2807
3160
|
};
|
|
3161
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
2808
3162
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
2809
3163
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
2810
3164
|
text: incoming.text,
|
|
@@ -2814,7 +3168,10 @@ var Chat = class {
|
|
|
2814
3168
|
if (incomingClientMsgId) {
|
|
2815
3169
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
2816
3170
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2817
|
-
this.deleteFold.reevaluatePending(
|
|
3171
|
+
this.deleteFold.reevaluatePending(
|
|
3172
|
+
incomingClientMsgId,
|
|
3173
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
3174
|
+
);
|
|
2818
3175
|
}
|
|
2819
3176
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
2820
3177
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -2823,11 +3180,200 @@ var Chat = class {
|
|
|
2823
3180
|
incoming.serverSeq
|
|
2824
3181
|
);
|
|
2825
3182
|
this.emit();
|
|
3183
|
+
void this.armPurge(
|
|
3184
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
3185
|
+
incoming.serverSeq,
|
|
3186
|
+
incomingClientMsgId
|
|
3187
|
+
);
|
|
3188
|
+
}
|
|
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
|
+
};
|
|
3308
|
+
// ── Mentions (mentions T6) ──
|
|
3309
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3310
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
3311
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
3312
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
3313
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
3314
|
+
resolveMentions(text, bodyRanges) {
|
|
3315
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
3316
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
3317
|
+
if (normalized.length === 0) return [];
|
|
3318
|
+
return normalized.map((r) => ({
|
|
3319
|
+
start: r.start,
|
|
3320
|
+
length: r.length,
|
|
3321
|
+
mentionedUserId: r.mentionedUserId,
|
|
3322
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
3323
|
+
}));
|
|
3324
|
+
}
|
|
3325
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
3326
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
3327
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
3328
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
3329
|
+
resolveMentionNames(m) {
|
|
3330
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
3331
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
3332
|
+
}
|
|
3333
|
+
let changed = false;
|
|
3334
|
+
const reresolved = m.mentions.map((span) => {
|
|
3335
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
3336
|
+
if (name === span.displayName) return span;
|
|
3337
|
+
changed = true;
|
|
3338
|
+
return { ...span, displayName: name };
|
|
3339
|
+
});
|
|
3340
|
+
if (!changed) return m;
|
|
3341
|
+
return { ...m, mentions: reresolved };
|
|
3342
|
+
}
|
|
3343
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
3344
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
3345
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
3346
|
+
editMentions(targetClientMsgId, newText) {
|
|
3347
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
3348
|
+
if (!ranges) return [];
|
|
3349
|
+
return this.resolveMentions(newText, ranges);
|
|
3350
|
+
}
|
|
3351
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
3352
|
+
displayNameOf(userId) {
|
|
3353
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
3354
|
+
}
|
|
3355
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
3356
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
3357
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
3358
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
3359
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
3360
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
3361
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
3362
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
3363
|
+
const me = this.backend.selfUserId;
|
|
3364
|
+
if (envelopeType === "edit") return;
|
|
3365
|
+
if (senderUserId === me) return;
|
|
3366
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
3367
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
3368
|
+
const key = `${me}|${idPart}`;
|
|
3369
|
+
if (this.elevated.has(key)) return;
|
|
3370
|
+
this.elevated.add(key);
|
|
3371
|
+
if (this._group) {
|
|
3372
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
this.onMentionElevation?.(message);
|
|
2826
3376
|
}
|
|
2827
|
-
/** The EditFold author-gate input: the target message's resolved author userId
|
|
2828
|
-
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
2829
|
-
* so it can be passed to the pure EditFold. */
|
|
2830
|
-
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
2831
3377
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
2832
3378
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
2833
3379
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -2884,9 +3430,10 @@ var Chat = class {
|
|
|
2884
3430
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
2885
3431
|
const text = editText ?? base;
|
|
2886
3432
|
const edited = foldEdited || m.edited;
|
|
2887
|
-
|
|
3433
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
3434
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
2888
3435
|
changed = true;
|
|
2889
|
-
return { ...m, text, edited };
|
|
3436
|
+
return { ...m, text, edited, mentions };
|
|
2890
3437
|
});
|
|
2891
3438
|
if (changed) this.emit();
|
|
2892
3439
|
}
|
|
@@ -2903,8 +3450,9 @@ var Chat = class {
|
|
|
2903
3450
|
if (editText === null && !foldEdited) return m;
|
|
2904
3451
|
const text = editText ?? m.text;
|
|
2905
3452
|
const edited = foldEdited || m.edited;
|
|
2906
|
-
|
|
2907
|
-
|
|
3453
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
3454
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3455
|
+
return { ...m, text, edited, mentions };
|
|
2908
3456
|
}
|
|
2909
3457
|
/** @internal — called by the backend's conv subscription. */
|
|
2910
3458
|
applyConv(event, payload) {
|
|
@@ -2962,6 +3510,18 @@ var Chat = class {
|
|
|
2962
3510
|
}
|
|
2963
3511
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2964
3512
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3513
|
+
this.reresolveAllMentionNames();
|
|
3514
|
+
}
|
|
3515
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
3516
|
+
* change). Re-emits only if any name actually changed. */
|
|
3517
|
+
reresolveAllMentionNames() {
|
|
3518
|
+
let changed = false;
|
|
3519
|
+
this.messageList = this.messageList.map((m) => {
|
|
3520
|
+
const reresolved = this.resolveMentionNames(m);
|
|
3521
|
+
if (reresolved !== m) changed = true;
|
|
3522
|
+
return reresolved;
|
|
3523
|
+
});
|
|
3524
|
+
if (changed) this.emit();
|
|
2965
3525
|
}
|
|
2966
3526
|
seedMembersFromGroup(group) {
|
|
2967
3527
|
const seed = [
|
|
@@ -3029,11 +3589,50 @@ var Chat = class {
|
|
|
3029
3589
|
};
|
|
3030
3590
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3031
3591
|
}
|
|
3032
|
-
const
|
|
3033
|
-
|
|
3592
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3593
|
+
const start = opts?.expiresIn?.start ?? "send";
|
|
3594
|
+
const expiry = opts?.expiresIn ? {
|
|
3595
|
+
v: 1,
|
|
3596
|
+
ttlSeconds: opts.expiresIn.ttlSeconds,
|
|
3597
|
+
start,
|
|
3598
|
+
senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
3599
|
+
} : null;
|
|
3600
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
3601
|
+
group,
|
|
3602
|
+
text,
|
|
3603
|
+
replyRef,
|
|
3604
|
+
bodyRanges,
|
|
3605
|
+
expiry
|
|
3606
|
+
);
|
|
3607
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
3608
|
+
if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
|
|
3034
3609
|
return receipt;
|
|
3035
3610
|
}
|
|
3036
|
-
|
|
3611
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
3612
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
3613
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
3614
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
3615
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
3616
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
3617
|
+
async setDisappearing(opts) {
|
|
3618
|
+
const group = await this.materializeIfNeeded();
|
|
3619
|
+
const clientMsgId = mintClientMsgId();
|
|
3620
|
+
const start = opts.start ?? "send";
|
|
3621
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
3622
|
+
clientMsgId,
|
|
3623
|
+
ttlSeconds: opts.ttlSeconds,
|
|
3624
|
+
start
|
|
3625
|
+
});
|
|
3626
|
+
this.timerFold.ingest({
|
|
3627
|
+
ttlSeconds: opts.ttlSeconds,
|
|
3628
|
+
start,
|
|
3629
|
+
actorUserId: this.backend.selfUserId,
|
|
3630
|
+
epoch: receipt.epoch,
|
|
3631
|
+
serverSeq: receipt.serverSeq,
|
|
3632
|
+
eventClientMsgId: clientMsgId
|
|
3633
|
+
});
|
|
3634
|
+
}
|
|
3635
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
3037
3636
|
if (receipt.serverSeq <= 0) return;
|
|
3038
3637
|
const key = this.internalKey(receipt.serverSeq);
|
|
3039
3638
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3058,7 +3657,13 @@ var Chat = class {
|
|
|
3058
3657
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3059
3658
|
edited: false,
|
|
3060
3659
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3061
|
-
isDeleted: false
|
|
3660
|
+
isDeleted: false,
|
|
3661
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
3662
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
3663
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
3664
|
+
// Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
|
|
3665
|
+
// via the messageList overlay; default null here (a plain own-send has no deadline).
|
|
3666
|
+
expiresAt: null
|
|
3062
3667
|
});
|
|
3063
3668
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3064
3669
|
this.emit();
|
|
@@ -3146,15 +3751,18 @@ var Chat = class {
|
|
|
3146
3751
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3147
3752
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3148
3753
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
3149
|
-
* message self IS the author, so the author-gate passes.
|
|
3150
|
-
|
|
3754
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
3755
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
3756
|
+
async edit(message, newText, opts) {
|
|
3151
3757
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3152
3758
|
const group = await this.materializeIfNeeded();
|
|
3153
3759
|
const clientMsgId = mintClientMsgId();
|
|
3760
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3154
3761
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
3155
3762
|
clientMsgId,
|
|
3156
3763
|
targetClientMsgId: message.clientMsgId,
|
|
3157
|
-
newText
|
|
3764
|
+
newText,
|
|
3765
|
+
bodyRanges
|
|
3158
3766
|
});
|
|
3159
3767
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3160
3768
|
this.editFold.ingest(
|
|
@@ -3164,7 +3772,8 @@ var Chat = class {
|
|
|
3164
3772
|
newText,
|
|
3165
3773
|
epoch: receipt.epoch,
|
|
3166
3774
|
serverSeq: receipt.serverSeq,
|
|
3167
|
-
eventClientMsgId: clientMsgId
|
|
3775
|
+
eventClientMsgId: clientMsgId,
|
|
3776
|
+
bodyRanges
|
|
3168
3777
|
},
|
|
3169
3778
|
this.authorOfTarget
|
|
3170
3779
|
);
|
|
@@ -3217,6 +3826,18 @@ var Chat = class {
|
|
|
3217
3826
|
}
|
|
3218
3827
|
}
|
|
3219
3828
|
};
|
|
3829
|
+
function sameMentions(a, b) {
|
|
3830
|
+
if (a.length !== b.length) return false;
|
|
3831
|
+
for (let i = 0; i < a.length; i++) {
|
|
3832
|
+
const x = a[i];
|
|
3833
|
+
const y = b[i];
|
|
3834
|
+
if (!x || !y) return false;
|
|
3835
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
3836
|
+
return false;
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
return true;
|
|
3840
|
+
}
|
|
3220
3841
|
function sameReactions(a, b) {
|
|
3221
3842
|
const ak = Object.keys(a);
|
|
3222
3843
|
const bk = Object.keys(b);
|
|
@@ -3397,6 +4018,7 @@ var MessageDeliverySource = class {
|
|
|
3397
4018
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
3398
4019
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
3399
4020
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
4021
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
3400
4022
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3401
4023
|
const stored = {
|
|
3402
4024
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3428,14 +4050,21 @@ var MessageDeliverySource = class {
|
|
|
3428
4050
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
3429
4051
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
3430
4052
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
3431
|
-
// `'text'`/no-edit (backward-compat).
|
|
4053
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
4054
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
3432
4055
|
...isEdit && decoded.edit ? {
|
|
3433
4056
|
envelopeType: "edit",
|
|
3434
4057
|
edit: {
|
|
3435
4058
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
3436
|
-
newText: decoded.edit.newText
|
|
4059
|
+
newText: decoded.edit.newText,
|
|
4060
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
3437
4061
|
}
|
|
3438
4062
|
} : {},
|
|
4063
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
4064
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
4065
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
4066
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
4067
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
3439
4068
|
// Thread the delete discriminator + target through the persisted row so a
|
|
3440
4069
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
3441
4070
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -3447,7 +4076,18 @@ var MessageDeliverySource = class {
|
|
|
3447
4076
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
3448
4077
|
scope: decoded.delete.scope
|
|
3449
4078
|
}
|
|
3450
|
-
} : {}
|
|
4079
|
+
} : {},
|
|
4080
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
4081
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
4082
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
4083
|
+
...isTimerSet && decoded.timer ? {
|
|
4084
|
+
envelopeType: "timer_set",
|
|
4085
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
4086
|
+
} : {},
|
|
4087
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
4088
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
4089
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
4090
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
3451
4091
|
};
|
|
3452
4092
|
try {
|
|
3453
4093
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3467,7 +4107,15 @@ var MessageDeliverySource = class {
|
|
|
3467
4107
|
envelopeType: decoded.type ?? "text",
|
|
3468
4108
|
reaction: isReaction ? decoded.reaction : null,
|
|
3469
4109
|
edit: isEdit ? decoded.edit : null,
|
|
3470
|
-
delete: isDelete ? decoded.delete : null
|
|
4110
|
+
delete: isDelete ? decoded.delete : null,
|
|
4111
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4112
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4113
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
4114
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
4115
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
4116
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
4117
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
4118
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
3471
4119
|
});
|
|
3472
4120
|
return true;
|
|
3473
4121
|
}
|
|
@@ -3546,6 +4194,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
3546
4194
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
3547
4195
|
}
|
|
3548
4196
|
|
|
4197
|
+
// src/messaging/disappearing.ts
|
|
4198
|
+
var DisappearingStore = class {
|
|
4199
|
+
constructor(kv) {
|
|
4200
|
+
this.kv = kv;
|
|
4201
|
+
}
|
|
4202
|
+
kv;
|
|
4203
|
+
key(rfc) {
|
|
4204
|
+
return `disappear:${rfc}`;
|
|
4205
|
+
}
|
|
4206
|
+
async load(rfc) {
|
|
4207
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
4208
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
4209
|
+
try {
|
|
4210
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
4211
|
+
return {
|
|
4212
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
4213
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
4214
|
+
anchors: r.anchors ?? {}
|
|
4215
|
+
};
|
|
4216
|
+
} catch {
|
|
4217
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
async save(rfc, rec) {
|
|
4221
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
4222
|
+
}
|
|
4223
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
4224
|
+
async tombstonedSeqs(rfc) {
|
|
4225
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
4226
|
+
}
|
|
4227
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
4228
|
+
async purgedClientMsgIds(rfc) {
|
|
4229
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
4230
|
+
}
|
|
4231
|
+
/**
|
|
4232
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
4233
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
4234
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
4235
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
4236
|
+
*/
|
|
4237
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
4238
|
+
const rec = await this.load(rfc);
|
|
4239
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
4240
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
4241
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
4242
|
+
}
|
|
4243
|
+
await this.save(rfc, rec);
|
|
4244
|
+
}
|
|
4245
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
4246
|
+
async anchor(rfc, clientMsgId) {
|
|
4247
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
4248
|
+
}
|
|
4249
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
4250
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
4251
|
+
const rec = await this.load(rfc);
|
|
4252
|
+
if (rec.anchors[clientMsgId]) return;
|
|
4253
|
+
rec.anchors[clientMsgId] = a;
|
|
4254
|
+
await this.save(rfc, rec);
|
|
4255
|
+
}
|
|
4256
|
+
};
|
|
4257
|
+
|
|
3549
4258
|
// src/messaging/history.ts
|
|
3550
4259
|
var MessageStore = class {
|
|
3551
4260
|
constructor(kv) {
|
|
@@ -3622,6 +4331,36 @@ var GroupCatalog = class {
|
|
|
3622
4331
|
}
|
|
3623
4332
|
};
|
|
3624
4333
|
|
|
4334
|
+
// src/messaging/mention-elevation.ts
|
|
4335
|
+
var MentionElevationStore = class {
|
|
4336
|
+
constructor(kv) {
|
|
4337
|
+
this.kv = kv;
|
|
4338
|
+
}
|
|
4339
|
+
kv;
|
|
4340
|
+
key(rfcGroupId) {
|
|
4341
|
+
return `elev:${rfcGroupId}`;
|
|
4342
|
+
}
|
|
4343
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
4344
|
+
async load(rfcGroupId) {
|
|
4345
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
4346
|
+
if (!raw) return [];
|
|
4347
|
+
try {
|
|
4348
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
4349
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4350
|
+
} catch {
|
|
4351
|
+
return [];
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4354
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
4355
|
+
async save(rfcGroupId, keys) {
|
|
4356
|
+
const sorted = [...new Set(keys)].sort();
|
|
4357
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
4358
|
+
}
|
|
4359
|
+
async wipe() {
|
|
4360
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
4361
|
+
}
|
|
4362
|
+
};
|
|
4363
|
+
|
|
3625
4364
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
3626
4365
|
var palbe_mls_bg_exports = {};
|
|
3627
4366
|
__export(palbe_mls_bg_exports, {
|
|
@@ -5329,6 +6068,8 @@ var MessagingCoordinator = class {
|
|
|
5329
6068
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5330
6069
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
5331
6070
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6071
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6072
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
5332
6073
|
this.registry.attachChatList(
|
|
5333
6074
|
(chats) => {
|
|
5334
6075
|
this.chatList = chats;
|
|
@@ -5344,6 +6085,8 @@ var MessagingCoordinator = class {
|
|
|
5344
6085
|
groupStore;
|
|
5345
6086
|
kpStore;
|
|
5346
6087
|
suppressionStore;
|
|
6088
|
+
elevationStore;
|
|
6089
|
+
disappearingStore;
|
|
5347
6090
|
registry = new GroupRegistry();
|
|
5348
6091
|
resolved = null;
|
|
5349
6092
|
resolvePromise = null;
|
|
@@ -5499,9 +6242,9 @@ var MessagingCoordinator = class {
|
|
|
5499
6242
|
});
|
|
5500
6243
|
return group;
|
|
5501
6244
|
}
|
|
5502
|
-
async sendText(group, text, replyTo) {
|
|
6245
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
5503
6246
|
const r = await this.resolve();
|
|
5504
|
-
return r.groups.sendText(group, text, replyTo);
|
|
6247
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
5505
6248
|
}
|
|
5506
6249
|
async sendReaction(group, args) {
|
|
5507
6250
|
const r = await this.resolve();
|
|
@@ -5515,6 +6258,10 @@ var MessagingCoordinator = class {
|
|
|
5515
6258
|
const r = await this.resolve();
|
|
5516
6259
|
return r.groups.sendDelete(group, args);
|
|
5517
6260
|
}
|
|
6261
|
+
async sendTimerSet(group, args) {
|
|
6262
|
+
const r = await this.resolve();
|
|
6263
|
+
return r.groups.sendTimerSet(group, args);
|
|
6264
|
+
}
|
|
5518
6265
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
5519
6266
|
loadSuppressed(group) {
|
|
5520
6267
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -5523,10 +6270,36 @@ var MessagingCoordinator = class {
|
|
|
5523
6270
|
saveSuppressed(group, keys) {
|
|
5524
6271
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
5525
6272
|
}
|
|
6273
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
6274
|
+
loadElevated(group) {
|
|
6275
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
6276
|
+
}
|
|
6277
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
6278
|
+
saveElevated(group, keys) {
|
|
6279
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6280
|
+
}
|
|
6281
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
6282
|
+
tombstonedSeqs(group) {
|
|
6283
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
6284
|
+
}
|
|
6285
|
+
purgedClientMsgIds(group) {
|
|
6286
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
6287
|
+
}
|
|
6288
|
+
anchor(group, clientMsgId) {
|
|
6289
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
6290
|
+
}
|
|
6291
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
6292
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
6293
|
+
}
|
|
6294
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
6295
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
6296
|
+
}
|
|
5526
6297
|
async history(group, limit, before) {
|
|
5527
6298
|
const r = await this.resolve();
|
|
5528
6299
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
5529
|
-
|
|
6300
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
6301
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
6302
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
5530
6303
|
}
|
|
5531
6304
|
async members(group) {
|
|
5532
6305
|
const r = await this.resolve();
|
|
@@ -5603,9 +6376,10 @@ var MessagingCoordinator = class {
|
|
|
5603
6376
|
return res.devices.map((d) => d.device_id);
|
|
5604
6377
|
}
|
|
5605
6378
|
};
|
|
5606
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
6379
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
6380
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
5607
6381
|
const fold = new ReactionFold();
|
|
5608
|
-
for (const s of
|
|
6382
|
+
for (const s of visible) {
|
|
5609
6383
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
5610
6384
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5611
6385
|
if (actor === null) continue;
|
|
@@ -5621,17 +6395,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5621
6395
|
}
|
|
5622
6396
|
const editFold = new EditFold();
|
|
5623
6397
|
const deleteFold = new DeleteFold();
|
|
6398
|
+
const pageTimerFold = new TimerFold();
|
|
5624
6399
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
5625
|
-
for (const s of
|
|
5626
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6400
|
+
for (const s of visible) {
|
|
6401
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
5627
6402
|
continue;
|
|
5628
6403
|
const cid = s.clientMsgId ?? "";
|
|
5629
6404
|
if (!cid) continue;
|
|
5630
6405
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
5631
6406
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
5632
6407
|
}
|
|
5633
|
-
const authorOfTarget = (cid) =>
|
|
5634
|
-
|
|
6408
|
+
const authorOfTarget = (cid) => {
|
|
6409
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
6410
|
+
const a = authorByClientMsgId.get(cid);
|
|
6411
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
6412
|
+
};
|
|
6413
|
+
for (const s of visible) {
|
|
5635
6414
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
5636
6415
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5637
6416
|
editFold.ingest(
|
|
@@ -5641,13 +6420,16 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5641
6420
|
newText: s.edit.newText,
|
|
5642
6421
|
epoch: s.epoch,
|
|
5643
6422
|
serverSeq: s.serverSeq,
|
|
5644
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
6423
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
6424
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
6425
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
6426
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
5645
6427
|
},
|
|
5646
6428
|
authorOfTarget
|
|
5647
6429
|
);
|
|
5648
6430
|
}
|
|
5649
6431
|
editFold.reevaluateHeld(authorOfTarget);
|
|
5650
|
-
for (const s of
|
|
6432
|
+
for (const s of visible) {
|
|
5651
6433
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
5652
6434
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5653
6435
|
deleteFold.ingest(
|
|
@@ -5661,10 +6443,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5661
6443
|
authorOfTarget
|
|
5662
6444
|
);
|
|
5663
6445
|
}
|
|
5664
|
-
for (const [cid
|
|
6446
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
5665
6447
|
const lookup = /* @__PURE__ */ new Map();
|
|
5666
|
-
for (const s of
|
|
5667
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6448
|
+
for (const s of visible) {
|
|
6449
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
5668
6450
|
continue;
|
|
5669
6451
|
const cid = s.clientMsgId ?? "";
|
|
5670
6452
|
if (cid && s.text !== null) {
|
|
@@ -5673,7 +6455,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5673
6455
|
}
|
|
5674
6456
|
}
|
|
5675
6457
|
const out = [];
|
|
5676
|
-
for (const s of
|
|
6458
|
+
for (const s of visible) {
|
|
6459
|
+
if (s.envelopeType === "timer_set") {
|
|
6460
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6461
|
+
if (actor !== null && s.timer) {
|
|
6462
|
+
pageTimerFold.ingest({
|
|
6463
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
6464
|
+
start: s.timer.start,
|
|
6465
|
+
actorUserId: actor,
|
|
6466
|
+
epoch: s.epoch,
|
|
6467
|
+
serverSeq: s.serverSeq,
|
|
6468
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6469
|
+
});
|
|
6470
|
+
}
|
|
6471
|
+
continue;
|
|
6472
|
+
}
|
|
5677
6473
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5678
6474
|
continue;
|
|
5679
6475
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -5691,10 +6487,23 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5691
6487
|
replyTo: null,
|
|
5692
6488
|
reactions: {},
|
|
5693
6489
|
edited: false,
|
|
5694
|
-
isDeleted: true
|
|
6490
|
+
isDeleted: true,
|
|
6491
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6492
|
+
mentions: [],
|
|
6493
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
6494
|
+
expiresAt: null
|
|
5695
6495
|
});
|
|
5696
6496
|
continue;
|
|
5697
6497
|
}
|
|
6498
|
+
let expiresAt = null;
|
|
6499
|
+
if (s.expiry) {
|
|
6500
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
6501
|
+
} else {
|
|
6502
|
+
const active = pageTimerFold.active();
|
|
6503
|
+
if (active && active.ttlSeconds !== null) {
|
|
6504
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
6505
|
+
}
|
|
6506
|
+
}
|
|
5698
6507
|
let replyTo = null;
|
|
5699
6508
|
if (s.replyTo) {
|
|
5700
6509
|
const ref = {
|
|
@@ -5711,23 +6520,37 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5711
6520
|
}
|
|
5712
6521
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
5713
6522
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
6523
|
+
const text = editText ?? s.text;
|
|
6524
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
6525
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
5714
6526
|
out.push({
|
|
5715
6527
|
id: `${displayId}#${s.serverSeq}`,
|
|
5716
6528
|
kind: s.text != null ? "text" : "system",
|
|
5717
6529
|
direction: s.direction,
|
|
5718
6530
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
5719
|
-
text
|
|
6531
|
+
text,
|
|
5720
6532
|
serverSeq: s.serverSeq,
|
|
5721
6533
|
sentAt: new Date(s.at),
|
|
5722
6534
|
clientMsgId,
|
|
5723
6535
|
replyTo,
|
|
5724
6536
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
5725
6537
|
edited,
|
|
5726
|
-
isDeleted: false
|
|
6538
|
+
isDeleted: false,
|
|
6539
|
+
mentions,
|
|
6540
|
+
expiresAt
|
|
5727
6541
|
});
|
|
5728
6542
|
}
|
|
5729
6543
|
return out;
|
|
5730
6544
|
}
|
|
6545
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
6546
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
6547
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
6548
|
+
start: r.start,
|
|
6549
|
+
length: r.length,
|
|
6550
|
+
mentionedUserId: r.mentionedUserId,
|
|
6551
|
+
displayName: null
|
|
6552
|
+
}));
|
|
6553
|
+
}
|
|
5731
6554
|
|
|
5732
6555
|
// src/messaging/facade.ts
|
|
5733
6556
|
var PalbeMessaging = class {
|
|
@@ -6580,7 +7403,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
|
|
|
6580
7403
|
}
|
|
6581
7404
|
|
|
6582
7405
|
// src/version.ts
|
|
6583
|
-
var VERSION = "1.
|
|
7406
|
+
var VERSION = "1.6.0";
|
|
6584
7407
|
|
|
6585
7408
|
// src/internal.ts
|
|
6586
7409
|
function getRuntime() {
|