@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/next/client.cjs
CHANGED
|
@@ -2413,6 +2413,151 @@ var PalbeFlags = class {
|
|
|
2413
2413
|
}
|
|
2414
2414
|
};
|
|
2415
2415
|
|
|
2416
|
+
// src/messaging/delete-fold.ts
|
|
2417
|
+
var DeleteFold = class {
|
|
2418
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2419
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2420
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2421
|
+
pending = /* @__PURE__ */ new Map();
|
|
2422
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2423
|
+
seen = /* @__PURE__ */ new Set();
|
|
2424
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2425
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2426
|
+
held = [];
|
|
2427
|
+
/**
|
|
2428
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2429
|
+
* userId (null = target absent locally → defer).
|
|
2430
|
+
*/
|
|
2431
|
+
ingest(e, authorOfTarget) {
|
|
2432
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2433
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2434
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2435
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2436
|
+
if (author !== null) {
|
|
2437
|
+
this.seen.add(e.eventClientMsgId);
|
|
2438
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2439
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2440
|
+
} else if (e.actorUserId !== null) {
|
|
2441
|
+
this.seen.add(e.eventClientMsgId);
|
|
2442
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2443
|
+
} else {
|
|
2444
|
+
this.held.push(e);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2448
|
+
isTombstoned(targetClientMsgId) {
|
|
2449
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2453
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2454
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2455
|
+
* the in-order path.
|
|
2456
|
+
*/
|
|
2457
|
+
reevaluatePending(target, author) {
|
|
2458
|
+
const actor = this.pending.get(target);
|
|
2459
|
+
if (actor !== void 0) {
|
|
2460
|
+
if (author !== null && actor === author) {
|
|
2461
|
+
this.tombstoned.add(target);
|
|
2462
|
+
this.pending.delete(target);
|
|
2463
|
+
} else if (author !== null) {
|
|
2464
|
+
this.pending.delete(target);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
if (this.held.length === 0) return;
|
|
2468
|
+
const pendingHeld = this.held;
|
|
2469
|
+
this.held = [];
|
|
2470
|
+
for (const e of pendingHeld) {
|
|
2471
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
heldContains(eventClientMsgId) {
|
|
2475
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2476
|
+
}
|
|
2477
|
+
};
|
|
2478
|
+
|
|
2479
|
+
// src/messaging/edit-fold.ts
|
|
2480
|
+
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2481
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
2482
|
+
return aSeq < bSeq;
|
|
2483
|
+
}
|
|
2484
|
+
function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2485
|
+
return aEpoch === bEpoch && aSeq === bSeq;
|
|
2486
|
+
}
|
|
2487
|
+
var EditFold = class {
|
|
2488
|
+
// target → winning edit state
|
|
2489
|
+
states = /* @__PURE__ */ new Map();
|
|
2490
|
+
// dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
|
|
2491
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
2492
|
+
// events parked because target/author or sender was unresolved at ingest time
|
|
2493
|
+
held = [];
|
|
2494
|
+
// targets that have had ≥1 valid edit applied (write-once)
|
|
2495
|
+
editedTargets = /* @__PURE__ */ new Set();
|
|
2496
|
+
/**
|
|
2497
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
|
|
2498
|
+
* (null = target unknown/dangling → HOLD).
|
|
2499
|
+
*/
|
|
2500
|
+
ingest(e, authorOfTarget) {
|
|
2501
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2502
|
+
if (author === null) {
|
|
2503
|
+
this.holdIfNew(e);
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
if (e.editorUserId === null) {
|
|
2507
|
+
this.holdIfNew(e);
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
if (e.editorUserId !== author) return;
|
|
2511
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
2512
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2513
|
+
const prev = this.states.get(e.targetClientMsgId);
|
|
2514
|
+
if (prev !== void 0) {
|
|
2515
|
+
if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
|
|
2516
|
+
if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
this.states.set(e.targetClientMsgId, {
|
|
2521
|
+
orderEpoch: e.epoch,
|
|
2522
|
+
orderSeq: e.serverSeq,
|
|
2523
|
+
lastEventId: e.eventClientMsgId,
|
|
2524
|
+
text: e.newText
|
|
2525
|
+
});
|
|
2526
|
+
this.editedTargets.add(e.targetClientMsgId);
|
|
2527
|
+
}
|
|
2528
|
+
/**
|
|
2529
|
+
* Park an event for later re-attempt, deduping held re-deliveries by
|
|
2530
|
+
* eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
|
|
2531
|
+
* once (and never double-applies when it finally resolves on reevaluate).
|
|
2532
|
+
*/
|
|
2533
|
+
holdIfNew(e) {
|
|
2534
|
+
if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
|
|
2535
|
+
this.held.push(e);
|
|
2536
|
+
}
|
|
2537
|
+
/** The winning edit text for a target, or null if no valid edit has applied. */
|
|
2538
|
+
text(targetClientMsgId) {
|
|
2539
|
+
return this.states.get(targetClientMsgId)?.text ?? null;
|
|
2540
|
+
}
|
|
2541
|
+
/** Write-once: true once any valid edit applied to the target. */
|
|
2542
|
+
isEdited(targetClientMsgId) {
|
|
2543
|
+
return this.editedTargets.has(targetClientMsgId);
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2547
|
+
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
2548
|
+
* event with the fresh `authorOfTarget` — events that still don't resolve are
|
|
2549
|
+
* simply re-held; events that now resolve fold via the normal LWW path.
|
|
2550
|
+
* Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
|
|
2551
|
+
* `holdIfNew` (still-held events), so reevaluating repeatedly can neither
|
|
2552
|
+
* double-apply nor lose an edit.
|
|
2553
|
+
*/
|
|
2554
|
+
reevaluateHeld(authorOfTarget) {
|
|
2555
|
+
const pending = this.held;
|
|
2556
|
+
this.held = [];
|
|
2557
|
+
for (const e of pending) this.ingest(e, authorOfTarget);
|
|
2558
|
+
}
|
|
2559
|
+
};
|
|
2560
|
+
|
|
2416
2561
|
// src/messaging/util.ts
|
|
2417
2562
|
function toBase64(bytes) {
|
|
2418
2563
|
if (typeof Buffer !== "undefined") {
|
|
@@ -2552,6 +2697,28 @@ async function listDevices(rt, userId) {
|
|
|
2552
2697
|
}
|
|
2553
2698
|
|
|
2554
2699
|
// src/messaging/group-messaging.ts
|
|
2700
|
+
function encodeDelete(args) {
|
|
2701
|
+
return encodeUtf8(
|
|
2702
|
+
JSON.stringify({
|
|
2703
|
+
v: 1,
|
|
2704
|
+
type: "delete",
|
|
2705
|
+
client_msg_id: args.clientMsgId,
|
|
2706
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2707
|
+
scope: "everyone"
|
|
2708
|
+
})
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
function encodeEdit(args) {
|
|
2712
|
+
return encodeUtf8(
|
|
2713
|
+
JSON.stringify({
|
|
2714
|
+
v: 1,
|
|
2715
|
+
type: "edit",
|
|
2716
|
+
client_msg_id: args.clientMsgId,
|
|
2717
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2718
|
+
new_text: args.newText
|
|
2719
|
+
})
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2555
2722
|
function encodeReaction(args) {
|
|
2556
2723
|
return encodeUtf8(
|
|
2557
2724
|
JSON.stringify({
|
|
@@ -2578,6 +2745,18 @@ function decodeEnvelope(bytes) {
|
|
|
2578
2745
|
const s = decodeUtf8(bytes);
|
|
2579
2746
|
try {
|
|
2580
2747
|
const o = JSON.parse(s);
|
|
2748
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2749
|
+
return {
|
|
2750
|
+
type: "delete",
|
|
2751
|
+
text: null,
|
|
2752
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2753
|
+
replyTo: null,
|
|
2754
|
+
delete: {
|
|
2755
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2756
|
+
scope: o.scope ?? "everyone"
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2581
2760
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2582
2761
|
return {
|
|
2583
2762
|
type: "reaction",
|
|
@@ -2591,6 +2770,18 @@ function decodeEnvelope(bytes) {
|
|
|
2591
2770
|
}
|
|
2592
2771
|
};
|
|
2593
2772
|
}
|
|
2773
|
+
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2774
|
+
return {
|
|
2775
|
+
type: "edit",
|
|
2776
|
+
text: null,
|
|
2777
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2778
|
+
replyTo: null,
|
|
2779
|
+
edit: {
|
|
2780
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2781
|
+
newText: o.new_text ?? ""
|
|
2782
|
+
}
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2594
2785
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2595
2786
|
return {
|
|
2596
2787
|
type: "text",
|
|
@@ -2905,6 +3096,103 @@ var GroupMessaging = class {
|
|
|
2905
3096
|
clientMsgId: args.clientMsgId
|
|
2906
3097
|
};
|
|
2907
3098
|
}
|
|
3099
|
+
/** Send an edit (edit-by-supersession on a target message). Encrypts a
|
|
3100
|
+
* `type:'edit'` envelope at the current epoch and sends through the SAME MLS
|
|
3101
|
+
* application path as `sendText` (the server stays blind — an edit is just
|
|
3102
|
+
* another application message). Persists the outgoing edit row so it re-folds
|
|
3103
|
+
* onto its target's text after a reload (the own-send half of the reload
|
|
3104
|
+
* parity). NEVER rebases (epoch-bound like any application message). */
|
|
3105
|
+
async sendEdit(group, args) {
|
|
3106
|
+
const plaintext = encodeEdit({
|
|
3107
|
+
clientMsgId: args.clientMsgId,
|
|
3108
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3109
|
+
newText: args.newText
|
|
3110
|
+
});
|
|
3111
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3112
|
+
const body = {
|
|
3113
|
+
ciphertext_b64: toBase64(ct),
|
|
3114
|
+
client_idem_key: randomId()
|
|
3115
|
+
};
|
|
3116
|
+
const wire = await palbeRequest(
|
|
3117
|
+
this.rt,
|
|
3118
|
+
"POST",
|
|
3119
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3120
|
+
{ body }
|
|
3121
|
+
);
|
|
3122
|
+
const stored = {
|
|
3123
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3124
|
+
direction: "outgoing",
|
|
3125
|
+
text: null,
|
|
3126
|
+
senderDeviceId: this.selfDeviceId,
|
|
3127
|
+
epoch: wire.epoch,
|
|
3128
|
+
serverSeq: wire.server_seq,
|
|
3129
|
+
at: Date.now(),
|
|
3130
|
+
clientMsgId: args.clientMsgId,
|
|
3131
|
+
replyTo: null,
|
|
3132
|
+
envelopeType: "edit",
|
|
3133
|
+
edit: {
|
|
3134
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3135
|
+
newText: args.newText
|
|
3136
|
+
}
|
|
3137
|
+
};
|
|
3138
|
+
try {
|
|
3139
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3140
|
+
} catch {
|
|
3141
|
+
}
|
|
3142
|
+
return {
|
|
3143
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3144
|
+
clientMsgId: args.clientMsgId
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3148
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3149
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3150
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3151
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3152
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3153
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3154
|
+
* rebases (epoch-bound like any application message). */
|
|
3155
|
+
async sendDelete(group, args) {
|
|
3156
|
+
const plaintext = encodeDelete({
|
|
3157
|
+
clientMsgId: args.clientMsgId,
|
|
3158
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3159
|
+
});
|
|
3160
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3161
|
+
const body = {
|
|
3162
|
+
ciphertext_b64: toBase64(ct),
|
|
3163
|
+
client_idem_key: randomId()
|
|
3164
|
+
};
|
|
3165
|
+
const wire = await palbeRequest(
|
|
3166
|
+
this.rt,
|
|
3167
|
+
"POST",
|
|
3168
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3169
|
+
{ body }
|
|
3170
|
+
);
|
|
3171
|
+
const stored = {
|
|
3172
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3173
|
+
direction: "outgoing",
|
|
3174
|
+
text: null,
|
|
3175
|
+
senderDeviceId: this.selfDeviceId,
|
|
3176
|
+
epoch: wire.epoch,
|
|
3177
|
+
serverSeq: wire.server_seq,
|
|
3178
|
+
at: Date.now(),
|
|
3179
|
+
clientMsgId: args.clientMsgId,
|
|
3180
|
+
replyTo: null,
|
|
3181
|
+
envelopeType: "delete",
|
|
3182
|
+
delete: {
|
|
3183
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3184
|
+
scope: "everyone"
|
|
3185
|
+
}
|
|
3186
|
+
};
|
|
3187
|
+
try {
|
|
3188
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3189
|
+
} catch {
|
|
3190
|
+
}
|
|
3191
|
+
return {
|
|
3192
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3193
|
+
clientMsgId: args.clientMsgId
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
2908
3196
|
// ── The rebase loop ──
|
|
2909
3197
|
async commitWithRebase(rfcGroupId, build) {
|
|
2910
3198
|
const gidBytes = fromBase64(rfcGroupId);
|
|
@@ -3022,6 +3310,7 @@ var ReactionFold = class {
|
|
|
3022
3310
|
};
|
|
3023
3311
|
|
|
3024
3312
|
// src/messaging/chat.ts
|
|
3313
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3025
3314
|
var Chat = class {
|
|
3026
3315
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3027
3316
|
id;
|
|
@@ -3041,6 +3330,23 @@ var Chat = class {
|
|
|
3041
3330
|
byClientMsgId = /* @__PURE__ */ new Map();
|
|
3042
3331
|
/** The single authoritative reaction fold for this chat (live + own-send + history). */
|
|
3043
3332
|
reactionFold = new ReactionFold();
|
|
3333
|
+
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3334
|
+
editFold = new EditFold();
|
|
3335
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3336
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3337
|
+
deleteFold = new DeleteFold();
|
|
3338
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3339
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3340
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3341
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3342
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3343
|
+
suppressedLoaded = false;
|
|
3344
|
+
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3345
|
+
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3346
|
+
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
3347
|
+
/** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
|
|
3348
|
+
* projection time from senderUserId; '' = resolved-but-unknown peer). */
|
|
3349
|
+
authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
3044
3350
|
loadedEarliestSeq = null;
|
|
3045
3351
|
historyLoaded = false;
|
|
3046
3352
|
wired = false;
|
|
@@ -3083,7 +3389,7 @@ var Chat = class {
|
|
|
3083
3389
|
return this.kind === "direct";
|
|
3084
3390
|
}
|
|
3085
3391
|
get messages() {
|
|
3086
|
-
return this.
|
|
3392
|
+
return this.surfaced();
|
|
3087
3393
|
}
|
|
3088
3394
|
get members() {
|
|
3089
3395
|
return this.memberCache;
|
|
@@ -3092,13 +3398,49 @@ var Chat = class {
|
|
|
3092
3398
|
return this.typingList;
|
|
3093
3399
|
}
|
|
3094
3400
|
get lastMessage() {
|
|
3095
|
-
return this.
|
|
3401
|
+
return this.surfaced().at(-1) ?? null;
|
|
3096
3402
|
}
|
|
3097
3403
|
get unreadCount() {
|
|
3098
|
-
return this.
|
|
3099
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3404
|
+
return this.surfaced().filter(
|
|
3405
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3100
3406
|
).length;
|
|
3101
3407
|
}
|
|
3408
|
+
/**
|
|
3409
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3410
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3411
|
+
* folded edit text + reactions + reply):
|
|
3412
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3413
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3414
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3415
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3416
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3417
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3418
|
+
*/
|
|
3419
|
+
surfaced() {
|
|
3420
|
+
const out = [];
|
|
3421
|
+
for (const m of this.messageList) {
|
|
3422
|
+
const key = this.suppressionKey(m);
|
|
3423
|
+
if (this.suppressed.has(key)) continue;
|
|
3424
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3425
|
+
if (tombstoned) {
|
|
3426
|
+
out.push({
|
|
3427
|
+
...m,
|
|
3428
|
+
text: DELETED_DESCRIPTOR,
|
|
3429
|
+
reactions: {},
|
|
3430
|
+
replyTo: null,
|
|
3431
|
+
edited: false,
|
|
3432
|
+
isDeleted: true
|
|
3433
|
+
});
|
|
3434
|
+
continue;
|
|
3435
|
+
}
|
|
3436
|
+
out.push(m);
|
|
3437
|
+
}
|
|
3438
|
+
return out;
|
|
3439
|
+
}
|
|
3440
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3441
|
+
suppressionKey(m) {
|
|
3442
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3443
|
+
}
|
|
3102
3444
|
get title() {
|
|
3103
3445
|
if (this.titleOverride) return this.titleOverride;
|
|
3104
3446
|
if (this._group?.name) return this._group.name;
|
|
@@ -3122,9 +3464,28 @@ var Chat = class {
|
|
|
3122
3464
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3123
3465
|
this.wired = true;
|
|
3124
3466
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3467
|
+
void this.loadSuppressed();
|
|
3125
3468
|
void this.hydrateHistory();
|
|
3126
3469
|
void this.refreshMembers();
|
|
3127
3470
|
}
|
|
3471
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3472
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3473
|
+
async loadSuppressed() {
|
|
3474
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3475
|
+
this.suppressedLoaded = true;
|
|
3476
|
+
try {
|
|
3477
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3478
|
+
let changed = false;
|
|
3479
|
+
for (const k of keys) {
|
|
3480
|
+
if (!this.suppressed.has(k)) {
|
|
3481
|
+
this.suppressed.add(k);
|
|
3482
|
+
changed = true;
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
if (changed) this.emit();
|
|
3486
|
+
} catch {
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3128
3489
|
async hydrateHistory() {
|
|
3129
3490
|
if (this.historyLoaded || !this._group) return;
|
|
3130
3491
|
this.historyLoaded = true;
|
|
@@ -3135,19 +3496,26 @@ var Chat = class {
|
|
|
3135
3496
|
let changed = false;
|
|
3136
3497
|
for (const m of incoming) {
|
|
3137
3498
|
if (m.serverSeq <= 0) continue;
|
|
3138
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3499
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3139
3500
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3140
3501
|
text: m.text,
|
|
3141
3502
|
senderUserId: m.senderUserId ?? ""
|
|
3142
3503
|
});
|
|
3143
3504
|
}
|
|
3505
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3506
|
+
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3507
|
+
}
|
|
3144
3508
|
}
|
|
3509
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3145
3510
|
for (const m of incoming) {
|
|
3146
3511
|
if (m.serverSeq <= 0) continue;
|
|
3147
3512
|
const key = this.internalKey(m.serverSeq);
|
|
3148
3513
|
if (this.seenKeys.has(key)) continue;
|
|
3149
3514
|
this.seenKeys.add(key);
|
|
3150
|
-
|
|
3515
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3516
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3517
|
+
}
|
|
3518
|
+
this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
|
|
3151
3519
|
changed = true;
|
|
3152
3520
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3153
3521
|
}
|
|
@@ -3188,6 +3556,37 @@ var Chat = class {
|
|
|
3188
3556
|
}
|
|
3189
3557
|
return;
|
|
3190
3558
|
}
|
|
3559
|
+
if (incoming.envelopeType === "edit" && incoming.edit) {
|
|
3560
|
+
const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3561
|
+
this.editFold.ingest(
|
|
3562
|
+
{
|
|
3563
|
+
targetClientMsgId: incoming.edit.targetClientMsgId,
|
|
3564
|
+
editorUserId,
|
|
3565
|
+
newText: incoming.edit.newText,
|
|
3566
|
+
epoch: incoming.epoch,
|
|
3567
|
+
serverSeq: incoming.serverSeq,
|
|
3568
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3569
|
+
},
|
|
3570
|
+
this.authorOfTarget
|
|
3571
|
+
);
|
|
3572
|
+
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3576
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3577
|
+
this.deleteFold.ingest(
|
|
3578
|
+
{
|
|
3579
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3580
|
+
actorUserId,
|
|
3581
|
+
epoch: incoming.epoch,
|
|
3582
|
+
serverSeq: incoming.serverSeq,
|
|
3583
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3584
|
+
},
|
|
3585
|
+
this.authorOfTarget
|
|
3586
|
+
);
|
|
3587
|
+
this.emit();
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3191
3590
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3192
3591
|
const incomingReplyRef = incoming.replyRef;
|
|
3193
3592
|
let resolvedReplyTo = null;
|
|
@@ -3206,7 +3605,11 @@ var Chat = class {
|
|
|
3206
3605
|
replyTo: resolvedReplyTo,
|
|
3207
3606
|
// Attach any tally already folded for this message (a reaction that arrived
|
|
3208
3607
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3209
|
-
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
|
|
3608
|
+
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3609
|
+
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3610
|
+
edited: false,
|
|
3611
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3612
|
+
isDeleted: false
|
|
3210
3613
|
};
|
|
3211
3614
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3212
3615
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
@@ -3214,7 +3617,12 @@ var Chat = class {
|
|
|
3214
3617
|
senderUserId: senderUser ?? ""
|
|
3215
3618
|
});
|
|
3216
3619
|
}
|
|
3217
|
-
|
|
3620
|
+
if (incomingClientMsgId) {
|
|
3621
|
+
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3622
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3623
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3624
|
+
}
|
|
3625
|
+
this.messageList.push(this.applyEditOverlay(msg));
|
|
3218
3626
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3219
3627
|
this.loadedEarliestSeq = Math.min(
|
|
3220
3628
|
this.loadedEarliestSeq ?? incoming.serverSeq,
|
|
@@ -3222,6 +3630,19 @@ var Chat = class {
|
|
|
3222
3630
|
);
|
|
3223
3631
|
this.emit();
|
|
3224
3632
|
}
|
|
3633
|
+
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3634
|
+
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3635
|
+
* so it can be passed to the pure EditFold. */
|
|
3636
|
+
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3637
|
+
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3638
|
+
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3639
|
+
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
3640
|
+
seedEditBase(clientMsgId, text, author) {
|
|
3641
|
+
if (!this.originalTextByClientMsgId.has(clientMsgId)) {
|
|
3642
|
+
this.originalTextByClientMsgId.set(clientMsgId, text);
|
|
3643
|
+
}
|
|
3644
|
+
if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
|
|
3645
|
+
}
|
|
3225
3646
|
/**
|
|
3226
3647
|
* Rebuild the target message's `reactions` from the authoritative fold and
|
|
3227
3648
|
* re-emit. No-op when the target isn't present yet (its tally is attached the
|
|
@@ -3251,6 +3672,46 @@ var Chat = class {
|
|
|
3251
3672
|
if (sameReactions(m.reactions, tally)) return m;
|
|
3252
3673
|
return { ...m, reactions: tally };
|
|
3253
3674
|
}
|
|
3675
|
+
/**
|
|
3676
|
+
* Rebuild the target message's rendered `text` + `edited` flag from the
|
|
3677
|
+
* authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
|
|
3678
|
+
* (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
|
|
3679
|
+
* base`; base is the seeded original so a forged/ignored edit leaves it intact.
|
|
3680
|
+
* No-op when the target isn't present yet (the fold already recorded it; the
|
|
3681
|
+
* overlay applies the moment the target lands) or when unchanged.
|
|
3682
|
+
*/
|
|
3683
|
+
recomputeEdit(targetClientMsgId) {
|
|
3684
|
+
if (!targetClientMsgId) return;
|
|
3685
|
+
const editText = this.editFold.text(targetClientMsgId);
|
|
3686
|
+
const foldEdited = this.editFold.isEdited(targetClientMsgId);
|
|
3687
|
+
let changed = false;
|
|
3688
|
+
this.messageList = this.messageList.map((m) => {
|
|
3689
|
+
if (m.clientMsgId !== targetClientMsgId) return m;
|
|
3690
|
+
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3691
|
+
const text = editText ?? base;
|
|
3692
|
+
const edited = foldEdited || m.edited;
|
|
3693
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3694
|
+
changed = true;
|
|
3695
|
+
return { ...m, text, edited };
|
|
3696
|
+
});
|
|
3697
|
+
if (changed) this.emit();
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Overlay the authoritative edit fold's winning text + flag onto a message as it
|
|
3701
|
+
* is appended/merged. The fold WINS when it has an edit for this target;
|
|
3702
|
+
* otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
|
|
3703
|
+
* history fold) is preserved. PRESERVES reactions + replyTo.
|
|
3704
|
+
*/
|
|
3705
|
+
applyEditOverlay(m) {
|
|
3706
|
+
if (!m.clientMsgId) return m;
|
|
3707
|
+
const editText = this.editFold.text(m.clientMsgId);
|
|
3708
|
+
const foldEdited = this.editFold.isEdited(m.clientMsgId);
|
|
3709
|
+
if (editText === null && !foldEdited) return m;
|
|
3710
|
+
const text = editText ?? m.text;
|
|
3711
|
+
const edited = foldEdited || m.edited;
|
|
3712
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3713
|
+
return { ...m, text, edited };
|
|
3714
|
+
}
|
|
3254
3715
|
/** @internal — called by the backend's conv subscription. */
|
|
3255
3716
|
applyConv(event, payload) {
|
|
3256
3717
|
const userId = typeof payload.user_id === "string" ? payload.user_id : null;
|
|
@@ -3305,6 +3766,8 @@ var Chat = class {
|
|
|
3305
3766
|
this.memberCache = m;
|
|
3306
3767
|
this.emit();
|
|
3307
3768
|
}
|
|
3769
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3770
|
+
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3308
3771
|
}
|
|
3309
3772
|
seedMembersFromGroup(group) {
|
|
3310
3773
|
const seed = [
|
|
@@ -3383,6 +3846,7 @@ var Chat = class {
|
|
|
3383
3846
|
this.seenKeys.add(key);
|
|
3384
3847
|
if (clientMsgId) {
|
|
3385
3848
|
this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
|
|
3849
|
+
this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
|
|
3386
3850
|
}
|
|
3387
3851
|
this.messageList.push({
|
|
3388
3852
|
id: this.publicId(receipt.serverSeq),
|
|
@@ -3396,7 +3860,11 @@ var Chat = class {
|
|
|
3396
3860
|
replyTo: resolvedReplyTo,
|
|
3397
3861
|
// Attach any tally already folded for this own-sent message (rare, but keeps
|
|
3398
3862
|
// the dangling-target invariant uniform across every append path).
|
|
3399
|
-
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
|
|
3863
|
+
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
3864
|
+
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3865
|
+
edited: false,
|
|
3866
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3867
|
+
isDeleted: false
|
|
3400
3868
|
});
|
|
3401
3869
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3402
3870
|
this.emit();
|
|
@@ -3477,6 +3945,83 @@ var Chat = class {
|
|
|
3477
3945
|
});
|
|
3478
3946
|
this.recomputeReactions(message.clientMsgId);
|
|
3479
3947
|
}
|
|
3948
|
+
// ── Edit ──
|
|
3949
|
+
/** Edit an own text message (edit-by-supersession). No-op if the message isn't
|
|
3950
|
+
* editable (empty clientMsgId, or not a `text` kind). The edit folds locally
|
|
3951
|
+
* with the server receipt's `(epoch, serverSeq)` so the target's text updates
|
|
3952
|
+
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3953
|
+
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3954
|
+
* reactions + reply context. Only the original author's edits count — for an own
|
|
3955
|
+
* message self IS the author, so the author-gate passes. */
|
|
3956
|
+
async edit(message, newText) {
|
|
3957
|
+
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3958
|
+
const group = await this.materializeIfNeeded();
|
|
3959
|
+
const clientMsgId = mintClientMsgId();
|
|
3960
|
+
const { receipt } = await this.backend.sendEdit(group, {
|
|
3961
|
+
clientMsgId,
|
|
3962
|
+
targetClientMsgId: message.clientMsgId,
|
|
3963
|
+
newText
|
|
3964
|
+
});
|
|
3965
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3966
|
+
this.editFold.ingest(
|
|
3967
|
+
{
|
|
3968
|
+
targetClientMsgId: message.clientMsgId,
|
|
3969
|
+
editorUserId: this.backend.selfUserId,
|
|
3970
|
+
newText,
|
|
3971
|
+
epoch: receipt.epoch,
|
|
3972
|
+
serverSeq: receipt.serverSeq,
|
|
3973
|
+
eventClientMsgId: clientMsgId
|
|
3974
|
+
},
|
|
3975
|
+
this.authorOfTarget
|
|
3976
|
+
);
|
|
3977
|
+
this.recomputeEdit(message.clientMsgId);
|
|
3978
|
+
}
|
|
3979
|
+
// ── Delete ──
|
|
3980
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
3981
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
3982
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
3983
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
3984
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
3985
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
3986
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
3987
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
3988
|
+
async deleteForEveryone(message) {
|
|
3989
|
+
if (!message.clientMsgId) return;
|
|
3990
|
+
const group = await this.materializeIfNeeded();
|
|
3991
|
+
const clientMsgId = mintClientMsgId();
|
|
3992
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
3993
|
+
clientMsgId,
|
|
3994
|
+
targetClientMsgId: message.clientMsgId
|
|
3995
|
+
});
|
|
3996
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3997
|
+
this.deleteFold.ingest(
|
|
3998
|
+
{
|
|
3999
|
+
targetClientMsgId: message.clientMsgId,
|
|
4000
|
+
actorUserId: this.backend.selfUserId,
|
|
4001
|
+
epoch: receipt.epoch,
|
|
4002
|
+
serverSeq: receipt.serverSeq,
|
|
4003
|
+
eventClientMsgId: clientMsgId
|
|
4004
|
+
},
|
|
4005
|
+
this.authorOfTarget
|
|
4006
|
+
);
|
|
4007
|
+
this.emit();
|
|
4008
|
+
}
|
|
4009
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4010
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4011
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4012
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4013
|
+
async deleteForMe(message) {
|
|
4014
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4015
|
+
if (this.suppressed.has(key)) return;
|
|
4016
|
+
this.suppressed.add(key);
|
|
4017
|
+
this.emit();
|
|
4018
|
+
if (this._group) {
|
|
4019
|
+
try {
|
|
4020
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4021
|
+
} catch {
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
3480
4025
|
};
|
|
3481
4026
|
function sameReactions(a, b) {
|
|
3482
4027
|
const ak = Object.keys(a);
|
|
@@ -3656,6 +4201,8 @@ var MessageDeliverySource = class {
|
|
|
3656
4201
|
const decoded = decodeEnvelope(received.data);
|
|
3657
4202
|
const { text, clientMsgId, replyTo } = decoded;
|
|
3658
4203
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4204
|
+
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4205
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
3659
4206
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3660
4207
|
const stored = {
|
|
3661
4208
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3683,6 +4230,29 @@ var MessageDeliverySource = class {
|
|
|
3683
4230
|
emoji: decoded.reaction.emoji,
|
|
3684
4231
|
op: decoded.reaction.op
|
|
3685
4232
|
}
|
|
4233
|
+
} : {},
|
|
4234
|
+
// Thread the edit discriminator + new text through the persisted row so an
|
|
4235
|
+
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4236
|
+
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4237
|
+
// `'text'`/no-edit (backward-compat).
|
|
4238
|
+
...isEdit && decoded.edit ? {
|
|
4239
|
+
envelopeType: "edit",
|
|
4240
|
+
edit: {
|
|
4241
|
+
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4242
|
+
newText: decoded.edit.newText
|
|
4243
|
+
}
|
|
4244
|
+
} : {},
|
|
4245
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4246
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4247
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4248
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4249
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4250
|
+
...isDelete && decoded.delete ? {
|
|
4251
|
+
envelopeType: "delete",
|
|
4252
|
+
delete: {
|
|
4253
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4254
|
+
scope: decoded.delete.scope
|
|
4255
|
+
}
|
|
3686
4256
|
} : {}
|
|
3687
4257
|
};
|
|
3688
4258
|
try {
|
|
@@ -3701,7 +4271,9 @@ var MessageDeliverySource = class {
|
|
|
3701
4271
|
clientMsgId,
|
|
3702
4272
|
replyRef: replyTo,
|
|
3703
4273
|
envelopeType: decoded.type ?? "text",
|
|
3704
|
-
reaction: isReaction ? decoded.reaction : null
|
|
4274
|
+
reaction: isReaction ? decoded.reaction : null,
|
|
4275
|
+
edit: isEdit ? decoded.edit : null,
|
|
4276
|
+
delete: isDelete ? decoded.delete : null
|
|
3705
4277
|
});
|
|
3706
4278
|
return true;
|
|
3707
4279
|
}
|
|
@@ -5523,6 +6095,36 @@ var SignatureKeyStore = class {
|
|
|
5523
6095
|
}
|
|
5524
6096
|
};
|
|
5525
6097
|
|
|
6098
|
+
// src/messaging/suppression.ts
|
|
6099
|
+
var SuppressionStore = class {
|
|
6100
|
+
constructor(kv) {
|
|
6101
|
+
this.kv = kv;
|
|
6102
|
+
}
|
|
6103
|
+
kv;
|
|
6104
|
+
key(rfcGroupId) {
|
|
6105
|
+
return `supp:${rfcGroupId}`;
|
|
6106
|
+
}
|
|
6107
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6108
|
+
async load(rfcGroupId) {
|
|
6109
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6110
|
+
if (!raw) return [];
|
|
6111
|
+
try {
|
|
6112
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6113
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6114
|
+
} catch {
|
|
6115
|
+
return [];
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
6118
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6119
|
+
async save(rfcGroupId, keys) {
|
|
6120
|
+
const sorted = [...new Set(keys)].sort();
|
|
6121
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6122
|
+
}
|
|
6123
|
+
async wipe() {
|
|
6124
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6125
|
+
}
|
|
6126
|
+
};
|
|
6127
|
+
|
|
5526
6128
|
// src/messaging/coordinator.ts
|
|
5527
6129
|
var MessagingCoordinator = class {
|
|
5528
6130
|
constructor(rt) {
|
|
@@ -5532,6 +6134,7 @@ var MessagingCoordinator = class {
|
|
|
5532
6134
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
5533
6135
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5534
6136
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6137
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5535
6138
|
this.registry.attachChatList(
|
|
5536
6139
|
(chats) => {
|
|
5537
6140
|
this.chatList = chats;
|
|
@@ -5546,6 +6149,7 @@ var MessagingCoordinator = class {
|
|
|
5546
6149
|
sigStore;
|
|
5547
6150
|
groupStore;
|
|
5548
6151
|
kpStore;
|
|
6152
|
+
suppressionStore;
|
|
5549
6153
|
registry = new GroupRegistry();
|
|
5550
6154
|
resolved = null;
|
|
5551
6155
|
resolvePromise = null;
|
|
@@ -5709,6 +6313,22 @@ var MessagingCoordinator = class {
|
|
|
5709
6313
|
const r = await this.resolve();
|
|
5710
6314
|
return r.groups.sendReaction(group, args);
|
|
5711
6315
|
}
|
|
6316
|
+
async sendEdit(group, args) {
|
|
6317
|
+
const r = await this.resolve();
|
|
6318
|
+
return r.groups.sendEdit(group, args);
|
|
6319
|
+
}
|
|
6320
|
+
async sendDelete(group, args) {
|
|
6321
|
+
const r = await this.resolve();
|
|
6322
|
+
return r.groups.sendDelete(group, args);
|
|
6323
|
+
}
|
|
6324
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6325
|
+
loadSuppressed(group) {
|
|
6326
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6327
|
+
}
|
|
6328
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6329
|
+
saveSuppressed(group, keys) {
|
|
6330
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6331
|
+
}
|
|
5712
6332
|
async history(group, limit, before) {
|
|
5713
6333
|
const r = await this.resolve();
|
|
5714
6334
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -5805,9 +6425,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5805
6425
|
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
5806
6426
|
});
|
|
5807
6427
|
}
|
|
6428
|
+
const editFold = new EditFold();
|
|
6429
|
+
const deleteFold = new DeleteFold();
|
|
6430
|
+
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6431
|
+
for (const s of rows) {
|
|
6432
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6433
|
+
continue;
|
|
6434
|
+
const cid = s.clientMsgId ?? "";
|
|
6435
|
+
if (!cid) continue;
|
|
6436
|
+
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6437
|
+
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6438
|
+
}
|
|
6439
|
+
const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
|
|
6440
|
+
for (const s of rows) {
|
|
6441
|
+
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6442
|
+
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6443
|
+
editFold.ingest(
|
|
6444
|
+
{
|
|
6445
|
+
targetClientMsgId: s.edit.targetClientMsgId,
|
|
6446
|
+
editorUserId: editor,
|
|
6447
|
+
newText: s.edit.newText,
|
|
6448
|
+
epoch: s.epoch,
|
|
6449
|
+
serverSeq: s.serverSeq,
|
|
6450
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6451
|
+
},
|
|
6452
|
+
authorOfTarget
|
|
6453
|
+
);
|
|
6454
|
+
}
|
|
6455
|
+
editFold.reevaluateHeld(authorOfTarget);
|
|
6456
|
+
for (const s of rows) {
|
|
6457
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6458
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6459
|
+
deleteFold.ingest(
|
|
6460
|
+
{
|
|
6461
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6462
|
+
actorUserId: actor,
|
|
6463
|
+
epoch: s.epoch,
|
|
6464
|
+
serverSeq: s.serverSeq,
|
|
6465
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6466
|
+
},
|
|
6467
|
+
authorOfTarget
|
|
6468
|
+
);
|
|
6469
|
+
}
|
|
6470
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
5808
6471
|
const lookup = /* @__PURE__ */ new Map();
|
|
5809
6472
|
for (const s of rows) {
|
|
5810
|
-
if (s.envelopeType === "reaction")
|
|
6473
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6474
|
+
continue;
|
|
5811
6475
|
const cid = s.clientMsgId ?? "";
|
|
5812
6476
|
if (cid && s.text !== null) {
|
|
5813
6477
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -5816,8 +6480,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5816
6480
|
}
|
|
5817
6481
|
const out = [];
|
|
5818
6482
|
for (const s of rows) {
|
|
5819
|
-
if (s.envelopeType === "reaction")
|
|
6483
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6484
|
+
continue;
|
|
5820
6485
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6486
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6487
|
+
if (isDeleted) {
|
|
6488
|
+
out.push({
|
|
6489
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6490
|
+
kind: "text",
|
|
6491
|
+
direction: s.direction,
|
|
6492
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6493
|
+
text: DELETED_DESCRIPTOR,
|
|
6494
|
+
serverSeq: s.serverSeq,
|
|
6495
|
+
sentAt: new Date(s.at),
|
|
6496
|
+
clientMsgId,
|
|
6497
|
+
replyTo: null,
|
|
6498
|
+
reactions: {},
|
|
6499
|
+
edited: false,
|
|
6500
|
+
isDeleted: true
|
|
6501
|
+
});
|
|
6502
|
+
continue;
|
|
6503
|
+
}
|
|
5821
6504
|
let replyTo = null;
|
|
5822
6505
|
if (s.replyTo) {
|
|
5823
6506
|
const ref = {
|
|
@@ -5832,17 +6515,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5832
6515
|
};
|
|
5833
6516
|
replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
|
|
5834
6517
|
}
|
|
6518
|
+
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6519
|
+
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
5835
6520
|
out.push({
|
|
5836
6521
|
id: `${displayId}#${s.serverSeq}`,
|
|
5837
6522
|
kind: s.text != null ? "text" : "system",
|
|
5838
6523
|
direction: s.direction,
|
|
5839
6524
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
5840
|
-
text: s.text,
|
|
6525
|
+
text: editText ?? s.text,
|
|
5841
6526
|
serverSeq: s.serverSeq,
|
|
5842
6527
|
sentAt: new Date(s.at),
|
|
5843
6528
|
clientMsgId,
|
|
5844
6529
|
replyTo,
|
|
5845
|
-
reactions: clientMsgId ? fold.tally(clientMsgId) : {}
|
|
6530
|
+
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6531
|
+
edited,
|
|
6532
|
+
isDeleted: false
|
|
5846
6533
|
});
|
|
5847
6534
|
}
|
|
5848
6535
|
return out;
|
|
@@ -6580,7 +7267,7 @@ function defaultSessionStorage(key) {
|
|
|
6580
7267
|
}
|
|
6581
7268
|
|
|
6582
7269
|
// src/version.ts
|
|
6583
|
-
var VERSION = "1.
|
|
7270
|
+
var VERSION = "1.4.0";
|
|
6584
7271
|
|
|
6585
7272
|
// src/runtime.ts
|
|
6586
7273
|
function buildRuntime(config) {
|