@palbase/web 1.2.1 → 1.4.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-C93tr7dA.d.ts → analytics-facade-CbXprYrS.d.ts} +138 -1
- package/dist/{analytics-facade-Ct3A1zop.d.cts → analytics-facade-DA9TGKLF.d.cts} +138 -1
- package/dist/{chunk-KJXRY4S3.js → chunk-3EVGYJ5F.js} +703 -16
- package/dist/chunk-3EVGYJ5F.js.map +1 -0
- package/dist/index.cjs +702 -15
- 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 +702 -15
- 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 +702 -15
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +702 -15
- 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-BtYdWClg.d.ts → pb-CLfTLQzH.d.ts} +1 -1
- package/dist/{pb-BlIfgBG-.d.cts → pb-SVs7vTOp.d.cts} +1 -1
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-KJXRY4S3.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1607,6 +1607,151 @@ 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
|
+
|
|
1673
|
+
// src/messaging/edit-fold.ts
|
|
1674
|
+
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
1675
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
1676
|
+
return aSeq < bSeq;
|
|
1677
|
+
}
|
|
1678
|
+
function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
|
|
1679
|
+
return aEpoch === bEpoch && aSeq === bSeq;
|
|
1680
|
+
}
|
|
1681
|
+
var EditFold = class {
|
|
1682
|
+
// target → winning edit state
|
|
1683
|
+
states = /* @__PURE__ */ new Map();
|
|
1684
|
+
// dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
|
|
1685
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
1686
|
+
// events parked because target/author or sender was unresolved at ingest time
|
|
1687
|
+
held = [];
|
|
1688
|
+
// targets that have had ≥1 valid edit applied (write-once)
|
|
1689
|
+
editedTargets = /* @__PURE__ */ new Set();
|
|
1690
|
+
/**
|
|
1691
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
|
|
1692
|
+
* (null = target unknown/dangling → HOLD).
|
|
1693
|
+
*/
|
|
1694
|
+
ingest(e, authorOfTarget) {
|
|
1695
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
1696
|
+
if (author === null) {
|
|
1697
|
+
this.holdIfNew(e);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
if (e.editorUserId === null) {
|
|
1701
|
+
this.holdIfNew(e);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (e.editorUserId !== author) return;
|
|
1705
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
1706
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
1707
|
+
const prev = this.states.get(e.targetClientMsgId);
|
|
1708
|
+
if (prev !== void 0) {
|
|
1709
|
+
if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
|
|
1710
|
+
if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
this.states.set(e.targetClientMsgId, {
|
|
1715
|
+
orderEpoch: e.epoch,
|
|
1716
|
+
orderSeq: e.serverSeq,
|
|
1717
|
+
lastEventId: e.eventClientMsgId,
|
|
1718
|
+
text: e.newText
|
|
1719
|
+
});
|
|
1720
|
+
this.editedTargets.add(e.targetClientMsgId);
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Park an event for later re-attempt, deduping held re-deliveries by
|
|
1724
|
+
* eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
|
|
1725
|
+
* once (and never double-applies when it finally resolves on reevaluate).
|
|
1726
|
+
*/
|
|
1727
|
+
holdIfNew(e) {
|
|
1728
|
+
if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
|
|
1729
|
+
this.held.push(e);
|
|
1730
|
+
}
|
|
1731
|
+
/** The winning edit text for a target, or null if no valid edit has applied. */
|
|
1732
|
+
text(targetClientMsgId) {
|
|
1733
|
+
return this.states.get(targetClientMsgId)?.text ?? null;
|
|
1734
|
+
}
|
|
1735
|
+
/** Write-once: true once any valid edit applied to the target. */
|
|
1736
|
+
isEdited(targetClientMsgId) {
|
|
1737
|
+
return this.editedTargets.has(targetClientMsgId);
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
1741
|
+
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
1742
|
+
* event with the fresh `authorOfTarget` — events that still don't resolve are
|
|
1743
|
+
* simply re-held; events that now resolve fold via the normal LWW path.
|
|
1744
|
+
* Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
|
|
1745
|
+
* `holdIfNew` (still-held events), so reevaluating repeatedly can neither
|
|
1746
|
+
* double-apply nor lose an edit.
|
|
1747
|
+
*/
|
|
1748
|
+
reevaluateHeld(authorOfTarget) {
|
|
1749
|
+
const pending = this.held;
|
|
1750
|
+
this.held = [];
|
|
1751
|
+
for (const e of pending) this.ingest(e, authorOfTarget);
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1610
1755
|
// src/messaging/util.ts
|
|
1611
1756
|
function toBase64(bytes) {
|
|
1612
1757
|
if (typeof Buffer !== "undefined") {
|
|
@@ -1746,6 +1891,28 @@ async function listDevices(rt, userId) {
|
|
|
1746
1891
|
}
|
|
1747
1892
|
|
|
1748
1893
|
// src/messaging/group-messaging.ts
|
|
1894
|
+
function encodeDelete(args) {
|
|
1895
|
+
return encodeUtf8(
|
|
1896
|
+
JSON.stringify({
|
|
1897
|
+
v: 1,
|
|
1898
|
+
type: "delete",
|
|
1899
|
+
client_msg_id: args.clientMsgId,
|
|
1900
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
1901
|
+
scope: "everyone"
|
|
1902
|
+
})
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
function encodeEdit(args) {
|
|
1906
|
+
return encodeUtf8(
|
|
1907
|
+
JSON.stringify({
|
|
1908
|
+
v: 1,
|
|
1909
|
+
type: "edit",
|
|
1910
|
+
client_msg_id: args.clientMsgId,
|
|
1911
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
1912
|
+
new_text: args.newText
|
|
1913
|
+
})
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1749
1916
|
function encodeReaction(args) {
|
|
1750
1917
|
return encodeUtf8(
|
|
1751
1918
|
JSON.stringify({
|
|
@@ -1772,6 +1939,18 @@ function decodeEnvelope(bytes) {
|
|
|
1772
1939
|
const s = decodeUtf8(bytes);
|
|
1773
1940
|
try {
|
|
1774
1941
|
const o = JSON.parse(s);
|
|
1942
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
1943
|
+
return {
|
|
1944
|
+
type: "delete",
|
|
1945
|
+
text: null,
|
|
1946
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
1947
|
+
replyTo: null,
|
|
1948
|
+
delete: {
|
|
1949
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
1950
|
+
scope: o.scope ?? "everyone"
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1775
1954
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
1776
1955
|
return {
|
|
1777
1956
|
type: "reaction",
|
|
@@ -1785,6 +1964,18 @@ function decodeEnvelope(bytes) {
|
|
|
1785
1964
|
}
|
|
1786
1965
|
};
|
|
1787
1966
|
}
|
|
1967
|
+
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
1968
|
+
return {
|
|
1969
|
+
type: "edit",
|
|
1970
|
+
text: null,
|
|
1971
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
1972
|
+
replyTo: null,
|
|
1973
|
+
edit: {
|
|
1974
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
1975
|
+
newText: o.new_text ?? ""
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1788
1979
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
1789
1980
|
return {
|
|
1790
1981
|
type: "text",
|
|
@@ -2099,6 +2290,103 @@ var GroupMessaging = class {
|
|
|
2099
2290
|
clientMsgId: args.clientMsgId
|
|
2100
2291
|
};
|
|
2101
2292
|
}
|
|
2293
|
+
/** Send an edit (edit-by-supersession on a target message). Encrypts a
|
|
2294
|
+
* `type:'edit'` envelope at the current epoch and sends through the SAME MLS
|
|
2295
|
+
* application path as `sendText` (the server stays blind — an edit is just
|
|
2296
|
+
* another application message). Persists the outgoing edit row so it re-folds
|
|
2297
|
+
* onto its target's text after a reload (the own-send half of the reload
|
|
2298
|
+
* parity). NEVER rebases (epoch-bound like any application message). */
|
|
2299
|
+
async sendEdit(group, args) {
|
|
2300
|
+
const plaintext = encodeEdit({
|
|
2301
|
+
clientMsgId: args.clientMsgId,
|
|
2302
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
2303
|
+
newText: args.newText
|
|
2304
|
+
});
|
|
2305
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2306
|
+
const body = {
|
|
2307
|
+
ciphertext_b64: toBase64(ct),
|
|
2308
|
+
client_idem_key: randomId()
|
|
2309
|
+
};
|
|
2310
|
+
const wire = await palbeRequest(
|
|
2311
|
+
this.rt,
|
|
2312
|
+
"POST",
|
|
2313
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
2314
|
+
{ body }
|
|
2315
|
+
);
|
|
2316
|
+
const stored = {
|
|
2317
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
2318
|
+
direction: "outgoing",
|
|
2319
|
+
text: null,
|
|
2320
|
+
senderDeviceId: this.selfDeviceId,
|
|
2321
|
+
epoch: wire.epoch,
|
|
2322
|
+
serverSeq: wire.server_seq,
|
|
2323
|
+
at: Date.now(),
|
|
2324
|
+
clientMsgId: args.clientMsgId,
|
|
2325
|
+
replyTo: null,
|
|
2326
|
+
envelopeType: "edit",
|
|
2327
|
+
edit: {
|
|
2328
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
2329
|
+
newText: args.newText
|
|
2330
|
+
}
|
|
2331
|
+
};
|
|
2332
|
+
try {
|
|
2333
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
2334
|
+
} catch {
|
|
2335
|
+
}
|
|
2336
|
+
return {
|
|
2337
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
2338
|
+
clientMsgId: args.clientMsgId
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
2342
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
2343
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
2344
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
2345
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
2346
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
2347
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
2348
|
+
* rebases (epoch-bound like any application message). */
|
|
2349
|
+
async sendDelete(group, args) {
|
|
2350
|
+
const plaintext = encodeDelete({
|
|
2351
|
+
clientMsgId: args.clientMsgId,
|
|
2352
|
+
targetClientMsgId: args.targetClientMsgId
|
|
2353
|
+
});
|
|
2354
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2355
|
+
const body = {
|
|
2356
|
+
ciphertext_b64: toBase64(ct),
|
|
2357
|
+
client_idem_key: randomId()
|
|
2358
|
+
};
|
|
2359
|
+
const wire = await palbeRequest(
|
|
2360
|
+
this.rt,
|
|
2361
|
+
"POST",
|
|
2362
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
2363
|
+
{ body }
|
|
2364
|
+
);
|
|
2365
|
+
const stored = {
|
|
2366
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
2367
|
+
direction: "outgoing",
|
|
2368
|
+
text: null,
|
|
2369
|
+
senderDeviceId: this.selfDeviceId,
|
|
2370
|
+
epoch: wire.epoch,
|
|
2371
|
+
serverSeq: wire.server_seq,
|
|
2372
|
+
at: Date.now(),
|
|
2373
|
+
clientMsgId: args.clientMsgId,
|
|
2374
|
+
replyTo: null,
|
|
2375
|
+
envelopeType: "delete",
|
|
2376
|
+
delete: {
|
|
2377
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
2378
|
+
scope: "everyone"
|
|
2379
|
+
}
|
|
2380
|
+
};
|
|
2381
|
+
try {
|
|
2382
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
2383
|
+
} catch {
|
|
2384
|
+
}
|
|
2385
|
+
return {
|
|
2386
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
2387
|
+
clientMsgId: args.clientMsgId
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2102
2390
|
// ── The rebase loop ──
|
|
2103
2391
|
async commitWithRebase(rfcGroupId, build) {
|
|
2104
2392
|
const gidBytes = fromBase64(rfcGroupId);
|
|
@@ -2216,6 +2504,7 @@ var ReactionFold = class {
|
|
|
2216
2504
|
};
|
|
2217
2505
|
|
|
2218
2506
|
// src/messaging/chat.ts
|
|
2507
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
2219
2508
|
var Chat = class {
|
|
2220
2509
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
2221
2510
|
id;
|
|
@@ -2235,6 +2524,23 @@ var Chat = class {
|
|
|
2235
2524
|
byClientMsgId = /* @__PURE__ */ new Map();
|
|
2236
2525
|
/** The single authoritative reaction fold for this chat (live + own-send + history). */
|
|
2237
2526
|
reactionFold = new ReactionFold();
|
|
2527
|
+
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
2528
|
+
editFold = new EditFold();
|
|
2529
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
2530
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
2531
|
+
deleteFold = new DeleteFold();
|
|
2532
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
2533
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
2534
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
2535
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
2536
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
2537
|
+
suppressedLoaded = false;
|
|
2538
|
+
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
2539
|
+
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
2540
|
+
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
2541
|
+
/** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
|
|
2542
|
+
* projection time from senderUserId; '' = resolved-but-unknown peer). */
|
|
2543
|
+
authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
2238
2544
|
loadedEarliestSeq = null;
|
|
2239
2545
|
historyLoaded = false;
|
|
2240
2546
|
wired = false;
|
|
@@ -2277,7 +2583,7 @@ var Chat = class {
|
|
|
2277
2583
|
return this.kind === "direct";
|
|
2278
2584
|
}
|
|
2279
2585
|
get messages() {
|
|
2280
|
-
return this.
|
|
2586
|
+
return this.surfaced();
|
|
2281
2587
|
}
|
|
2282
2588
|
get members() {
|
|
2283
2589
|
return this.memberCache;
|
|
@@ -2286,13 +2592,49 @@ var Chat = class {
|
|
|
2286
2592
|
return this.typingList;
|
|
2287
2593
|
}
|
|
2288
2594
|
get lastMessage() {
|
|
2289
|
-
return this.
|
|
2595
|
+
return this.surfaced().at(-1) ?? null;
|
|
2290
2596
|
}
|
|
2291
2597
|
get unreadCount() {
|
|
2292
|
-
return this.
|
|
2293
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
2598
|
+
return this.surfaced().filter(
|
|
2599
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
2294
2600
|
).length;
|
|
2295
2601
|
}
|
|
2602
|
+
/**
|
|
2603
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
2604
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
2605
|
+
* folded edit text + reactions + reply):
|
|
2606
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
2607
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
2608
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
2609
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
2610
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
2611
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
2612
|
+
*/
|
|
2613
|
+
surfaced() {
|
|
2614
|
+
const out = [];
|
|
2615
|
+
for (const m of this.messageList) {
|
|
2616
|
+
const key = this.suppressionKey(m);
|
|
2617
|
+
if (this.suppressed.has(key)) continue;
|
|
2618
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
2619
|
+
if (tombstoned) {
|
|
2620
|
+
out.push({
|
|
2621
|
+
...m,
|
|
2622
|
+
text: DELETED_DESCRIPTOR,
|
|
2623
|
+
reactions: {},
|
|
2624
|
+
replyTo: null,
|
|
2625
|
+
edited: false,
|
|
2626
|
+
isDeleted: true
|
|
2627
|
+
});
|
|
2628
|
+
continue;
|
|
2629
|
+
}
|
|
2630
|
+
out.push(m);
|
|
2631
|
+
}
|
|
2632
|
+
return out;
|
|
2633
|
+
}
|
|
2634
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
2635
|
+
suppressionKey(m) {
|
|
2636
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
2637
|
+
}
|
|
2296
2638
|
get title() {
|
|
2297
2639
|
if (this.titleOverride) return this.titleOverride;
|
|
2298
2640
|
if (this._group?.name) return this._group.name;
|
|
@@ -2316,9 +2658,28 @@ var Chat = class {
|
|
|
2316
2658
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
2317
2659
|
this.wired = true;
|
|
2318
2660
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
2661
|
+
void this.loadSuppressed();
|
|
2319
2662
|
void this.hydrateHistory();
|
|
2320
2663
|
void this.refreshMembers();
|
|
2321
2664
|
}
|
|
2665
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
2666
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
2667
|
+
async loadSuppressed() {
|
|
2668
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
2669
|
+
this.suppressedLoaded = true;
|
|
2670
|
+
try {
|
|
2671
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
2672
|
+
let changed = false;
|
|
2673
|
+
for (const k of keys) {
|
|
2674
|
+
if (!this.suppressed.has(k)) {
|
|
2675
|
+
this.suppressed.add(k);
|
|
2676
|
+
changed = true;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
if (changed) this.emit();
|
|
2680
|
+
} catch {
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2322
2683
|
async hydrateHistory() {
|
|
2323
2684
|
if (this.historyLoaded || !this._group) return;
|
|
2324
2685
|
this.historyLoaded = true;
|
|
@@ -2329,19 +2690,26 @@ var Chat = class {
|
|
|
2329
2690
|
let changed = false;
|
|
2330
2691
|
for (const m of incoming) {
|
|
2331
2692
|
if (m.serverSeq <= 0) continue;
|
|
2332
|
-
if (m.clientMsgId && m.text !== null) {
|
|
2693
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
2333
2694
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
2334
2695
|
text: m.text,
|
|
2335
2696
|
senderUserId: m.senderUserId ?? ""
|
|
2336
2697
|
});
|
|
2337
2698
|
}
|
|
2699
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
2700
|
+
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
2701
|
+
}
|
|
2338
2702
|
}
|
|
2703
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2339
2704
|
for (const m of incoming) {
|
|
2340
2705
|
if (m.serverSeq <= 0) continue;
|
|
2341
2706
|
const key = this.internalKey(m.serverSeq);
|
|
2342
2707
|
if (this.seenKeys.has(key)) continue;
|
|
2343
2708
|
this.seenKeys.add(key);
|
|
2344
|
-
|
|
2709
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
2710
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
2711
|
+
}
|
|
2712
|
+
this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
|
|
2345
2713
|
changed = true;
|
|
2346
2714
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
2347
2715
|
}
|
|
@@ -2382,6 +2750,37 @@ var Chat = class {
|
|
|
2382
2750
|
}
|
|
2383
2751
|
return;
|
|
2384
2752
|
}
|
|
2753
|
+
if (incoming.envelopeType === "edit" && incoming.edit) {
|
|
2754
|
+
const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
2755
|
+
this.editFold.ingest(
|
|
2756
|
+
{
|
|
2757
|
+
targetClientMsgId: incoming.edit.targetClientMsgId,
|
|
2758
|
+
editorUserId,
|
|
2759
|
+
newText: incoming.edit.newText,
|
|
2760
|
+
epoch: incoming.epoch,
|
|
2761
|
+
serverSeq: incoming.serverSeq,
|
|
2762
|
+
eventClientMsgId: incoming.clientMsgId
|
|
2763
|
+
},
|
|
2764
|
+
this.authorOfTarget
|
|
2765
|
+
);
|
|
2766
|
+
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
2770
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
2771
|
+
this.deleteFold.ingest(
|
|
2772
|
+
{
|
|
2773
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
2774
|
+
actorUserId,
|
|
2775
|
+
epoch: incoming.epoch,
|
|
2776
|
+
serverSeq: incoming.serverSeq,
|
|
2777
|
+
eventClientMsgId: incoming.clientMsgId
|
|
2778
|
+
},
|
|
2779
|
+
this.authorOfTarget
|
|
2780
|
+
);
|
|
2781
|
+
this.emit();
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2385
2784
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
2386
2785
|
const incomingReplyRef = incoming.replyRef;
|
|
2387
2786
|
let resolvedReplyTo = null;
|
|
@@ -2400,7 +2799,11 @@ var Chat = class {
|
|
|
2400
2799
|
replyTo: resolvedReplyTo,
|
|
2401
2800
|
// Attach any tally already folded for this message (a reaction that arrived
|
|
2402
2801
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
2403
|
-
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
|
|
2802
|
+
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
2803
|
+
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
2804
|
+
edited: false,
|
|
2805
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
2806
|
+
isDeleted: false
|
|
2404
2807
|
};
|
|
2405
2808
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
2406
2809
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
@@ -2408,7 +2811,12 @@ var Chat = class {
|
|
|
2408
2811
|
senderUserId: senderUser ?? ""
|
|
2409
2812
|
});
|
|
2410
2813
|
}
|
|
2411
|
-
|
|
2814
|
+
if (incomingClientMsgId) {
|
|
2815
|
+
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
2816
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2817
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
2818
|
+
}
|
|
2819
|
+
this.messageList.push(this.applyEditOverlay(msg));
|
|
2412
2820
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
2413
2821
|
this.loadedEarliestSeq = Math.min(
|
|
2414
2822
|
this.loadedEarliestSeq ?? incoming.serverSeq,
|
|
@@ -2416,6 +2824,19 @@ var Chat = class {
|
|
|
2416
2824
|
);
|
|
2417
2825
|
this.emit();
|
|
2418
2826
|
}
|
|
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
|
+
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
2832
|
+
* (a later own/peer edit must not overwrite the original we render against). The
|
|
2833
|
+
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
2834
|
+
seedEditBase(clientMsgId, text, author) {
|
|
2835
|
+
if (!this.originalTextByClientMsgId.has(clientMsgId)) {
|
|
2836
|
+
this.originalTextByClientMsgId.set(clientMsgId, text);
|
|
2837
|
+
}
|
|
2838
|
+
if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
|
|
2839
|
+
}
|
|
2419
2840
|
/**
|
|
2420
2841
|
* Rebuild the target message's `reactions` from the authoritative fold and
|
|
2421
2842
|
* re-emit. No-op when the target isn't present yet (its tally is attached the
|
|
@@ -2445,6 +2866,46 @@ var Chat = class {
|
|
|
2445
2866
|
if (sameReactions(m.reactions, tally)) return m;
|
|
2446
2867
|
return { ...m, reactions: tally };
|
|
2447
2868
|
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Rebuild the target message's rendered `text` + `edited` flag from the
|
|
2871
|
+
* authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
|
|
2872
|
+
* (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
|
|
2873
|
+
* base`; base is the seeded original so a forged/ignored edit leaves it intact.
|
|
2874
|
+
* No-op when the target isn't present yet (the fold already recorded it; the
|
|
2875
|
+
* overlay applies the moment the target lands) or when unchanged.
|
|
2876
|
+
*/
|
|
2877
|
+
recomputeEdit(targetClientMsgId) {
|
|
2878
|
+
if (!targetClientMsgId) return;
|
|
2879
|
+
const editText = this.editFold.text(targetClientMsgId);
|
|
2880
|
+
const foldEdited = this.editFold.isEdited(targetClientMsgId);
|
|
2881
|
+
let changed = false;
|
|
2882
|
+
this.messageList = this.messageList.map((m) => {
|
|
2883
|
+
if (m.clientMsgId !== targetClientMsgId) return m;
|
|
2884
|
+
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
2885
|
+
const text = editText ?? base;
|
|
2886
|
+
const edited = foldEdited || m.edited;
|
|
2887
|
+
if (m.text === text && m.edited === edited) return m;
|
|
2888
|
+
changed = true;
|
|
2889
|
+
return { ...m, text, edited };
|
|
2890
|
+
});
|
|
2891
|
+
if (changed) this.emit();
|
|
2892
|
+
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Overlay the authoritative edit fold's winning text + flag onto a message as it
|
|
2895
|
+
* is appended/merged. The fold WINS when it has an edit for this target;
|
|
2896
|
+
* otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
|
|
2897
|
+
* history fold) is preserved. PRESERVES reactions + replyTo.
|
|
2898
|
+
*/
|
|
2899
|
+
applyEditOverlay(m) {
|
|
2900
|
+
if (!m.clientMsgId) return m;
|
|
2901
|
+
const editText = this.editFold.text(m.clientMsgId);
|
|
2902
|
+
const foldEdited = this.editFold.isEdited(m.clientMsgId);
|
|
2903
|
+
if (editText === null && !foldEdited) return m;
|
|
2904
|
+
const text = editText ?? m.text;
|
|
2905
|
+
const edited = foldEdited || m.edited;
|
|
2906
|
+
if (m.text === text && m.edited === edited) return m;
|
|
2907
|
+
return { ...m, text, edited };
|
|
2908
|
+
}
|
|
2448
2909
|
/** @internal — called by the backend's conv subscription. */
|
|
2449
2910
|
applyConv(event, payload) {
|
|
2450
2911
|
const userId = typeof payload.user_id === "string" ? payload.user_id : null;
|
|
@@ -2499,6 +2960,8 @@ var Chat = class {
|
|
|
2499
2960
|
this.memberCache = m;
|
|
2500
2961
|
this.emit();
|
|
2501
2962
|
}
|
|
2963
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2964
|
+
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
2502
2965
|
}
|
|
2503
2966
|
seedMembersFromGroup(group) {
|
|
2504
2967
|
const seed = [
|
|
@@ -2577,6 +3040,7 @@ var Chat = class {
|
|
|
2577
3040
|
this.seenKeys.add(key);
|
|
2578
3041
|
if (clientMsgId) {
|
|
2579
3042
|
this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
|
|
3043
|
+
this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
|
|
2580
3044
|
}
|
|
2581
3045
|
this.messageList.push({
|
|
2582
3046
|
id: this.publicId(receipt.serverSeq),
|
|
@@ -2590,7 +3054,11 @@ var Chat = class {
|
|
|
2590
3054
|
replyTo: resolvedReplyTo,
|
|
2591
3055
|
// Attach any tally already folded for this own-sent message (rare, but keeps
|
|
2592
3056
|
// the dangling-target invariant uniform across every append path).
|
|
2593
|
-
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
|
|
3057
|
+
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
3058
|
+
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3059
|
+
edited: false,
|
|
3060
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3061
|
+
isDeleted: false
|
|
2594
3062
|
});
|
|
2595
3063
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
2596
3064
|
this.emit();
|
|
@@ -2671,6 +3139,83 @@ var Chat = class {
|
|
|
2671
3139
|
});
|
|
2672
3140
|
this.recomputeReactions(message.clientMsgId);
|
|
2673
3141
|
}
|
|
3142
|
+
// ── Edit ──
|
|
3143
|
+
/** Edit an own text message (edit-by-supersession). No-op if the message isn't
|
|
3144
|
+
* editable (empty clientMsgId, or not a `text` kind). The edit folds locally
|
|
3145
|
+
* with the server receipt's `(epoch, serverSeq)` so the target's text updates
|
|
3146
|
+
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3147
|
+
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3148
|
+
* 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
|
+
async edit(message, newText) {
|
|
3151
|
+
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3152
|
+
const group = await this.materializeIfNeeded();
|
|
3153
|
+
const clientMsgId = mintClientMsgId();
|
|
3154
|
+
const { receipt } = await this.backend.sendEdit(group, {
|
|
3155
|
+
clientMsgId,
|
|
3156
|
+
targetClientMsgId: message.clientMsgId,
|
|
3157
|
+
newText
|
|
3158
|
+
});
|
|
3159
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3160
|
+
this.editFold.ingest(
|
|
3161
|
+
{
|
|
3162
|
+
targetClientMsgId: message.clientMsgId,
|
|
3163
|
+
editorUserId: this.backend.selfUserId,
|
|
3164
|
+
newText,
|
|
3165
|
+
epoch: receipt.epoch,
|
|
3166
|
+
serverSeq: receipt.serverSeq,
|
|
3167
|
+
eventClientMsgId: clientMsgId
|
|
3168
|
+
},
|
|
3169
|
+
this.authorOfTarget
|
|
3170
|
+
);
|
|
3171
|
+
this.recomputeEdit(message.clientMsgId);
|
|
3172
|
+
}
|
|
3173
|
+
// ── Delete ──
|
|
3174
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
3175
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
3176
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
3177
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
3178
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
3179
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
3180
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
3181
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
3182
|
+
async deleteForEveryone(message) {
|
|
3183
|
+
if (!message.clientMsgId) return;
|
|
3184
|
+
const group = await this.materializeIfNeeded();
|
|
3185
|
+
const clientMsgId = mintClientMsgId();
|
|
3186
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
3187
|
+
clientMsgId,
|
|
3188
|
+
targetClientMsgId: message.clientMsgId
|
|
3189
|
+
});
|
|
3190
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3191
|
+
this.deleteFold.ingest(
|
|
3192
|
+
{
|
|
3193
|
+
targetClientMsgId: message.clientMsgId,
|
|
3194
|
+
actorUserId: this.backend.selfUserId,
|
|
3195
|
+
epoch: receipt.epoch,
|
|
3196
|
+
serverSeq: receipt.serverSeq,
|
|
3197
|
+
eventClientMsgId: clientMsgId
|
|
3198
|
+
},
|
|
3199
|
+
this.authorOfTarget
|
|
3200
|
+
);
|
|
3201
|
+
this.emit();
|
|
3202
|
+
}
|
|
3203
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
3204
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
3205
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
3206
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
3207
|
+
async deleteForMe(message) {
|
|
3208
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
3209
|
+
if (this.suppressed.has(key)) return;
|
|
3210
|
+
this.suppressed.add(key);
|
|
3211
|
+
this.emit();
|
|
3212
|
+
if (this._group) {
|
|
3213
|
+
try {
|
|
3214
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
3215
|
+
} catch {
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
2674
3219
|
};
|
|
2675
3220
|
function sameReactions(a, b) {
|
|
2676
3221
|
const ak = Object.keys(a);
|
|
@@ -2850,6 +3395,8 @@ var MessageDeliverySource = class {
|
|
|
2850
3395
|
const decoded = decodeEnvelope(received.data);
|
|
2851
3396
|
const { text, clientMsgId, replyTo } = decoded;
|
|
2852
3397
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
3398
|
+
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
3399
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
2853
3400
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
2854
3401
|
const stored = {
|
|
2855
3402
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -2877,6 +3424,29 @@ var MessageDeliverySource = class {
|
|
|
2877
3424
|
emoji: decoded.reaction.emoji,
|
|
2878
3425
|
op: decoded.reaction.op
|
|
2879
3426
|
}
|
|
3427
|
+
} : {},
|
|
3428
|
+
// Thread the edit discriminator + new text through the persisted row so an
|
|
3429
|
+
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
3430
|
+
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
3431
|
+
// `'text'`/no-edit (backward-compat).
|
|
3432
|
+
...isEdit && decoded.edit ? {
|
|
3433
|
+
envelopeType: "edit",
|
|
3434
|
+
edit: {
|
|
3435
|
+
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
3436
|
+
newText: decoded.edit.newText
|
|
3437
|
+
}
|
|
3438
|
+
} : {},
|
|
3439
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
3440
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
3441
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
3442
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
3443
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
3444
|
+
...isDelete && decoded.delete ? {
|
|
3445
|
+
envelopeType: "delete",
|
|
3446
|
+
delete: {
|
|
3447
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
3448
|
+
scope: decoded.delete.scope
|
|
3449
|
+
}
|
|
2880
3450
|
} : {}
|
|
2881
3451
|
};
|
|
2882
3452
|
try {
|
|
@@ -2895,7 +3465,9 @@ var MessageDeliverySource = class {
|
|
|
2895
3465
|
clientMsgId,
|
|
2896
3466
|
replyRef: replyTo,
|
|
2897
3467
|
envelopeType: decoded.type ?? "text",
|
|
2898
|
-
reaction: isReaction ? decoded.reaction : null
|
|
3468
|
+
reaction: isReaction ? decoded.reaction : null,
|
|
3469
|
+
edit: isEdit ? decoded.edit : null,
|
|
3470
|
+
delete: isDelete ? decoded.delete : null
|
|
2899
3471
|
});
|
|
2900
3472
|
return true;
|
|
2901
3473
|
}
|
|
@@ -4717,6 +5289,36 @@ var SignatureKeyStore = class {
|
|
|
4717
5289
|
}
|
|
4718
5290
|
};
|
|
4719
5291
|
|
|
5292
|
+
// src/messaging/suppression.ts
|
|
5293
|
+
var SuppressionStore = class {
|
|
5294
|
+
constructor(kv) {
|
|
5295
|
+
this.kv = kv;
|
|
5296
|
+
}
|
|
5297
|
+
kv;
|
|
5298
|
+
key(rfcGroupId) {
|
|
5299
|
+
return `supp:${rfcGroupId}`;
|
|
5300
|
+
}
|
|
5301
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
5302
|
+
async load(rfcGroupId) {
|
|
5303
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
5304
|
+
if (!raw) return [];
|
|
5305
|
+
try {
|
|
5306
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
5307
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5308
|
+
} catch {
|
|
5309
|
+
return [];
|
|
5310
|
+
}
|
|
5311
|
+
}
|
|
5312
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
5313
|
+
async save(rfcGroupId, keys) {
|
|
5314
|
+
const sorted = [...new Set(keys)].sort();
|
|
5315
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
5316
|
+
}
|
|
5317
|
+
async wipe() {
|
|
5318
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
5319
|
+
}
|
|
5320
|
+
};
|
|
5321
|
+
|
|
4720
5322
|
// src/messaging/coordinator.ts
|
|
4721
5323
|
var MessagingCoordinator = class {
|
|
4722
5324
|
constructor(rt) {
|
|
@@ -4726,6 +5328,7 @@ var MessagingCoordinator = class {
|
|
|
4726
5328
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
4727
5329
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
4728
5330
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
5331
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
4729
5332
|
this.registry.attachChatList(
|
|
4730
5333
|
(chats) => {
|
|
4731
5334
|
this.chatList = chats;
|
|
@@ -4740,6 +5343,7 @@ var MessagingCoordinator = class {
|
|
|
4740
5343
|
sigStore;
|
|
4741
5344
|
groupStore;
|
|
4742
5345
|
kpStore;
|
|
5346
|
+
suppressionStore;
|
|
4743
5347
|
registry = new GroupRegistry();
|
|
4744
5348
|
resolved = null;
|
|
4745
5349
|
resolvePromise = null;
|
|
@@ -4903,6 +5507,22 @@ var MessagingCoordinator = class {
|
|
|
4903
5507
|
const r = await this.resolve();
|
|
4904
5508
|
return r.groups.sendReaction(group, args);
|
|
4905
5509
|
}
|
|
5510
|
+
async sendEdit(group, args) {
|
|
5511
|
+
const r = await this.resolve();
|
|
5512
|
+
return r.groups.sendEdit(group, args);
|
|
5513
|
+
}
|
|
5514
|
+
async sendDelete(group, args) {
|
|
5515
|
+
const r = await this.resolve();
|
|
5516
|
+
return r.groups.sendDelete(group, args);
|
|
5517
|
+
}
|
|
5518
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
5519
|
+
loadSuppressed(group) {
|
|
5520
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
5521
|
+
}
|
|
5522
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
5523
|
+
saveSuppressed(group, keys) {
|
|
5524
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
5525
|
+
}
|
|
4906
5526
|
async history(group, limit, before) {
|
|
4907
5527
|
const r = await this.resolve();
|
|
4908
5528
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -4999,9 +5619,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
4999
5619
|
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
5000
5620
|
});
|
|
5001
5621
|
}
|
|
5622
|
+
const editFold = new EditFold();
|
|
5623
|
+
const deleteFold = new DeleteFold();
|
|
5624
|
+
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
5625
|
+
for (const s of rows) {
|
|
5626
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5627
|
+
continue;
|
|
5628
|
+
const cid = s.clientMsgId ?? "";
|
|
5629
|
+
if (!cid) continue;
|
|
5630
|
+
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
5631
|
+
if (author != null) authorByClientMsgId.set(cid, author);
|
|
5632
|
+
}
|
|
5633
|
+
const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
|
|
5634
|
+
for (const s of rows) {
|
|
5635
|
+
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
5636
|
+
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5637
|
+
editFold.ingest(
|
|
5638
|
+
{
|
|
5639
|
+
targetClientMsgId: s.edit.targetClientMsgId,
|
|
5640
|
+
editorUserId: editor,
|
|
5641
|
+
newText: s.edit.newText,
|
|
5642
|
+
epoch: s.epoch,
|
|
5643
|
+
serverSeq: s.serverSeq,
|
|
5644
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
5645
|
+
},
|
|
5646
|
+
authorOfTarget
|
|
5647
|
+
);
|
|
5648
|
+
}
|
|
5649
|
+
editFold.reevaluateHeld(authorOfTarget);
|
|
5650
|
+
for (const s of rows) {
|
|
5651
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
5652
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
5653
|
+
deleteFold.ingest(
|
|
5654
|
+
{
|
|
5655
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
5656
|
+
actorUserId: actor,
|
|
5657
|
+
epoch: s.epoch,
|
|
5658
|
+
serverSeq: s.serverSeq,
|
|
5659
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
5660
|
+
},
|
|
5661
|
+
authorOfTarget
|
|
5662
|
+
);
|
|
5663
|
+
}
|
|
5664
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
5002
5665
|
const lookup = /* @__PURE__ */ new Map();
|
|
5003
5666
|
for (const s of rows) {
|
|
5004
|
-
if (s.envelopeType === "reaction")
|
|
5667
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5668
|
+
continue;
|
|
5005
5669
|
const cid = s.clientMsgId ?? "";
|
|
5006
5670
|
if (cid && s.text !== null) {
|
|
5007
5671
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -5010,8 +5674,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5010
5674
|
}
|
|
5011
5675
|
const out = [];
|
|
5012
5676
|
for (const s of rows) {
|
|
5013
|
-
if (s.envelopeType === "reaction")
|
|
5677
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
5678
|
+
continue;
|
|
5014
5679
|
const clientMsgId = s.clientMsgId ?? "";
|
|
5680
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
5681
|
+
if (isDeleted) {
|
|
5682
|
+
out.push({
|
|
5683
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
5684
|
+
kind: "text",
|
|
5685
|
+
direction: s.direction,
|
|
5686
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
5687
|
+
text: DELETED_DESCRIPTOR,
|
|
5688
|
+
serverSeq: s.serverSeq,
|
|
5689
|
+
sentAt: new Date(s.at),
|
|
5690
|
+
clientMsgId,
|
|
5691
|
+
replyTo: null,
|
|
5692
|
+
reactions: {},
|
|
5693
|
+
edited: false,
|
|
5694
|
+
isDeleted: true
|
|
5695
|
+
});
|
|
5696
|
+
continue;
|
|
5697
|
+
}
|
|
5015
5698
|
let replyTo = null;
|
|
5016
5699
|
if (s.replyTo) {
|
|
5017
5700
|
const ref = {
|
|
@@ -5026,17 +5709,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5026
5709
|
};
|
|
5027
5710
|
replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
|
|
5028
5711
|
}
|
|
5712
|
+
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
5713
|
+
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
5029
5714
|
out.push({
|
|
5030
5715
|
id: `${displayId}#${s.serverSeq}`,
|
|
5031
5716
|
kind: s.text != null ? "text" : "system",
|
|
5032
5717
|
direction: s.direction,
|
|
5033
5718
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
5034
|
-
text: s.text,
|
|
5719
|
+
text: editText ?? s.text,
|
|
5035
5720
|
serverSeq: s.serverSeq,
|
|
5036
5721
|
sentAt: new Date(s.at),
|
|
5037
5722
|
clientMsgId,
|
|
5038
5723
|
replyTo,
|
|
5039
|
-
reactions: clientMsgId ? fold.tally(clientMsgId) : {}
|
|
5724
|
+
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
5725
|
+
edited,
|
|
5726
|
+
isDeleted: false
|
|
5040
5727
|
});
|
|
5041
5728
|
}
|
|
5042
5729
|
return out;
|
|
@@ -5893,7 +6580,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
|
|
|
5893
6580
|
}
|
|
5894
6581
|
|
|
5895
6582
|
// src/version.ts
|
|
5896
|
-
var VERSION = "1.
|
|
6583
|
+
var VERSION = "1.4.0";
|
|
5897
6584
|
|
|
5898
6585
|
// src/internal.ts
|
|
5899
6586
|
function getRuntime() {
|