@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/internal.cjs
CHANGED
|
@@ -2612,6 +2612,151 @@ var PalbeFlags = class {
|
|
|
2612
2612
|
}
|
|
2613
2613
|
};
|
|
2614
2614
|
|
|
2615
|
+
// src/messaging/delete-fold.ts
|
|
2616
|
+
var DeleteFold = class {
|
|
2617
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2618
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2619
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2620
|
+
pending = /* @__PURE__ */ new Map();
|
|
2621
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2622
|
+
seen = /* @__PURE__ */ new Set();
|
|
2623
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2624
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2625
|
+
held = [];
|
|
2626
|
+
/**
|
|
2627
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2628
|
+
* userId (null = target absent locally → defer).
|
|
2629
|
+
*/
|
|
2630
|
+
ingest(e, authorOfTarget) {
|
|
2631
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2632
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2633
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2634
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2635
|
+
if (author !== null) {
|
|
2636
|
+
this.seen.add(e.eventClientMsgId);
|
|
2637
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2638
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2639
|
+
} else if (e.actorUserId !== null) {
|
|
2640
|
+
this.seen.add(e.eventClientMsgId);
|
|
2641
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2642
|
+
} else {
|
|
2643
|
+
this.held.push(e);
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2647
|
+
isTombstoned(targetClientMsgId) {
|
|
2648
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2652
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2653
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2654
|
+
* the in-order path.
|
|
2655
|
+
*/
|
|
2656
|
+
reevaluatePending(target, author) {
|
|
2657
|
+
const actor = this.pending.get(target);
|
|
2658
|
+
if (actor !== void 0) {
|
|
2659
|
+
if (author !== null && actor === author) {
|
|
2660
|
+
this.tombstoned.add(target);
|
|
2661
|
+
this.pending.delete(target);
|
|
2662
|
+
} else if (author !== null) {
|
|
2663
|
+
this.pending.delete(target);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
if (this.held.length === 0) return;
|
|
2667
|
+
const pendingHeld = this.held;
|
|
2668
|
+
this.held = [];
|
|
2669
|
+
for (const e of pendingHeld) {
|
|
2670
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
heldContains(eventClientMsgId) {
|
|
2674
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
|
|
2678
|
+
// src/messaging/edit-fold.ts
|
|
2679
|
+
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2680
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
2681
|
+
return aSeq < bSeq;
|
|
2682
|
+
}
|
|
2683
|
+
function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2684
|
+
return aEpoch === bEpoch && aSeq === bSeq;
|
|
2685
|
+
}
|
|
2686
|
+
var EditFold = class {
|
|
2687
|
+
// target → winning edit state
|
|
2688
|
+
states = /* @__PURE__ */ new Map();
|
|
2689
|
+
// dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
|
|
2690
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
2691
|
+
// events parked because target/author or sender was unresolved at ingest time
|
|
2692
|
+
held = [];
|
|
2693
|
+
// targets that have had ≥1 valid edit applied (write-once)
|
|
2694
|
+
editedTargets = /* @__PURE__ */ new Set();
|
|
2695
|
+
/**
|
|
2696
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
|
|
2697
|
+
* (null = target unknown/dangling → HOLD).
|
|
2698
|
+
*/
|
|
2699
|
+
ingest(e, authorOfTarget) {
|
|
2700
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2701
|
+
if (author === null) {
|
|
2702
|
+
this.holdIfNew(e);
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
if (e.editorUserId === null) {
|
|
2706
|
+
this.holdIfNew(e);
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
if (e.editorUserId !== author) return;
|
|
2710
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
2711
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2712
|
+
const prev = this.states.get(e.targetClientMsgId);
|
|
2713
|
+
if (prev !== void 0) {
|
|
2714
|
+
if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
|
|
2715
|
+
if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
this.states.set(e.targetClientMsgId, {
|
|
2720
|
+
orderEpoch: e.epoch,
|
|
2721
|
+
orderSeq: e.serverSeq,
|
|
2722
|
+
lastEventId: e.eventClientMsgId,
|
|
2723
|
+
text: e.newText
|
|
2724
|
+
});
|
|
2725
|
+
this.editedTargets.add(e.targetClientMsgId);
|
|
2726
|
+
}
|
|
2727
|
+
/**
|
|
2728
|
+
* Park an event for later re-attempt, deduping held re-deliveries by
|
|
2729
|
+
* eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
|
|
2730
|
+
* once (and never double-applies when it finally resolves on reevaluate).
|
|
2731
|
+
*/
|
|
2732
|
+
holdIfNew(e) {
|
|
2733
|
+
if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
|
|
2734
|
+
this.held.push(e);
|
|
2735
|
+
}
|
|
2736
|
+
/** The winning edit text for a target, or null if no valid edit has applied. */
|
|
2737
|
+
text(targetClientMsgId) {
|
|
2738
|
+
return this.states.get(targetClientMsgId)?.text ?? null;
|
|
2739
|
+
}
|
|
2740
|
+
/** Write-once: true once any valid edit applied to the target. */
|
|
2741
|
+
isEdited(targetClientMsgId) {
|
|
2742
|
+
return this.editedTargets.has(targetClientMsgId);
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2746
|
+
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
2747
|
+
* event with the fresh `authorOfTarget` — events that still don't resolve are
|
|
2748
|
+
* simply re-held; events that now resolve fold via the normal LWW path.
|
|
2749
|
+
* Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
|
|
2750
|
+
* `holdIfNew` (still-held events), so reevaluating repeatedly can neither
|
|
2751
|
+
* double-apply nor lose an edit.
|
|
2752
|
+
*/
|
|
2753
|
+
reevaluateHeld(authorOfTarget) {
|
|
2754
|
+
const pending = this.held;
|
|
2755
|
+
this.held = [];
|
|
2756
|
+
for (const e of pending) this.ingest(e, authorOfTarget);
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
|
|
2615
2760
|
// src/messaging/util.ts
|
|
2616
2761
|
function toBase64(bytes) {
|
|
2617
2762
|
if (typeof Buffer !== "undefined") {
|
|
@@ -2751,6 +2896,28 @@ async function listDevices(rt, userId) {
|
|
|
2751
2896
|
}
|
|
2752
2897
|
|
|
2753
2898
|
// src/messaging/group-messaging.ts
|
|
2899
|
+
function encodeDelete(args) {
|
|
2900
|
+
return encodeUtf8(
|
|
2901
|
+
JSON.stringify({
|
|
2902
|
+
v: 1,
|
|
2903
|
+
type: "delete",
|
|
2904
|
+
client_msg_id: args.clientMsgId,
|
|
2905
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2906
|
+
scope: "everyone"
|
|
2907
|
+
})
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
function encodeEdit(args) {
|
|
2911
|
+
return encodeUtf8(
|
|
2912
|
+
JSON.stringify({
|
|
2913
|
+
v: 1,
|
|
2914
|
+
type: "edit",
|
|
2915
|
+
client_msg_id: args.clientMsgId,
|
|
2916
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2917
|
+
new_text: args.newText
|
|
2918
|
+
})
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2754
2921
|
function encodeReaction(args) {
|
|
2755
2922
|
return encodeUtf8(
|
|
2756
2923
|
JSON.stringify({
|
|
@@ -2777,6 +2944,18 @@ function decodeEnvelope(bytes) {
|
|
|
2777
2944
|
const s = decodeUtf8(bytes);
|
|
2778
2945
|
try {
|
|
2779
2946
|
const o = JSON.parse(s);
|
|
2947
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2948
|
+
return {
|
|
2949
|
+
type: "delete",
|
|
2950
|
+
text: null,
|
|
2951
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2952
|
+
replyTo: null,
|
|
2953
|
+
delete: {
|
|
2954
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2955
|
+
scope: o.scope ?? "everyone"
|
|
2956
|
+
}
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2780
2959
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2781
2960
|
return {
|
|
2782
2961
|
type: "reaction",
|
|
@@ -2790,6 +2969,18 @@ function decodeEnvelope(bytes) {
|
|
|
2790
2969
|
}
|
|
2791
2970
|
};
|
|
2792
2971
|
}
|
|
2972
|
+
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2973
|
+
return {
|
|
2974
|
+
type: "edit",
|
|
2975
|
+
text: null,
|
|
2976
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2977
|
+
replyTo: null,
|
|
2978
|
+
edit: {
|
|
2979
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2980
|
+
newText: o.new_text ?? ""
|
|
2981
|
+
}
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2793
2984
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2794
2985
|
return {
|
|
2795
2986
|
type: "text",
|
|
@@ -3104,6 +3295,103 @@ var GroupMessaging = class {
|
|
|
3104
3295
|
clientMsgId: args.clientMsgId
|
|
3105
3296
|
};
|
|
3106
3297
|
}
|
|
3298
|
+
/** Send an edit (edit-by-supersession on a target message). Encrypts a
|
|
3299
|
+
* `type:'edit'` envelope at the current epoch and sends through the SAME MLS
|
|
3300
|
+
* application path as `sendText` (the server stays blind — an edit is just
|
|
3301
|
+
* another application message). Persists the outgoing edit row so it re-folds
|
|
3302
|
+
* onto its target's text after a reload (the own-send half of the reload
|
|
3303
|
+
* parity). NEVER rebases (epoch-bound like any application message). */
|
|
3304
|
+
async sendEdit(group, args) {
|
|
3305
|
+
const plaintext = encodeEdit({
|
|
3306
|
+
clientMsgId: args.clientMsgId,
|
|
3307
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3308
|
+
newText: args.newText
|
|
3309
|
+
});
|
|
3310
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3311
|
+
const body = {
|
|
3312
|
+
ciphertext_b64: toBase64(ct),
|
|
3313
|
+
client_idem_key: randomId()
|
|
3314
|
+
};
|
|
3315
|
+
const wire = await palbeRequest(
|
|
3316
|
+
this.rt,
|
|
3317
|
+
"POST",
|
|
3318
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3319
|
+
{ body }
|
|
3320
|
+
);
|
|
3321
|
+
const stored = {
|
|
3322
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3323
|
+
direction: "outgoing",
|
|
3324
|
+
text: null,
|
|
3325
|
+
senderDeviceId: this.selfDeviceId,
|
|
3326
|
+
epoch: wire.epoch,
|
|
3327
|
+
serverSeq: wire.server_seq,
|
|
3328
|
+
at: Date.now(),
|
|
3329
|
+
clientMsgId: args.clientMsgId,
|
|
3330
|
+
replyTo: null,
|
|
3331
|
+
envelopeType: "edit",
|
|
3332
|
+
edit: {
|
|
3333
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3334
|
+
newText: args.newText
|
|
3335
|
+
}
|
|
3336
|
+
};
|
|
3337
|
+
try {
|
|
3338
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3339
|
+
} catch {
|
|
3340
|
+
}
|
|
3341
|
+
return {
|
|
3342
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3343
|
+
clientMsgId: args.clientMsgId
|
|
3344
|
+
};
|
|
3345
|
+
}
|
|
3346
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3347
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3348
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3349
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3350
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3351
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3352
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3353
|
+
* rebases (epoch-bound like any application message). */
|
|
3354
|
+
async sendDelete(group, args) {
|
|
3355
|
+
const plaintext = encodeDelete({
|
|
3356
|
+
clientMsgId: args.clientMsgId,
|
|
3357
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3358
|
+
});
|
|
3359
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3360
|
+
const body = {
|
|
3361
|
+
ciphertext_b64: toBase64(ct),
|
|
3362
|
+
client_idem_key: randomId()
|
|
3363
|
+
};
|
|
3364
|
+
const wire = await palbeRequest(
|
|
3365
|
+
this.rt,
|
|
3366
|
+
"POST",
|
|
3367
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3368
|
+
{ body }
|
|
3369
|
+
);
|
|
3370
|
+
const stored = {
|
|
3371
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3372
|
+
direction: "outgoing",
|
|
3373
|
+
text: null,
|
|
3374
|
+
senderDeviceId: this.selfDeviceId,
|
|
3375
|
+
epoch: wire.epoch,
|
|
3376
|
+
serverSeq: wire.server_seq,
|
|
3377
|
+
at: Date.now(),
|
|
3378
|
+
clientMsgId: args.clientMsgId,
|
|
3379
|
+
replyTo: null,
|
|
3380
|
+
envelopeType: "delete",
|
|
3381
|
+
delete: {
|
|
3382
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3383
|
+
scope: "everyone"
|
|
3384
|
+
}
|
|
3385
|
+
};
|
|
3386
|
+
try {
|
|
3387
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3388
|
+
} catch {
|
|
3389
|
+
}
|
|
3390
|
+
return {
|
|
3391
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3392
|
+
clientMsgId: args.clientMsgId
|
|
3393
|
+
};
|
|
3394
|
+
}
|
|
3107
3395
|
// ── The rebase loop ──
|
|
3108
3396
|
async commitWithRebase(rfcGroupId, build) {
|
|
3109
3397
|
const gidBytes = fromBase64(rfcGroupId);
|
|
@@ -3221,6 +3509,7 @@ var ReactionFold = class {
|
|
|
3221
3509
|
};
|
|
3222
3510
|
|
|
3223
3511
|
// src/messaging/chat.ts
|
|
3512
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3224
3513
|
var Chat = class {
|
|
3225
3514
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3226
3515
|
id;
|
|
@@ -3240,6 +3529,23 @@ var Chat = class {
|
|
|
3240
3529
|
byClientMsgId = /* @__PURE__ */ new Map();
|
|
3241
3530
|
/** The single authoritative reaction fold for this chat (live + own-send + history). */
|
|
3242
3531
|
reactionFold = new ReactionFold();
|
|
3532
|
+
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3533
|
+
editFold = new EditFold();
|
|
3534
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3535
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3536
|
+
deleteFold = new DeleteFold();
|
|
3537
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3538
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3539
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3540
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3541
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3542
|
+
suppressedLoaded = false;
|
|
3543
|
+
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3544
|
+
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3545
|
+
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
3546
|
+
/** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
|
|
3547
|
+
* projection time from senderUserId; '' = resolved-but-unknown peer). */
|
|
3548
|
+
authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
3243
3549
|
loadedEarliestSeq = null;
|
|
3244
3550
|
historyLoaded = false;
|
|
3245
3551
|
wired = false;
|
|
@@ -3282,7 +3588,7 @@ var Chat = class {
|
|
|
3282
3588
|
return this.kind === "direct";
|
|
3283
3589
|
}
|
|
3284
3590
|
get messages() {
|
|
3285
|
-
return this.
|
|
3591
|
+
return this.surfaced();
|
|
3286
3592
|
}
|
|
3287
3593
|
get members() {
|
|
3288
3594
|
return this.memberCache;
|
|
@@ -3291,13 +3597,49 @@ var Chat = class {
|
|
|
3291
3597
|
return this.typingList;
|
|
3292
3598
|
}
|
|
3293
3599
|
get lastMessage() {
|
|
3294
|
-
return this.
|
|
3600
|
+
return this.surfaced().at(-1) ?? null;
|
|
3295
3601
|
}
|
|
3296
3602
|
get unreadCount() {
|
|
3297
|
-
return this.
|
|
3298
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3603
|
+
return this.surfaced().filter(
|
|
3604
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3299
3605
|
).length;
|
|
3300
3606
|
}
|
|
3607
|
+
/**
|
|
3608
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3609
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3610
|
+
* folded edit text + reactions + reply):
|
|
3611
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3612
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3613
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3614
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3615
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3616
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3617
|
+
*/
|
|
3618
|
+
surfaced() {
|
|
3619
|
+
const out = [];
|
|
3620
|
+
for (const m of this.messageList) {
|
|
3621
|
+
const key = this.suppressionKey(m);
|
|
3622
|
+
if (this.suppressed.has(key)) continue;
|
|
3623
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3624
|
+
if (tombstoned) {
|
|
3625
|
+
out.push({
|
|
3626
|
+
...m,
|
|
3627
|
+
text: DELETED_DESCRIPTOR,
|
|
3628
|
+
reactions: {},
|
|
3629
|
+
replyTo: null,
|
|
3630
|
+
edited: false,
|
|
3631
|
+
isDeleted: true
|
|
3632
|
+
});
|
|
3633
|
+
continue;
|
|
3634
|
+
}
|
|
3635
|
+
out.push(m);
|
|
3636
|
+
}
|
|
3637
|
+
return out;
|
|
3638
|
+
}
|
|
3639
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3640
|
+
suppressionKey(m) {
|
|
3641
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3642
|
+
}
|
|
3301
3643
|
get title() {
|
|
3302
3644
|
if (this.titleOverride) return this.titleOverride;
|
|
3303
3645
|
if (this._group?.name) return this._group.name;
|
|
@@ -3321,9 +3663,28 @@ var Chat = class {
|
|
|
3321
3663
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3322
3664
|
this.wired = true;
|
|
3323
3665
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3666
|
+
void this.loadSuppressed();
|
|
3324
3667
|
void this.hydrateHistory();
|
|
3325
3668
|
void this.refreshMembers();
|
|
3326
3669
|
}
|
|
3670
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3671
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3672
|
+
async loadSuppressed() {
|
|
3673
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3674
|
+
this.suppressedLoaded = true;
|
|
3675
|
+
try {
|
|
3676
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3677
|
+
let changed = false;
|
|
3678
|
+
for (const k of keys) {
|
|
3679
|
+
if (!this.suppressed.has(k)) {
|
|
3680
|
+
this.suppressed.add(k);
|
|
3681
|
+
changed = true;
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
if (changed) this.emit();
|
|
3685
|
+
} catch {
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3327
3688
|
async hydrateHistory() {
|
|
3328
3689
|
if (this.historyLoaded || !this._group) return;
|
|
3329
3690
|
this.historyLoaded = true;
|
|
@@ -3334,19 +3695,26 @@ var Chat = class {
|
|
|
3334
3695
|
let changed = false;
|
|
3335
3696
|
for (const m of incoming) {
|
|
3336
3697
|
if (m.serverSeq <= 0) continue;
|
|
3337
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3698
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3338
3699
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3339
3700
|
text: m.text,
|
|
3340
3701
|
senderUserId: m.senderUserId ?? ""
|
|
3341
3702
|
});
|
|
3342
3703
|
}
|
|
3704
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3705
|
+
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3706
|
+
}
|
|
3343
3707
|
}
|
|
3708
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3344
3709
|
for (const m of incoming) {
|
|
3345
3710
|
if (m.serverSeq <= 0) continue;
|
|
3346
3711
|
const key = this.internalKey(m.serverSeq);
|
|
3347
3712
|
if (this.seenKeys.has(key)) continue;
|
|
3348
3713
|
this.seenKeys.add(key);
|
|
3349
|
-
|
|
3714
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3715
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3716
|
+
}
|
|
3717
|
+
this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
|
|
3350
3718
|
changed = true;
|
|
3351
3719
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3352
3720
|
}
|
|
@@ -3387,6 +3755,37 @@ var Chat = class {
|
|
|
3387
3755
|
}
|
|
3388
3756
|
return;
|
|
3389
3757
|
}
|
|
3758
|
+
if (incoming.envelopeType === "edit" && incoming.edit) {
|
|
3759
|
+
const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3760
|
+
this.editFold.ingest(
|
|
3761
|
+
{
|
|
3762
|
+
targetClientMsgId: incoming.edit.targetClientMsgId,
|
|
3763
|
+
editorUserId,
|
|
3764
|
+
newText: incoming.edit.newText,
|
|
3765
|
+
epoch: incoming.epoch,
|
|
3766
|
+
serverSeq: incoming.serverSeq,
|
|
3767
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3768
|
+
},
|
|
3769
|
+
this.authorOfTarget
|
|
3770
|
+
);
|
|
3771
|
+
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3772
|
+
return;
|
|
3773
|
+
}
|
|
3774
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3775
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3776
|
+
this.deleteFold.ingest(
|
|
3777
|
+
{
|
|
3778
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3779
|
+
actorUserId,
|
|
3780
|
+
epoch: incoming.epoch,
|
|
3781
|
+
serverSeq: incoming.serverSeq,
|
|
3782
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3783
|
+
},
|
|
3784
|
+
this.authorOfTarget
|
|
3785
|
+
);
|
|
3786
|
+
this.emit();
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3390
3789
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3391
3790
|
const incomingReplyRef = incoming.replyRef;
|
|
3392
3791
|
let resolvedReplyTo = null;
|
|
@@ -3405,7 +3804,11 @@ var Chat = class {
|
|
|
3405
3804
|
replyTo: resolvedReplyTo,
|
|
3406
3805
|
// Attach any tally already folded for this message (a reaction that arrived
|
|
3407
3806
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3408
|
-
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
|
|
3807
|
+
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3808
|
+
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3809
|
+
edited: false,
|
|
3810
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3811
|
+
isDeleted: false
|
|
3409
3812
|
};
|
|
3410
3813
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3411
3814
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
@@ -3413,7 +3816,12 @@ var Chat = class {
|
|
|
3413
3816
|
senderUserId: senderUser ?? ""
|
|
3414
3817
|
});
|
|
3415
3818
|
}
|
|
3416
|
-
|
|
3819
|
+
if (incomingClientMsgId) {
|
|
3820
|
+
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3821
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3822
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3823
|
+
}
|
|
3824
|
+
this.messageList.push(this.applyEditOverlay(msg));
|
|
3417
3825
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3418
3826
|
this.loadedEarliestSeq = Math.min(
|
|
3419
3827
|
this.loadedEarliestSeq ?? incoming.serverSeq,
|
|
@@ -3421,6 +3829,19 @@ var Chat = class {
|
|
|
3421
3829
|
);
|
|
3422
3830
|
this.emit();
|
|
3423
3831
|
}
|
|
3832
|
+
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3833
|
+
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3834
|
+
* so it can be passed to the pure EditFold. */
|
|
3835
|
+
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3836
|
+
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3837
|
+
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3838
|
+
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
3839
|
+
seedEditBase(clientMsgId, text, author) {
|
|
3840
|
+
if (!this.originalTextByClientMsgId.has(clientMsgId)) {
|
|
3841
|
+
this.originalTextByClientMsgId.set(clientMsgId, text);
|
|
3842
|
+
}
|
|
3843
|
+
if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
|
|
3844
|
+
}
|
|
3424
3845
|
/**
|
|
3425
3846
|
* Rebuild the target message's `reactions` from the authoritative fold and
|
|
3426
3847
|
* re-emit. No-op when the target isn't present yet (its tally is attached the
|
|
@@ -3450,6 +3871,46 @@ var Chat = class {
|
|
|
3450
3871
|
if (sameReactions(m.reactions, tally)) return m;
|
|
3451
3872
|
return { ...m, reactions: tally };
|
|
3452
3873
|
}
|
|
3874
|
+
/**
|
|
3875
|
+
* Rebuild the target message's rendered `text` + `edited` flag from the
|
|
3876
|
+
* authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
|
|
3877
|
+
* (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
|
|
3878
|
+
* base`; base is the seeded original so a forged/ignored edit leaves it intact.
|
|
3879
|
+
* No-op when the target isn't present yet (the fold already recorded it; the
|
|
3880
|
+
* overlay applies the moment the target lands) or when unchanged.
|
|
3881
|
+
*/
|
|
3882
|
+
recomputeEdit(targetClientMsgId) {
|
|
3883
|
+
if (!targetClientMsgId) return;
|
|
3884
|
+
const editText = this.editFold.text(targetClientMsgId);
|
|
3885
|
+
const foldEdited = this.editFold.isEdited(targetClientMsgId);
|
|
3886
|
+
let changed = false;
|
|
3887
|
+
this.messageList = this.messageList.map((m) => {
|
|
3888
|
+
if (m.clientMsgId !== targetClientMsgId) return m;
|
|
3889
|
+
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3890
|
+
const text = editText ?? base;
|
|
3891
|
+
const edited = foldEdited || m.edited;
|
|
3892
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3893
|
+
changed = true;
|
|
3894
|
+
return { ...m, text, edited };
|
|
3895
|
+
});
|
|
3896
|
+
if (changed) this.emit();
|
|
3897
|
+
}
|
|
3898
|
+
/**
|
|
3899
|
+
* Overlay the authoritative edit fold's winning text + flag onto a message as it
|
|
3900
|
+
* is appended/merged. The fold WINS when it has an edit for this target;
|
|
3901
|
+
* otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
|
|
3902
|
+
* history fold) is preserved. PRESERVES reactions + replyTo.
|
|
3903
|
+
*/
|
|
3904
|
+
applyEditOverlay(m) {
|
|
3905
|
+
if (!m.clientMsgId) return m;
|
|
3906
|
+
const editText = this.editFold.text(m.clientMsgId);
|
|
3907
|
+
const foldEdited = this.editFold.isEdited(m.clientMsgId);
|
|
3908
|
+
if (editText === null && !foldEdited) return m;
|
|
3909
|
+
const text = editText ?? m.text;
|
|
3910
|
+
const edited = foldEdited || m.edited;
|
|
3911
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3912
|
+
return { ...m, text, edited };
|
|
3913
|
+
}
|
|
3453
3914
|
/** @internal — called by the backend's conv subscription. */
|
|
3454
3915
|
applyConv(event, payload) {
|
|
3455
3916
|
const userId = typeof payload.user_id === "string" ? payload.user_id : null;
|
|
@@ -3504,6 +3965,8 @@ var Chat = class {
|
|
|
3504
3965
|
this.memberCache = m;
|
|
3505
3966
|
this.emit();
|
|
3506
3967
|
}
|
|
3968
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3969
|
+
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3507
3970
|
}
|
|
3508
3971
|
seedMembersFromGroup(group) {
|
|
3509
3972
|
const seed = [
|
|
@@ -3582,6 +4045,7 @@ var Chat = class {
|
|
|
3582
4045
|
this.seenKeys.add(key);
|
|
3583
4046
|
if (clientMsgId) {
|
|
3584
4047
|
this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
|
|
4048
|
+
this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
|
|
3585
4049
|
}
|
|
3586
4050
|
this.messageList.push({
|
|
3587
4051
|
id: this.publicId(receipt.serverSeq),
|
|
@@ -3595,7 +4059,11 @@ var Chat = class {
|
|
|
3595
4059
|
replyTo: resolvedReplyTo,
|
|
3596
4060
|
// Attach any tally already folded for this own-sent message (rare, but keeps
|
|
3597
4061
|
// the dangling-target invariant uniform across every append path).
|
|
3598
|
-
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
|
|
4062
|
+
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
4063
|
+
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
4064
|
+
edited: false,
|
|
4065
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4066
|
+
isDeleted: false
|
|
3599
4067
|
});
|
|
3600
4068
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3601
4069
|
this.emit();
|
|
@@ -3676,6 +4144,83 @@ var Chat = class {
|
|
|
3676
4144
|
});
|
|
3677
4145
|
this.recomputeReactions(message.clientMsgId);
|
|
3678
4146
|
}
|
|
4147
|
+
// ── Edit ──
|
|
4148
|
+
/** Edit an own text message (edit-by-supersession). No-op if the message isn't
|
|
4149
|
+
* editable (empty clientMsgId, or not a `text` kind). The edit folds locally
|
|
4150
|
+
* with the server receipt's `(epoch, serverSeq)` so the target's text updates
|
|
4151
|
+
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
4152
|
+
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
4153
|
+
* reactions + reply context. Only the original author's edits count — for an own
|
|
4154
|
+
* message self IS the author, so the author-gate passes. */
|
|
4155
|
+
async edit(message, newText) {
|
|
4156
|
+
if (!message.clientMsgId || message.kind !== "text") return;
|
|
4157
|
+
const group = await this.materializeIfNeeded();
|
|
4158
|
+
const clientMsgId = mintClientMsgId();
|
|
4159
|
+
const { receipt } = await this.backend.sendEdit(group, {
|
|
4160
|
+
clientMsgId,
|
|
4161
|
+
targetClientMsgId: message.clientMsgId,
|
|
4162
|
+
newText
|
|
4163
|
+
});
|
|
4164
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4165
|
+
this.editFold.ingest(
|
|
4166
|
+
{
|
|
4167
|
+
targetClientMsgId: message.clientMsgId,
|
|
4168
|
+
editorUserId: this.backend.selfUserId,
|
|
4169
|
+
newText,
|
|
4170
|
+
epoch: receipt.epoch,
|
|
4171
|
+
serverSeq: receipt.serverSeq,
|
|
4172
|
+
eventClientMsgId: clientMsgId
|
|
4173
|
+
},
|
|
4174
|
+
this.authorOfTarget
|
|
4175
|
+
);
|
|
4176
|
+
this.recomputeEdit(message.clientMsgId);
|
|
4177
|
+
}
|
|
4178
|
+
// ── Delete ──
|
|
4179
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
4180
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
4181
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
4182
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
4183
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
4184
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
4185
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
4186
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
4187
|
+
async deleteForEveryone(message) {
|
|
4188
|
+
if (!message.clientMsgId) return;
|
|
4189
|
+
const group = await this.materializeIfNeeded();
|
|
4190
|
+
const clientMsgId = mintClientMsgId();
|
|
4191
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
4192
|
+
clientMsgId,
|
|
4193
|
+
targetClientMsgId: message.clientMsgId
|
|
4194
|
+
});
|
|
4195
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4196
|
+
this.deleteFold.ingest(
|
|
4197
|
+
{
|
|
4198
|
+
targetClientMsgId: message.clientMsgId,
|
|
4199
|
+
actorUserId: this.backend.selfUserId,
|
|
4200
|
+
epoch: receipt.epoch,
|
|
4201
|
+
serverSeq: receipt.serverSeq,
|
|
4202
|
+
eventClientMsgId: clientMsgId
|
|
4203
|
+
},
|
|
4204
|
+
this.authorOfTarget
|
|
4205
|
+
);
|
|
4206
|
+
this.emit();
|
|
4207
|
+
}
|
|
4208
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4209
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4210
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4211
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4212
|
+
async deleteForMe(message) {
|
|
4213
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4214
|
+
if (this.suppressed.has(key)) return;
|
|
4215
|
+
this.suppressed.add(key);
|
|
4216
|
+
this.emit();
|
|
4217
|
+
if (this._group) {
|
|
4218
|
+
try {
|
|
4219
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4220
|
+
} catch {
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
3679
4224
|
};
|
|
3680
4225
|
function sameReactions(a, b) {
|
|
3681
4226
|
const ak = Object.keys(a);
|
|
@@ -3855,6 +4400,8 @@ var MessageDeliverySource = class {
|
|
|
3855
4400
|
const decoded = decodeEnvelope(received.data);
|
|
3856
4401
|
const { text, clientMsgId, replyTo } = decoded;
|
|
3857
4402
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4403
|
+
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4404
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
3858
4405
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3859
4406
|
const stored = {
|
|
3860
4407
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3882,6 +4429,29 @@ var MessageDeliverySource = class {
|
|
|
3882
4429
|
emoji: decoded.reaction.emoji,
|
|
3883
4430
|
op: decoded.reaction.op
|
|
3884
4431
|
}
|
|
4432
|
+
} : {},
|
|
4433
|
+
// Thread the edit discriminator + new text through the persisted row so an
|
|
4434
|
+
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4435
|
+
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4436
|
+
// `'text'`/no-edit (backward-compat).
|
|
4437
|
+
...isEdit && decoded.edit ? {
|
|
4438
|
+
envelopeType: "edit",
|
|
4439
|
+
edit: {
|
|
4440
|
+
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4441
|
+
newText: decoded.edit.newText
|
|
4442
|
+
}
|
|
4443
|
+
} : {},
|
|
4444
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4445
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4446
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4447
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4448
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4449
|
+
...isDelete && decoded.delete ? {
|
|
4450
|
+
envelopeType: "delete",
|
|
4451
|
+
delete: {
|
|
4452
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4453
|
+
scope: decoded.delete.scope
|
|
4454
|
+
}
|
|
3885
4455
|
} : {}
|
|
3886
4456
|
};
|
|
3887
4457
|
try {
|
|
@@ -3900,7 +4470,9 @@ var MessageDeliverySource = class {
|
|
|
3900
4470
|
clientMsgId,
|
|
3901
4471
|
replyRef: replyTo,
|
|
3902
4472
|
envelopeType: decoded.type ?? "text",
|
|
3903
|
-
reaction: isReaction ? decoded.reaction : null
|
|
4473
|
+
reaction: isReaction ? decoded.reaction : null,
|
|
4474
|
+
edit: isEdit ? decoded.edit : null,
|
|
4475
|
+
delete: isDelete ? decoded.delete : null
|
|
3904
4476
|
});
|
|
3905
4477
|
return true;
|
|
3906
4478
|
}
|
|
@@ -5722,6 +6294,36 @@ var SignatureKeyStore = class {
|
|
|
5722
6294
|
}
|
|
5723
6295
|
};
|
|
5724
6296
|
|
|
6297
|
+
// src/messaging/suppression.ts
|
|
6298
|
+
var SuppressionStore = class {
|
|
6299
|
+
constructor(kv) {
|
|
6300
|
+
this.kv = kv;
|
|
6301
|
+
}
|
|
6302
|
+
kv;
|
|
6303
|
+
key(rfcGroupId) {
|
|
6304
|
+
return `supp:${rfcGroupId}`;
|
|
6305
|
+
}
|
|
6306
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6307
|
+
async load(rfcGroupId) {
|
|
6308
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6309
|
+
if (!raw) return [];
|
|
6310
|
+
try {
|
|
6311
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6312
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6313
|
+
} catch {
|
|
6314
|
+
return [];
|
|
6315
|
+
}
|
|
6316
|
+
}
|
|
6317
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6318
|
+
async save(rfcGroupId, keys) {
|
|
6319
|
+
const sorted = [...new Set(keys)].sort();
|
|
6320
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6321
|
+
}
|
|
6322
|
+
async wipe() {
|
|
6323
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6324
|
+
}
|
|
6325
|
+
};
|
|
6326
|
+
|
|
5725
6327
|
// src/messaging/coordinator.ts
|
|
5726
6328
|
var MessagingCoordinator = class {
|
|
5727
6329
|
constructor(rt) {
|
|
@@ -5731,6 +6333,7 @@ var MessagingCoordinator = class {
|
|
|
5731
6333
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
5732
6334
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5733
6335
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6336
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5734
6337
|
this.registry.attachChatList(
|
|
5735
6338
|
(chats) => {
|
|
5736
6339
|
this.chatList = chats;
|
|
@@ -5745,6 +6348,7 @@ var MessagingCoordinator = class {
|
|
|
5745
6348
|
sigStore;
|
|
5746
6349
|
groupStore;
|
|
5747
6350
|
kpStore;
|
|
6351
|
+
suppressionStore;
|
|
5748
6352
|
registry = new GroupRegistry();
|
|
5749
6353
|
resolved = null;
|
|
5750
6354
|
resolvePromise = null;
|
|
@@ -5908,6 +6512,22 @@ var MessagingCoordinator = class {
|
|
|
5908
6512
|
const r = await this.resolve();
|
|
5909
6513
|
return r.groups.sendReaction(group, args);
|
|
5910
6514
|
}
|
|
6515
|
+
async sendEdit(group, args) {
|
|
6516
|
+
const r = await this.resolve();
|
|
6517
|
+
return r.groups.sendEdit(group, args);
|
|
6518
|
+
}
|
|
6519
|
+
async sendDelete(group, args) {
|
|
6520
|
+
const r = await this.resolve();
|
|
6521
|
+
return r.groups.sendDelete(group, args);
|
|
6522
|
+
}
|
|
6523
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6524
|
+
loadSuppressed(group) {
|
|
6525
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6526
|
+
}
|
|
6527
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6528
|
+
saveSuppressed(group, keys) {
|
|
6529
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6530
|
+
}
|
|
5911
6531
|
async history(group, limit, before) {
|
|
5912
6532
|
const r = await this.resolve();
|
|
5913
6533
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6004,9 +6624,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6004
6624
|
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6005
6625
|
});
|
|
6006
6626
|
}
|
|
6627
|
+
const editFold = new EditFold();
|
|
6628
|
+
const deleteFold = new DeleteFold();
|
|
6629
|
+
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6630
|
+
for (const s of rows) {
|
|
6631
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6632
|
+
continue;
|
|
6633
|
+
const cid = s.clientMsgId ?? "";
|
|
6634
|
+
if (!cid) continue;
|
|
6635
|
+
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6636
|
+
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6637
|
+
}
|
|
6638
|
+
const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
|
|
6639
|
+
for (const s of rows) {
|
|
6640
|
+
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6641
|
+
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6642
|
+
editFold.ingest(
|
|
6643
|
+
{
|
|
6644
|
+
targetClientMsgId: s.edit.targetClientMsgId,
|
|
6645
|
+
editorUserId: editor,
|
|
6646
|
+
newText: s.edit.newText,
|
|
6647
|
+
epoch: s.epoch,
|
|
6648
|
+
serverSeq: s.serverSeq,
|
|
6649
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6650
|
+
},
|
|
6651
|
+
authorOfTarget
|
|
6652
|
+
);
|
|
6653
|
+
}
|
|
6654
|
+
editFold.reevaluateHeld(authorOfTarget);
|
|
6655
|
+
for (const s of rows) {
|
|
6656
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6657
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6658
|
+
deleteFold.ingest(
|
|
6659
|
+
{
|
|
6660
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6661
|
+
actorUserId: actor,
|
|
6662
|
+
epoch: s.epoch,
|
|
6663
|
+
serverSeq: s.serverSeq,
|
|
6664
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6665
|
+
},
|
|
6666
|
+
authorOfTarget
|
|
6667
|
+
);
|
|
6668
|
+
}
|
|
6669
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
6007
6670
|
const lookup = /* @__PURE__ */ new Map();
|
|
6008
6671
|
for (const s of rows) {
|
|
6009
|
-
if (s.envelopeType === "reaction")
|
|
6672
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6673
|
+
continue;
|
|
6010
6674
|
const cid = s.clientMsgId ?? "";
|
|
6011
6675
|
if (cid && s.text !== null) {
|
|
6012
6676
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -6015,8 +6679,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6015
6679
|
}
|
|
6016
6680
|
const out = [];
|
|
6017
6681
|
for (const s of rows) {
|
|
6018
|
-
if (s.envelopeType === "reaction")
|
|
6682
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6683
|
+
continue;
|
|
6019
6684
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6685
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6686
|
+
if (isDeleted) {
|
|
6687
|
+
out.push({
|
|
6688
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6689
|
+
kind: "text",
|
|
6690
|
+
direction: s.direction,
|
|
6691
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6692
|
+
text: DELETED_DESCRIPTOR,
|
|
6693
|
+
serverSeq: s.serverSeq,
|
|
6694
|
+
sentAt: new Date(s.at),
|
|
6695
|
+
clientMsgId,
|
|
6696
|
+
replyTo: null,
|
|
6697
|
+
reactions: {},
|
|
6698
|
+
edited: false,
|
|
6699
|
+
isDeleted: true
|
|
6700
|
+
});
|
|
6701
|
+
continue;
|
|
6702
|
+
}
|
|
6020
6703
|
let replyTo = null;
|
|
6021
6704
|
if (s.replyTo) {
|
|
6022
6705
|
const ref = {
|
|
@@ -6031,17 +6714,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6031
6714
|
};
|
|
6032
6715
|
replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
|
|
6033
6716
|
}
|
|
6717
|
+
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6718
|
+
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
6034
6719
|
out.push({
|
|
6035
6720
|
id: `${displayId}#${s.serverSeq}`,
|
|
6036
6721
|
kind: s.text != null ? "text" : "system",
|
|
6037
6722
|
direction: s.direction,
|
|
6038
6723
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6039
|
-
text: s.text,
|
|
6724
|
+
text: editText ?? s.text,
|
|
6040
6725
|
serverSeq: s.serverSeq,
|
|
6041
6726
|
sentAt: new Date(s.at),
|
|
6042
6727
|
clientMsgId,
|
|
6043
6728
|
replyTo,
|
|
6044
|
-
reactions: clientMsgId ? fold.tally(clientMsgId) : {}
|
|
6729
|
+
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6730
|
+
edited,
|
|
6731
|
+
isDeleted: false
|
|
6045
6732
|
});
|
|
6046
6733
|
}
|
|
6047
6734
|
return out;
|
|
@@ -6779,7 +7466,7 @@ function defaultSessionStorage(key) {
|
|
|
6779
7466
|
}
|
|
6780
7467
|
|
|
6781
7468
|
// src/version.ts
|
|
6782
|
-
var VERSION = "1.
|
|
7469
|
+
var VERSION = "1.4.0";
|
|
6783
7470
|
|
|
6784
7471
|
// src/runtime.ts
|
|
6785
7472
|
function buildRuntime(config) {
|