@palbase/web 1.3.0 → 1.5.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-Cq7R-VH_.d.ts → analytics-facade-BQlTfsEm.d.ts} +168 -4
- package/dist/{analytics-facade-B8UATgS4.d.cts → analytics-facade-DvsLE_qz.d.cts} +168 -4
- package/dist/{chunk-UX43AB4W.js → chunk-MBA2NAKS.js} +694 -44
- package/dist/chunk-MBA2NAKS.js.map +1 -0
- package/dist/index.cjs +693 -43
- 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 +693 -43
- 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 +693 -43
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +693 -43
- 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-DSJiH1uV.d.ts → pb-Cl7TeyCR.d.ts} +1 -1
- package/dist/{pb-DivIiUGR.d.cts → pb-a86jrG9h.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 +3 -3
- package/dist/chunk-UX43AB4W.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1607,6 +1607,69 @@ var PalbeFlags = class {
|
|
|
1607
1607
|
}
|
|
1608
1608
|
};
|
|
1609
1609
|
|
|
1610
|
+
// src/messaging/delete-fold.ts
|
|
1611
|
+
var DeleteFold = class {
|
|
1612
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
1613
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
1614
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
1615
|
+
pending = /* @__PURE__ */ new Map();
|
|
1616
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
1617
|
+
seen = /* @__PURE__ */ new Set();
|
|
1618
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
1619
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
1620
|
+
held = [];
|
|
1621
|
+
/**
|
|
1622
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
1623
|
+
* userId (null = target absent locally → defer).
|
|
1624
|
+
*/
|
|
1625
|
+
ingest(e, authorOfTarget) {
|
|
1626
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
1627
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
1628
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
1629
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
1630
|
+
if (author !== null) {
|
|
1631
|
+
this.seen.add(e.eventClientMsgId);
|
|
1632
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
1633
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
1634
|
+
} else if (e.actorUserId !== null) {
|
|
1635
|
+
this.seen.add(e.eventClientMsgId);
|
|
1636
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
1637
|
+
} else {
|
|
1638
|
+
this.held.push(e);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
1642
|
+
isTombstoned(targetClientMsgId) {
|
|
1643
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
1647
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
1648
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
1649
|
+
* the in-order path.
|
|
1650
|
+
*/
|
|
1651
|
+
reevaluatePending(target, author) {
|
|
1652
|
+
const actor = this.pending.get(target);
|
|
1653
|
+
if (actor !== void 0) {
|
|
1654
|
+
if (author !== null && actor === author) {
|
|
1655
|
+
this.tombstoned.add(target);
|
|
1656
|
+
this.pending.delete(target);
|
|
1657
|
+
} else if (author !== null) {
|
|
1658
|
+
this.pending.delete(target);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
if (this.held.length === 0) return;
|
|
1662
|
+
const pendingHeld = this.held;
|
|
1663
|
+
this.held = [];
|
|
1664
|
+
for (const e of pendingHeld) {
|
|
1665
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
heldContains(eventClientMsgId) {
|
|
1669
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1610
1673
|
// src/messaging/edit-fold.ts
|
|
1611
1674
|
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
1612
1675
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -1652,7 +1715,8 @@ var EditFold = class {
|
|
|
1652
1715
|
orderEpoch: e.epoch,
|
|
1653
1716
|
orderSeq: e.serverSeq,
|
|
1654
1717
|
lastEventId: e.eventClientMsgId,
|
|
1655
|
-
text: e.newText
|
|
1718
|
+
text: e.newText,
|
|
1719
|
+
bodyRanges: e.bodyRanges ?? null
|
|
1656
1720
|
});
|
|
1657
1721
|
this.editedTargets.add(e.targetClientMsgId);
|
|
1658
1722
|
}
|
|
@@ -1673,6 +1737,15 @@ var EditFold = class {
|
|
|
1673
1737
|
isEdited(targetClientMsgId) {
|
|
1674
1738
|
return this.editedTargets.has(targetClientMsgId);
|
|
1675
1739
|
}
|
|
1740
|
+
/**
|
|
1741
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
1742
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
1743
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
1744
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
1745
|
+
*/
|
|
1746
|
+
bodyRanges(targetClientMsgId) {
|
|
1747
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
1748
|
+
}
|
|
1676
1749
|
/**
|
|
1677
1750
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
1678
1751
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -1828,6 +1901,17 @@ async function listDevices(rt, userId) {
|
|
|
1828
1901
|
}
|
|
1829
1902
|
|
|
1830
1903
|
// src/messaging/group-messaging.ts
|
|
1904
|
+
function encodeDelete(args) {
|
|
1905
|
+
return encodeUtf8(
|
|
1906
|
+
JSON.stringify({
|
|
1907
|
+
v: 1,
|
|
1908
|
+
type: "delete",
|
|
1909
|
+
client_msg_id: args.clientMsgId,
|
|
1910
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
1911
|
+
scope: "everyone"
|
|
1912
|
+
})
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1831
1915
|
function encodeEdit(args) {
|
|
1832
1916
|
return encodeUtf8(
|
|
1833
1917
|
JSON.stringify({
|
|
@@ -1835,7 +1919,14 @@ function encodeEdit(args) {
|
|
|
1835
1919
|
type: "edit",
|
|
1836
1920
|
client_msg_id: args.clientMsgId,
|
|
1837
1921
|
target_client_msg_id: args.targetClientMsgId,
|
|
1838
|
-
new_text: args.newText
|
|
1922
|
+
new_text: args.newText,
|
|
1923
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
1924
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
1925
|
+
start: r.start,
|
|
1926
|
+
length: r.length,
|
|
1927
|
+
mentioned_user_id: r.mentionedUserId
|
|
1928
|
+
}))
|
|
1929
|
+
} : {}
|
|
1839
1930
|
})
|
|
1840
1931
|
);
|
|
1841
1932
|
}
|
|
@@ -1857,7 +1948,14 @@ function encodeEnvelope(args) {
|
|
|
1857
1948
|
type: "text",
|
|
1858
1949
|
client_msg_id: args.clientMsgId,
|
|
1859
1950
|
text: args.text,
|
|
1860
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
1951
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
1952
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
1953
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
1954
|
+
start: r.start,
|
|
1955
|
+
length: r.length,
|
|
1956
|
+
mentioned_user_id: r.mentionedUserId
|
|
1957
|
+
}))
|
|
1958
|
+
} : {}
|
|
1861
1959
|
};
|
|
1862
1960
|
return encodeUtf8(JSON.stringify(env));
|
|
1863
1961
|
}
|
|
@@ -1865,6 +1963,18 @@ function decodeEnvelope(bytes) {
|
|
|
1865
1963
|
const s = decodeUtf8(bytes);
|
|
1866
1964
|
try {
|
|
1867
1965
|
const o = JSON.parse(s);
|
|
1966
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
1967
|
+
return {
|
|
1968
|
+
type: "delete",
|
|
1969
|
+
text: null,
|
|
1970
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
1971
|
+
replyTo: null,
|
|
1972
|
+
delete: {
|
|
1973
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
1974
|
+
scope: o.scope ?? "everyone"
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1868
1978
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
1869
1979
|
return {
|
|
1870
1980
|
type: "reaction",
|
|
@@ -1879,6 +1989,7 @@ function decodeEnvelope(bytes) {
|
|
|
1879
1989
|
};
|
|
1880
1990
|
}
|
|
1881
1991
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
1992
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
1882
1993
|
return {
|
|
1883
1994
|
type: "edit",
|
|
1884
1995
|
text: null,
|
|
@@ -1887,15 +1998,18 @@ function decodeEnvelope(bytes) {
|
|
|
1887
1998
|
edit: {
|
|
1888
1999
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
1889
2000
|
newText: o.new_text ?? ""
|
|
1890
|
-
}
|
|
2001
|
+
},
|
|
2002
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
1891
2003
|
};
|
|
1892
2004
|
}
|
|
1893
2005
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2006
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
1894
2007
|
return {
|
|
1895
2008
|
type: "text",
|
|
1896
2009
|
text: o.text ?? null,
|
|
1897
2010
|
clientMsgId: o.client_msg_id ?? "",
|
|
1898
|
-
replyTo: o.reply_to ?? null
|
|
2011
|
+
replyTo: o.reply_to ?? null,
|
|
2012
|
+
...textRanges ? { bodyRanges: textRanges } : {}
|
|
1899
2013
|
};
|
|
1900
2014
|
}
|
|
1901
2015
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -1907,6 +2021,14 @@ function decodeEnvelope(bytes) {
|
|
|
1907
2021
|
}
|
|
1908
2022
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
1909
2023
|
}
|
|
2024
|
+
function decodeBodyRanges(raw) {
|
|
2025
|
+
if (!raw || raw.length === 0) return void 0;
|
|
2026
|
+
return raw.map((r) => ({
|
|
2027
|
+
start: r.start,
|
|
2028
|
+
length: r.length,
|
|
2029
|
+
mentionedUserId: r.mentioned_user_id
|
|
2030
|
+
}));
|
|
2031
|
+
}
|
|
1910
2032
|
function resolveReply(ref, lookup) {
|
|
1911
2033
|
const parent = lookup(ref.client_msg_id);
|
|
1912
2034
|
if (parent !== null) {
|
|
@@ -2118,9 +2240,9 @@ var GroupMessaging = class {
|
|
|
2118
2240
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
2119
2241
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
2120
2242
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
2121
|
-
async sendText(group, text, replyTo) {
|
|
2243
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
2122
2244
|
const clientMsgId = mintClientMsgId();
|
|
2123
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
2245
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
2124
2246
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2125
2247
|
const body = {
|
|
2126
2248
|
ciphertext_b64: toBase64(ct),
|
|
@@ -2146,7 +2268,10 @@ var GroupMessaging = class {
|
|
|
2146
2268
|
previewBody: replyTo.preview?.body ?? null,
|
|
2147
2269
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
2148
2270
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
2149
|
-
} : null
|
|
2271
|
+
} : null,
|
|
2272
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
2273
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
2274
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
2150
2275
|
};
|
|
2151
2276
|
try {
|
|
2152
2277
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -2214,7 +2339,8 @@ var GroupMessaging = class {
|
|
|
2214
2339
|
const plaintext = encodeEdit({
|
|
2215
2340
|
clientMsgId: args.clientMsgId,
|
|
2216
2341
|
targetClientMsgId: args.targetClientMsgId,
|
|
2217
|
-
newText: args.newText
|
|
2342
|
+
newText: args.newText,
|
|
2343
|
+
bodyRanges: args.bodyRanges
|
|
2218
2344
|
});
|
|
2219
2345
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2220
2346
|
const body = {
|
|
@@ -2240,7 +2366,59 @@ var GroupMessaging = class {
|
|
|
2240
2366
|
envelopeType: "edit",
|
|
2241
2367
|
edit: {
|
|
2242
2368
|
targetClientMsgId: args.targetClientMsgId,
|
|
2243
|
-
newText: args.newText
|
|
2369
|
+
newText: args.newText,
|
|
2370
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
2371
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
2372
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
try {
|
|
2376
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
2377
|
+
} catch {
|
|
2378
|
+
}
|
|
2379
|
+
return {
|
|
2380
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
2381
|
+
clientMsgId: args.clientMsgId
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
2385
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
2386
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
2387
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
2388
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
2389
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
2390
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
2391
|
+
* rebases (epoch-bound like any application message). */
|
|
2392
|
+
async sendDelete(group, args) {
|
|
2393
|
+
const plaintext = encodeDelete({
|
|
2394
|
+
clientMsgId: args.clientMsgId,
|
|
2395
|
+
targetClientMsgId: args.targetClientMsgId
|
|
2396
|
+
});
|
|
2397
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2398
|
+
const body = {
|
|
2399
|
+
ciphertext_b64: toBase64(ct),
|
|
2400
|
+
client_idem_key: randomId()
|
|
2401
|
+
};
|
|
2402
|
+
const wire = await palbeRequest(
|
|
2403
|
+
this.rt,
|
|
2404
|
+
"POST",
|
|
2405
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
2406
|
+
{ body }
|
|
2407
|
+
);
|
|
2408
|
+
const stored = {
|
|
2409
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
2410
|
+
direction: "outgoing",
|
|
2411
|
+
text: null,
|
|
2412
|
+
senderDeviceId: this.selfDeviceId,
|
|
2413
|
+
epoch: wire.epoch,
|
|
2414
|
+
serverSeq: wire.server_seq,
|
|
2415
|
+
at: Date.now(),
|
|
2416
|
+
clientMsgId: args.clientMsgId,
|
|
2417
|
+
replyTo: null,
|
|
2418
|
+
envelopeType: "delete",
|
|
2419
|
+
delete: {
|
|
2420
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
2421
|
+
scope: "everyone"
|
|
2244
2422
|
}
|
|
2245
2423
|
};
|
|
2246
2424
|
try {
|
|
@@ -2313,6 +2491,41 @@ var GroupMessaging = class {
|
|
|
2313
2491
|
}
|
|
2314
2492
|
};
|
|
2315
2493
|
|
|
2494
|
+
// src/messaging/mention-ranges.ts
|
|
2495
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
2496
|
+
const n = text.length;
|
|
2497
|
+
function splitsSurrogatePair(index) {
|
|
2498
|
+
if (index <= 0 || index >= n) return false;
|
|
2499
|
+
const before = text.charCodeAt(index - 1);
|
|
2500
|
+
const at = text.charCodeAt(index);
|
|
2501
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
2502
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
2503
|
+
return beforeIsHigh && atIsLow;
|
|
2504
|
+
}
|
|
2505
|
+
const survivors = [];
|
|
2506
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
2507
|
+
const r = ranges[idx];
|
|
2508
|
+
if (r === void 0) continue;
|
|
2509
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
2510
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
2511
|
+
survivors.push({ idx, range: r });
|
|
2512
|
+
}
|
|
2513
|
+
survivors.sort((lhs, rhs) => {
|
|
2514
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
2515
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
2516
|
+
return lhs.idx - rhs.idx;
|
|
2517
|
+
});
|
|
2518
|
+
const kept = [];
|
|
2519
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
2520
|
+
for (const s of survivors) {
|
|
2521
|
+
if (s.range.start >= prevEnd) {
|
|
2522
|
+
kept.push(s.range);
|
|
2523
|
+
prevEnd = s.range.start + s.range.length;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
return kept;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2316
2529
|
// src/messaging/reaction-fold.ts
|
|
2317
2530
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2318
2531
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -2369,6 +2582,7 @@ var ReactionFold = class {
|
|
|
2369
2582
|
};
|
|
2370
2583
|
|
|
2371
2584
|
// src/messaging/chat.ts
|
|
2585
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
2372
2586
|
var Chat = class {
|
|
2373
2587
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
2374
2588
|
id;
|
|
@@ -2390,6 +2604,22 @@ var Chat = class {
|
|
|
2390
2604
|
reactionFold = new ReactionFold();
|
|
2391
2605
|
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
2392
2606
|
editFold = new EditFold();
|
|
2607
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
2608
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
2609
|
+
deleteFold = new DeleteFold();
|
|
2610
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
2611
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
2612
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
2613
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
2614
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
2615
|
+
suppressedLoaded = false;
|
|
2616
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
2617
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
2618
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
2619
|
+
elevated = /* @__PURE__ */ new Set();
|
|
2620
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
2621
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
2622
|
+
elevatedLoaded = false;
|
|
2393
2623
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
2394
2624
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
2395
2625
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -2401,6 +2631,14 @@ var Chat = class {
|
|
|
2401
2631
|
wired = false;
|
|
2402
2632
|
liveUnsub = null;
|
|
2403
2633
|
listeners = /* @__PURE__ */ new Set();
|
|
2634
|
+
/**
|
|
2635
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
2636
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
2637
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
2638
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
2639
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
2640
|
+
*/
|
|
2641
|
+
onMentionElevation;
|
|
2404
2642
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
2405
2643
|
constructor(args) {
|
|
2406
2644
|
this.backend = args.backend;
|
|
@@ -2438,7 +2676,7 @@ var Chat = class {
|
|
|
2438
2676
|
return this.kind === "direct";
|
|
2439
2677
|
}
|
|
2440
2678
|
get messages() {
|
|
2441
|
-
return this.
|
|
2679
|
+
return this.surfaced();
|
|
2442
2680
|
}
|
|
2443
2681
|
get members() {
|
|
2444
2682
|
return this.memberCache;
|
|
@@ -2447,13 +2685,50 @@ var Chat = class {
|
|
|
2447
2685
|
return this.typingList;
|
|
2448
2686
|
}
|
|
2449
2687
|
get lastMessage() {
|
|
2450
|
-
return this.
|
|
2688
|
+
return this.surfaced().at(-1) ?? null;
|
|
2451
2689
|
}
|
|
2452
2690
|
get unreadCount() {
|
|
2453
|
-
return this.
|
|
2454
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
2691
|
+
return this.surfaced().filter(
|
|
2692
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
2455
2693
|
).length;
|
|
2456
2694
|
}
|
|
2695
|
+
/**
|
|
2696
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
2697
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
2698
|
+
* folded edit text + reactions + reply):
|
|
2699
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
2700
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
2701
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
2702
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
2703
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
2704
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
2705
|
+
*/
|
|
2706
|
+
surfaced() {
|
|
2707
|
+
const out = [];
|
|
2708
|
+
for (const m of this.messageList) {
|
|
2709
|
+
const key = this.suppressionKey(m);
|
|
2710
|
+
if (this.suppressed.has(key)) continue;
|
|
2711
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
2712
|
+
if (tombstoned) {
|
|
2713
|
+
out.push({
|
|
2714
|
+
...m,
|
|
2715
|
+
text: DELETED_DESCRIPTOR,
|
|
2716
|
+
reactions: {},
|
|
2717
|
+
replyTo: null,
|
|
2718
|
+
edited: false,
|
|
2719
|
+
isDeleted: true,
|
|
2720
|
+
mentions: []
|
|
2721
|
+
});
|
|
2722
|
+
continue;
|
|
2723
|
+
}
|
|
2724
|
+
out.push(m);
|
|
2725
|
+
}
|
|
2726
|
+
return out;
|
|
2727
|
+
}
|
|
2728
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
2729
|
+
suppressionKey(m) {
|
|
2730
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
2731
|
+
}
|
|
2457
2732
|
get title() {
|
|
2458
2733
|
if (this.titleOverride) return this.titleOverride;
|
|
2459
2734
|
if (this._group?.name) return this._group.name;
|
|
@@ -2477,9 +2752,40 @@ var Chat = class {
|
|
|
2477
2752
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
2478
2753
|
this.wired = true;
|
|
2479
2754
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
2755
|
+
void this.loadSuppressed();
|
|
2756
|
+
void this.loadElevated();
|
|
2480
2757
|
void this.hydrateHistory();
|
|
2481
2758
|
void this.refreshMembers();
|
|
2482
2759
|
}
|
|
2760
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
2761
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
2762
|
+
async loadSuppressed() {
|
|
2763
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
2764
|
+
this.suppressedLoaded = true;
|
|
2765
|
+
try {
|
|
2766
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
2767
|
+
let changed = false;
|
|
2768
|
+
for (const k of keys) {
|
|
2769
|
+
if (!this.suppressed.has(k)) {
|
|
2770
|
+
this.suppressed.add(k);
|
|
2771
|
+
changed = true;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
if (changed) this.emit();
|
|
2775
|
+
} catch {
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
2779
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
2780
|
+
async loadElevated() {
|
|
2781
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
2782
|
+
this.elevatedLoaded = true;
|
|
2783
|
+
try {
|
|
2784
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
2785
|
+
for (const k of keys) this.elevated.add(k);
|
|
2786
|
+
} catch {
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2483
2789
|
async hydrateHistory() {
|
|
2484
2790
|
if (this.historyLoaded || !this._group) return;
|
|
2485
2791
|
this.historyLoaded = true;
|
|
@@ -2490,13 +2796,13 @@ var Chat = class {
|
|
|
2490
2796
|
let changed = false;
|
|
2491
2797
|
for (const m of incoming) {
|
|
2492
2798
|
if (m.serverSeq <= 0) continue;
|
|
2493
|
-
if (m.clientMsgId && m.text !== null) {
|
|
2799
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
2494
2800
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
2495
2801
|
text: m.text,
|
|
2496
2802
|
senderUserId: m.senderUserId ?? ""
|
|
2497
2803
|
});
|
|
2498
2804
|
}
|
|
2499
|
-
if (m.clientMsgId) {
|
|
2805
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
2500
2806
|
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
2501
2807
|
}
|
|
2502
2808
|
}
|
|
@@ -2506,7 +2812,12 @@ var Chat = class {
|
|
|
2506
2812
|
const key = this.internalKey(m.serverSeq);
|
|
2507
2813
|
if (this.seenKeys.has(key)) continue;
|
|
2508
2814
|
this.seenKeys.add(key);
|
|
2509
|
-
|
|
2815
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
2816
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
2817
|
+
}
|
|
2818
|
+
this.messageList.push(
|
|
2819
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
2820
|
+
);
|
|
2510
2821
|
changed = true;
|
|
2511
2822
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
2512
2823
|
}
|
|
@@ -2556,19 +2867,38 @@ var Chat = class {
|
|
|
2556
2867
|
newText: incoming.edit.newText,
|
|
2557
2868
|
epoch: incoming.epoch,
|
|
2558
2869
|
serverSeq: incoming.serverSeq,
|
|
2559
|
-
eventClientMsgId: incoming.clientMsgId
|
|
2870
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
2871
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
2872
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
2873
|
+
bodyRanges: incoming.bodyRanges
|
|
2560
2874
|
},
|
|
2561
2875
|
this.authorOfTarget
|
|
2562
2876
|
);
|
|
2563
2877
|
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
2564
2878
|
return;
|
|
2565
2879
|
}
|
|
2880
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
2881
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
2882
|
+
this.deleteFold.ingest(
|
|
2883
|
+
{
|
|
2884
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
2885
|
+
actorUserId,
|
|
2886
|
+
epoch: incoming.epoch,
|
|
2887
|
+
serverSeq: incoming.serverSeq,
|
|
2888
|
+
eventClientMsgId: incoming.clientMsgId
|
|
2889
|
+
},
|
|
2890
|
+
this.authorOfTarget
|
|
2891
|
+
);
|
|
2892
|
+
this.emit();
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2566
2895
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
2567
2896
|
const incomingReplyRef = incoming.replyRef;
|
|
2568
2897
|
let resolvedReplyTo = null;
|
|
2569
2898
|
if (incomingReplyRef) {
|
|
2570
2899
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
2571
2900
|
}
|
|
2901
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
2572
2902
|
const msg = {
|
|
2573
2903
|
id: this.publicId(incoming.serverSeq),
|
|
2574
2904
|
kind: this.kindOf(incoming),
|
|
@@ -2583,8 +2913,12 @@ var Chat = class {
|
|
|
2583
2913
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
2584
2914
|
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
2585
2915
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
2586
|
-
edited: false
|
|
2916
|
+
edited: false,
|
|
2917
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
2918
|
+
isDeleted: false,
|
|
2919
|
+
mentions
|
|
2587
2920
|
};
|
|
2921
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
2588
2922
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
2589
2923
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
2590
2924
|
text: incoming.text,
|
|
@@ -2594,6 +2928,7 @@ var Chat = class {
|
|
|
2594
2928
|
if (incomingClientMsgId) {
|
|
2595
2929
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
2596
2930
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2931
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
2597
2932
|
}
|
|
2598
2933
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
2599
2934
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -2607,6 +2942,75 @@ var Chat = class {
|
|
|
2607
2942
|
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
2608
2943
|
* so it can be passed to the pure EditFold. */
|
|
2609
2944
|
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
2945
|
+
// ── Mentions (mentions T6) ──
|
|
2946
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
2947
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
2948
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
2949
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
2950
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
2951
|
+
resolveMentions(text, bodyRanges) {
|
|
2952
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
2953
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
2954
|
+
if (normalized.length === 0) return [];
|
|
2955
|
+
return normalized.map((r) => ({
|
|
2956
|
+
start: r.start,
|
|
2957
|
+
length: r.length,
|
|
2958
|
+
mentionedUserId: r.mentionedUserId,
|
|
2959
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
2960
|
+
}));
|
|
2961
|
+
}
|
|
2962
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
2963
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
2964
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
2965
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
2966
|
+
resolveMentionNames(m) {
|
|
2967
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
2968
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
2969
|
+
}
|
|
2970
|
+
let changed = false;
|
|
2971
|
+
const reresolved = m.mentions.map((span) => {
|
|
2972
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
2973
|
+
if (name === span.displayName) return span;
|
|
2974
|
+
changed = true;
|
|
2975
|
+
return { ...span, displayName: name };
|
|
2976
|
+
});
|
|
2977
|
+
if (!changed) return m;
|
|
2978
|
+
return { ...m, mentions: reresolved };
|
|
2979
|
+
}
|
|
2980
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
2981
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
2982
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
2983
|
+
editMentions(targetClientMsgId, newText) {
|
|
2984
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
2985
|
+
if (!ranges) return [];
|
|
2986
|
+
return this.resolveMentions(newText, ranges);
|
|
2987
|
+
}
|
|
2988
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
2989
|
+
displayNameOf(userId) {
|
|
2990
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
2991
|
+
}
|
|
2992
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
2993
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
2994
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
2995
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
2996
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
2997
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
2998
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
2999
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
3000
|
+
const me = this.backend.selfUserId;
|
|
3001
|
+
if (envelopeType === "edit") return;
|
|
3002
|
+
if (senderUserId === me) return;
|
|
3003
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
3004
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
3005
|
+
const key = `${me}|${idPart}`;
|
|
3006
|
+
if (this.elevated.has(key)) return;
|
|
3007
|
+
this.elevated.add(key);
|
|
3008
|
+
if (this._group) {
|
|
3009
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
this.onMentionElevation?.(message);
|
|
3013
|
+
}
|
|
2610
3014
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
2611
3015
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
2612
3016
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -2663,9 +3067,10 @@ var Chat = class {
|
|
|
2663
3067
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
2664
3068
|
const text = editText ?? base;
|
|
2665
3069
|
const edited = foldEdited || m.edited;
|
|
2666
|
-
|
|
3070
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
3071
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
2667
3072
|
changed = true;
|
|
2668
|
-
return { ...m, text, edited };
|
|
3073
|
+
return { ...m, text, edited, mentions };
|
|
2669
3074
|
});
|
|
2670
3075
|
if (changed) this.emit();
|
|
2671
3076
|
}
|
|
@@ -2682,8 +3087,9 @@ var Chat = class {
|
|
|
2682
3087
|
if (editText === null && !foldEdited) return m;
|
|
2683
3088
|
const text = editText ?? m.text;
|
|
2684
3089
|
const edited = foldEdited || m.edited;
|
|
2685
|
-
|
|
2686
|
-
|
|
3090
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
3091
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3092
|
+
return { ...m, text, edited, mentions };
|
|
2687
3093
|
}
|
|
2688
3094
|
/** @internal — called by the backend's conv subscription. */
|
|
2689
3095
|
applyConv(event, payload) {
|
|
@@ -2741,6 +3147,18 @@ var Chat = class {
|
|
|
2741
3147
|
}
|
|
2742
3148
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2743
3149
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3150
|
+
this.reresolveAllMentionNames();
|
|
3151
|
+
}
|
|
3152
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
3153
|
+
* change). Re-emits only if any name actually changed. */
|
|
3154
|
+
reresolveAllMentionNames() {
|
|
3155
|
+
let changed = false;
|
|
3156
|
+
this.messageList = this.messageList.map((m) => {
|
|
3157
|
+
const reresolved = this.resolveMentionNames(m);
|
|
3158
|
+
if (reresolved !== m) changed = true;
|
|
3159
|
+
return reresolved;
|
|
3160
|
+
});
|
|
3161
|
+
if (changed) this.emit();
|
|
2744
3162
|
}
|
|
2745
3163
|
seedMembersFromGroup(group) {
|
|
2746
3164
|
const seed = [
|
|
@@ -2808,11 +3226,12 @@ var Chat = class {
|
|
|
2808
3226
|
};
|
|
2809
3227
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
2810
3228
|
}
|
|
2811
|
-
const
|
|
2812
|
-
this.
|
|
3229
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3230
|
+
const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
|
|
3231
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
2813
3232
|
return receipt;
|
|
2814
3233
|
}
|
|
2815
|
-
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
|
|
3234
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
2816
3235
|
if (receipt.serverSeq <= 0) return;
|
|
2817
3236
|
const key = this.internalKey(receipt.serverSeq);
|
|
2818
3237
|
if (this.seenKeys.has(key)) return;
|
|
@@ -2835,7 +3254,12 @@ var Chat = class {
|
|
|
2835
3254
|
// the dangling-target invariant uniform across every append path).
|
|
2836
3255
|
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
2837
3256
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
2838
|
-
edited: false
|
|
3257
|
+
edited: false,
|
|
3258
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3259
|
+
isDeleted: false,
|
|
3260
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
3261
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
3262
|
+
mentions: this.resolveMentions(text, bodyRanges)
|
|
2839
3263
|
});
|
|
2840
3264
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
2841
3265
|
this.emit();
|
|
@@ -2923,15 +3347,18 @@ var Chat = class {
|
|
|
2923
3347
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
2924
3348
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
2925
3349
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
2926
|
-
* message self IS the author, so the author-gate passes.
|
|
2927
|
-
|
|
3350
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
3351
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
3352
|
+
async edit(message, newText, opts) {
|
|
2928
3353
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
2929
3354
|
const group = await this.materializeIfNeeded();
|
|
2930
3355
|
const clientMsgId = mintClientMsgId();
|
|
3356
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
2931
3357
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
2932
3358
|
clientMsgId,
|
|
2933
3359
|
targetClientMsgId: message.clientMsgId,
|
|
2934
|
-
newText
|
|
3360
|
+
newText,
|
|
3361
|
+
bodyRanges
|
|
2935
3362
|
});
|
|
2936
3363
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
2937
3364
|
this.editFold.ingest(
|
|
@@ -2941,13 +3368,72 @@ var Chat = class {
|
|
|
2941
3368
|
newText,
|
|
2942
3369
|
epoch: receipt.epoch,
|
|
2943
3370
|
serverSeq: receipt.serverSeq,
|
|
2944
|
-
eventClientMsgId: clientMsgId
|
|
3371
|
+
eventClientMsgId: clientMsgId,
|
|
3372
|
+
bodyRanges
|
|
2945
3373
|
},
|
|
2946
3374
|
this.authorOfTarget
|
|
2947
3375
|
);
|
|
2948
3376
|
this.recomputeEdit(message.clientMsgId);
|
|
2949
3377
|
}
|
|
3378
|
+
// ── Delete ──
|
|
3379
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
3380
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
3381
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
3382
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
3383
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
3384
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
3385
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
3386
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
3387
|
+
async deleteForEveryone(message) {
|
|
3388
|
+
if (!message.clientMsgId) return;
|
|
3389
|
+
const group = await this.materializeIfNeeded();
|
|
3390
|
+
const clientMsgId = mintClientMsgId();
|
|
3391
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
3392
|
+
clientMsgId,
|
|
3393
|
+
targetClientMsgId: message.clientMsgId
|
|
3394
|
+
});
|
|
3395
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3396
|
+
this.deleteFold.ingest(
|
|
3397
|
+
{
|
|
3398
|
+
targetClientMsgId: message.clientMsgId,
|
|
3399
|
+
actorUserId: this.backend.selfUserId,
|
|
3400
|
+
epoch: receipt.epoch,
|
|
3401
|
+
serverSeq: receipt.serverSeq,
|
|
3402
|
+
eventClientMsgId: clientMsgId
|
|
3403
|
+
},
|
|
3404
|
+
this.authorOfTarget
|
|
3405
|
+
);
|
|
3406
|
+
this.emit();
|
|
3407
|
+
}
|
|
3408
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
3409
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
3410
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
3411
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
3412
|
+
async deleteForMe(message) {
|
|
3413
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
3414
|
+
if (this.suppressed.has(key)) return;
|
|
3415
|
+
this.suppressed.add(key);
|
|
3416
|
+
this.emit();
|
|
3417
|
+
if (this._group) {
|
|
3418
|
+
try {
|
|
3419
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
3420
|
+
} catch {
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
2950
3424
|
};
|
|
3425
|
+
function sameMentions(a, b) {
|
|
3426
|
+
if (a.length !== b.length) return false;
|
|
3427
|
+
for (let i = 0; i < a.length; i++) {
|
|
3428
|
+
const x = a[i];
|
|
3429
|
+
const y = b[i];
|
|
3430
|
+
if (!x || !y) return false;
|
|
3431
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
3432
|
+
return false;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
return true;
|
|
3436
|
+
}
|
|
2951
3437
|
function sameReactions(a, b) {
|
|
2952
3438
|
const ak = Object.keys(a);
|
|
2953
3439
|
const bk = Object.keys(b);
|
|
@@ -3127,6 +3613,7 @@ var MessageDeliverySource = class {
|
|
|
3127
3613
|
const { text, clientMsgId, replyTo } = decoded;
|
|
3128
3614
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
3129
3615
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
3616
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
3130
3617
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3131
3618
|
const stored = {
|
|
3132
3619
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3158,12 +3645,31 @@ var MessageDeliverySource = class {
|
|
|
3158
3645
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
3159
3646
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
3160
3647
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
3161
|
-
// `'text'`/no-edit (backward-compat).
|
|
3648
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
3649
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
3162
3650
|
...isEdit && decoded.edit ? {
|
|
3163
3651
|
envelopeType: "edit",
|
|
3164
3652
|
edit: {
|
|
3165
3653
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
3166
|
-
newText: decoded.edit.newText
|
|
3654
|
+
newText: decoded.edit.newText,
|
|
3655
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
3656
|
+
}
|
|
3657
|
+
} : {},
|
|
3658
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
3659
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
3660
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
3661
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
3662
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
3663
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
3664
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
3665
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
3666
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
3667
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
3668
|
+
...isDelete && decoded.delete ? {
|
|
3669
|
+
envelopeType: "delete",
|
|
3670
|
+
delete: {
|
|
3671
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
3672
|
+
scope: decoded.delete.scope
|
|
3167
3673
|
}
|
|
3168
3674
|
} : {}
|
|
3169
3675
|
};
|
|
@@ -3184,7 +3690,11 @@ var MessageDeliverySource = class {
|
|
|
3184
3690
|
replyRef: replyTo,
|
|
3185
3691
|
envelopeType: decoded.type ?? "text",
|
|
3186
3692
|
reaction: isReaction ? decoded.reaction : null,
|
|
3187
|
-
edit: isEdit ? decoded.edit : null
|
|
3693
|
+
edit: isEdit ? decoded.edit : null,
|
|
3694
|
+
delete: isDelete ? decoded.delete : null,
|
|
3695
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
3696
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
3697
|
+
bodyRanges: decoded.bodyRanges ?? null
|
|
3188
3698
|
});
|
|
3189
3699
|
return true;
|
|
3190
3700
|
}
|
|
@@ -3339,6 +3849,36 @@ var GroupCatalog = class {
|
|
|
3339
3849
|
}
|
|
3340
3850
|
};
|
|
3341
3851
|
|
|
3852
|
+
// src/messaging/mention-elevation.ts
|
|
3853
|
+
var MentionElevationStore = class {
|
|
3854
|
+
constructor(kv) {
|
|
3855
|
+
this.kv = kv;
|
|
3856
|
+
}
|
|
3857
|
+
kv;
|
|
3858
|
+
key(rfcGroupId) {
|
|
3859
|
+
return `elev:${rfcGroupId}`;
|
|
3860
|
+
}
|
|
3861
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
3862
|
+
async load(rfcGroupId) {
|
|
3863
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
3864
|
+
if (!raw) return [];
|
|
3865
|
+
try {
|
|
3866
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
3867
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
3868
|
+
} catch {
|
|
3869
|
+
return [];
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
3873
|
+
async save(rfcGroupId, keys) {
|
|
3874
|
+
const sorted = [...new Set(keys)].sort();
|
|
3875
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
3876
|
+
}
|
|
3877
|
+
async wipe() {
|
|
3878
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
3879
|
+
}
|
|
3880
|
+
};
|
|
3881
|
+
|
|
3342
3882
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
3343
3883
|
var palbe_mls_bg_exports = {};
|
|
3344
3884
|
__export(palbe_mls_bg_exports, {
|
|
@@ -5006,6 +5546,36 @@ var SignatureKeyStore = class {
|
|
|
5006
5546
|
}
|
|
5007
5547
|
};
|
|
5008
5548
|
|
|
5549
|
+
// src/messaging/suppression.ts
|
|
5550
|
+
var SuppressionStore = class {
|
|
5551
|
+
constructor(kv) {
|
|
5552
|
+
this.kv = kv;
|
|
5553
|
+
}
|
|
5554
|
+
kv;
|
|
5555
|
+
key(rfcGroupId) {
|
|
5556
|
+
return `supp:${rfcGroupId}`;
|
|
5557
|
+
}
|
|
5558
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
5559
|
+
async load(rfcGroupId) {
|
|
5560
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
5561
|
+
if (!raw) return [];
|
|
5562
|
+
try {
|
|
5563
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
5564
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5565
|
+
} catch {
|
|
5566
|
+
return [];
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
5570
|
+
async save(rfcGroupId, keys) {
|
|
5571
|
+
const sorted = [...new Set(keys)].sort();
|
|
5572
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
5573
|
+
}
|
|
5574
|
+
async wipe() {
|
|
5575
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
5576
|
+
}
|
|
5577
|
+
};
|
|
5578
|
+
|
|
5009
5579
|
// src/messaging/coordinator.ts
|
|
5010
5580
|
var MessagingCoordinator = class {
|
|
5011
5581
|
constructor(rt) {
|
|
@@ -5015,6 +5585,8 @@ var MessagingCoordinator = class {
|
|
|
5015
5585
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
5016
5586
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5017
5587
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
5588
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5589
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
5018
5590
|
this.registry.attachChatList(
|
|
5019
5591
|
(chats) => {
|
|
5020
5592
|
this.chatList = chats;
|
|
@@ -5029,6 +5601,8 @@ var MessagingCoordinator = class {
|
|
|
5029
5601
|
sigStore;
|
|
5030
5602
|
groupStore;
|
|
5031
5603
|
kpStore;
|
|
5604
|
+
suppressionStore;
|
|
5605
|
+
elevationStore;
|
|
5032
5606
|
registry = new GroupRegistry();
|
|
5033
5607
|
resolved = null;
|
|
5034
5608
|
resolvePromise = null;
|
|
@@ -5184,9 +5758,9 @@ var MessagingCoordinator = class {
|
|
|
5184
5758
|
});
|
|
5185
5759
|
return group;
|
|
5186
5760
|
}
|
|
5187
|
-
async sendText(group, text, replyTo) {
|
|
5761
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
5188
5762
|
const r = await this.resolve();
|
|
5189
|
-
return r.groups.sendText(group, text, replyTo);
|
|
5763
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
5190
5764
|
}
|
|
5191
5765
|
async sendReaction(group, args) {
|
|
5192
5766
|
const r = await this.resolve();
|
|
@@ -5196,6 +5770,26 @@ var MessagingCoordinator = class {
|
|
|
5196
5770
|
const r = await this.resolve();
|
|
5197
5771
|
return r.groups.sendEdit(group, args);
|
|
5198
5772
|
}
|
|
5773
|
+
async sendDelete(group, args) {
|
|
5774
|
+
const r = await this.resolve();
|
|
5775
|
+
return r.groups.sendDelete(group, args);
|
|
5776
|
+
}
|
|
5777
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
5778
|
+
loadSuppressed(group) {
|
|
5779
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
5780
|
+
}
|
|
5781
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
5782
|
+
saveSuppressed(group, keys) {
|
|
5783
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
5784
|
+
}
|
|
5785
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
5786
|
+
loadElevated(group) {
|
|
5787
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
5788
|
+
}
|
|
5789
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
5790
|
+
saveElevated(group, keys) {
|
|
5791
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
5792
|
+
}
|
|
5199
5793
|
async history(group, limit, before) {
|
|
5200
5794
|
const r = await this.resolve();
|
|
5201
5795
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -5293,9 +5887,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5293
5887
|
});
|
|
5294
5888
|
}
|
|
5295
5889
|
const editFold = new EditFold();
|
|
5890
|
+
const deleteFold = new DeleteFold();
|
|
5296
5891
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
5297
5892
|
for (const s of rows) {
|
|
5298
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
5893
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5894
|
+
continue;
|
|
5299
5895
|
const cid = s.clientMsgId ?? "";
|
|
5300
5896
|
if (!cid) continue;
|
|
5301
5897
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
@@ -5312,15 +5908,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5312
5908
|
newText: s.edit.newText,
|
|
5313
5909
|
epoch: s.epoch,
|
|
5314
5910
|
serverSeq: s.serverSeq,
|
|
5315
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
5911
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
5912
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
5913
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
5914
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
5316
5915
|
},
|
|
5317
5916
|
authorOfTarget
|
|
5318
5917
|
);
|
|
5319
5918
|
}
|
|
5320
5919
|
editFold.reevaluateHeld(authorOfTarget);
|
|
5920
|
+
for (const s of rows) {
|
|
5921
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
5922
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5923
|
+
deleteFold.ingest(
|
|
5924
|
+
{
|
|
5925
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
5926
|
+
actorUserId: actor,
|
|
5927
|
+
epoch: s.epoch,
|
|
5928
|
+
serverSeq: s.serverSeq,
|
|
5929
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
5930
|
+
},
|
|
5931
|
+
authorOfTarget
|
|
5932
|
+
);
|
|
5933
|
+
}
|
|
5934
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
5321
5935
|
const lookup = /* @__PURE__ */ new Map();
|
|
5322
5936
|
for (const s of rows) {
|
|
5323
|
-
if (s.envelopeType === "reaction")
|
|
5937
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5938
|
+
continue;
|
|
5324
5939
|
const cid = s.clientMsgId ?? "";
|
|
5325
5940
|
if (cid && s.text !== null) {
|
|
5326
5941
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -5329,8 +5944,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5329
5944
|
}
|
|
5330
5945
|
const out = [];
|
|
5331
5946
|
for (const s of rows) {
|
|
5332
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
5947
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5948
|
+
continue;
|
|
5333
5949
|
const clientMsgId = s.clientMsgId ?? "";
|
|
5950
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
5951
|
+
if (isDeleted) {
|
|
5952
|
+
out.push({
|
|
5953
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
5954
|
+
kind: "text",
|
|
5955
|
+
direction: s.direction,
|
|
5956
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
5957
|
+
text: DELETED_DESCRIPTOR,
|
|
5958
|
+
serverSeq: s.serverSeq,
|
|
5959
|
+
sentAt: new Date(s.at),
|
|
5960
|
+
clientMsgId,
|
|
5961
|
+
replyTo: null,
|
|
5962
|
+
reactions: {},
|
|
5963
|
+
edited: false,
|
|
5964
|
+
isDeleted: true,
|
|
5965
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
5966
|
+
mentions: []
|
|
5967
|
+
});
|
|
5968
|
+
continue;
|
|
5969
|
+
}
|
|
5334
5970
|
let replyTo = null;
|
|
5335
5971
|
if (s.replyTo) {
|
|
5336
5972
|
const ref = {
|
|
@@ -5347,22 +5983,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5347
5983
|
}
|
|
5348
5984
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
5349
5985
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
5986
|
+
const text = editText ?? s.text;
|
|
5987
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
5988
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
5350
5989
|
out.push({
|
|
5351
5990
|
id: `${displayId}#${s.serverSeq}`,
|
|
5352
5991
|
kind: s.text != null ? "text" : "system",
|
|
5353
5992
|
direction: s.direction,
|
|
5354
5993
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
5355
|
-
text
|
|
5994
|
+
text,
|
|
5356
5995
|
serverSeq: s.serverSeq,
|
|
5357
5996
|
sentAt: new Date(s.at),
|
|
5358
5997
|
clientMsgId,
|
|
5359
5998
|
replyTo,
|
|
5360
5999
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
5361
|
-
edited
|
|
6000
|
+
edited,
|
|
6001
|
+
isDeleted: false,
|
|
6002
|
+
mentions
|
|
5362
6003
|
});
|
|
5363
6004
|
}
|
|
5364
6005
|
return out;
|
|
5365
6006
|
}
|
|
6007
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
6008
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
6009
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
6010
|
+
start: r.start,
|
|
6011
|
+
length: r.length,
|
|
6012
|
+
mentionedUserId: r.mentionedUserId,
|
|
6013
|
+
displayName: null
|
|
6014
|
+
}));
|
|
6015
|
+
}
|
|
5366
6016
|
|
|
5367
6017
|
// src/messaging/facade.ts
|
|
5368
6018
|
var PalbeMessaging = class {
|
|
@@ -6215,7 +6865,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
|
|
|
6215
6865
|
}
|
|
6216
6866
|
|
|
6217
6867
|
// src/version.ts
|
|
6218
|
-
var VERSION = "1.
|
|
6868
|
+
var VERSION = "1.5.0";
|
|
6219
6869
|
|
|
6220
6870
|
// src/internal.ts
|
|
6221
6871
|
function getRuntime() {
|