@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/index.cjs
CHANGED
|
@@ -2628,6 +2628,151 @@ var PalbeFlags = class {
|
|
|
2628
2628
|
}
|
|
2629
2629
|
};
|
|
2630
2630
|
|
|
2631
|
+
// src/messaging/delete-fold.ts
|
|
2632
|
+
var DeleteFold = class {
|
|
2633
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2634
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2635
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2636
|
+
pending = /* @__PURE__ */ new Map();
|
|
2637
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2638
|
+
seen = /* @__PURE__ */ new Set();
|
|
2639
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2640
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2641
|
+
held = [];
|
|
2642
|
+
/**
|
|
2643
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2644
|
+
* userId (null = target absent locally → defer).
|
|
2645
|
+
*/
|
|
2646
|
+
ingest(e, authorOfTarget) {
|
|
2647
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2648
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2649
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2650
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2651
|
+
if (author !== null) {
|
|
2652
|
+
this.seen.add(e.eventClientMsgId);
|
|
2653
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2654
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2655
|
+
} else if (e.actorUserId !== null) {
|
|
2656
|
+
this.seen.add(e.eventClientMsgId);
|
|
2657
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2658
|
+
} else {
|
|
2659
|
+
this.held.push(e);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2663
|
+
isTombstoned(targetClientMsgId) {
|
|
2664
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2668
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2669
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2670
|
+
* the in-order path.
|
|
2671
|
+
*/
|
|
2672
|
+
reevaluatePending(target, author) {
|
|
2673
|
+
const actor = this.pending.get(target);
|
|
2674
|
+
if (actor !== void 0) {
|
|
2675
|
+
if (author !== null && actor === author) {
|
|
2676
|
+
this.tombstoned.add(target);
|
|
2677
|
+
this.pending.delete(target);
|
|
2678
|
+
} else if (author !== null) {
|
|
2679
|
+
this.pending.delete(target);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
if (this.held.length === 0) return;
|
|
2683
|
+
const pendingHeld = this.held;
|
|
2684
|
+
this.held = [];
|
|
2685
|
+
for (const e of pendingHeld) {
|
|
2686
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
heldContains(eventClientMsgId) {
|
|
2690
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
|
|
2694
|
+
// src/messaging/edit-fold.ts
|
|
2695
|
+
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2696
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
2697
|
+
return aSeq < bSeq;
|
|
2698
|
+
}
|
|
2699
|
+
function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2700
|
+
return aEpoch === bEpoch && aSeq === bSeq;
|
|
2701
|
+
}
|
|
2702
|
+
var EditFold = class {
|
|
2703
|
+
// target → winning edit state
|
|
2704
|
+
states = /* @__PURE__ */ new Map();
|
|
2705
|
+
// dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
|
|
2706
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
2707
|
+
// events parked because target/author or sender was unresolved at ingest time
|
|
2708
|
+
held = [];
|
|
2709
|
+
// targets that have had ≥1 valid edit applied (write-once)
|
|
2710
|
+
editedTargets = /* @__PURE__ */ new Set();
|
|
2711
|
+
/**
|
|
2712
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
|
|
2713
|
+
* (null = target unknown/dangling → HOLD).
|
|
2714
|
+
*/
|
|
2715
|
+
ingest(e, authorOfTarget) {
|
|
2716
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2717
|
+
if (author === null) {
|
|
2718
|
+
this.holdIfNew(e);
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
if (e.editorUserId === null) {
|
|
2722
|
+
this.holdIfNew(e);
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
if (e.editorUserId !== author) return;
|
|
2726
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
2727
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2728
|
+
const prev = this.states.get(e.targetClientMsgId);
|
|
2729
|
+
if (prev !== void 0) {
|
|
2730
|
+
if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
|
|
2731
|
+
if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
this.states.set(e.targetClientMsgId, {
|
|
2736
|
+
orderEpoch: e.epoch,
|
|
2737
|
+
orderSeq: e.serverSeq,
|
|
2738
|
+
lastEventId: e.eventClientMsgId,
|
|
2739
|
+
text: e.newText
|
|
2740
|
+
});
|
|
2741
|
+
this.editedTargets.add(e.targetClientMsgId);
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Park an event for later re-attempt, deduping held re-deliveries by
|
|
2745
|
+
* eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
|
|
2746
|
+
* once (and never double-applies when it finally resolves on reevaluate).
|
|
2747
|
+
*/
|
|
2748
|
+
holdIfNew(e) {
|
|
2749
|
+
if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
|
|
2750
|
+
this.held.push(e);
|
|
2751
|
+
}
|
|
2752
|
+
/** The winning edit text for a target, or null if no valid edit has applied. */
|
|
2753
|
+
text(targetClientMsgId) {
|
|
2754
|
+
return this.states.get(targetClientMsgId)?.text ?? null;
|
|
2755
|
+
}
|
|
2756
|
+
/** Write-once: true once any valid edit applied to the target. */
|
|
2757
|
+
isEdited(targetClientMsgId) {
|
|
2758
|
+
return this.editedTargets.has(targetClientMsgId);
|
|
2759
|
+
}
|
|
2760
|
+
/**
|
|
2761
|
+
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2762
|
+
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
2763
|
+
* event with the fresh `authorOfTarget` — events that still don't resolve are
|
|
2764
|
+
* simply re-held; events that now resolve fold via the normal LWW path.
|
|
2765
|
+
* Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
|
|
2766
|
+
* `holdIfNew` (still-held events), so reevaluating repeatedly can neither
|
|
2767
|
+
* double-apply nor lose an edit.
|
|
2768
|
+
*/
|
|
2769
|
+
reevaluateHeld(authorOfTarget) {
|
|
2770
|
+
const pending = this.held;
|
|
2771
|
+
this.held = [];
|
|
2772
|
+
for (const e of pending) this.ingest(e, authorOfTarget);
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2775
|
+
|
|
2631
2776
|
// src/messaging/util.ts
|
|
2632
2777
|
function toBase64(bytes) {
|
|
2633
2778
|
if (typeof Buffer !== "undefined") {
|
|
@@ -2767,6 +2912,28 @@ async function listDevices(rt, userId) {
|
|
|
2767
2912
|
}
|
|
2768
2913
|
|
|
2769
2914
|
// src/messaging/group-messaging.ts
|
|
2915
|
+
function encodeDelete(args) {
|
|
2916
|
+
return encodeUtf8(
|
|
2917
|
+
JSON.stringify({
|
|
2918
|
+
v: 1,
|
|
2919
|
+
type: "delete",
|
|
2920
|
+
client_msg_id: args.clientMsgId,
|
|
2921
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2922
|
+
scope: "everyone"
|
|
2923
|
+
})
|
|
2924
|
+
);
|
|
2925
|
+
}
|
|
2926
|
+
function encodeEdit(args) {
|
|
2927
|
+
return encodeUtf8(
|
|
2928
|
+
JSON.stringify({
|
|
2929
|
+
v: 1,
|
|
2930
|
+
type: "edit",
|
|
2931
|
+
client_msg_id: args.clientMsgId,
|
|
2932
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2933
|
+
new_text: args.newText
|
|
2934
|
+
})
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2770
2937
|
function encodeReaction(args) {
|
|
2771
2938
|
return encodeUtf8(
|
|
2772
2939
|
JSON.stringify({
|
|
@@ -2793,6 +2960,18 @@ function decodeEnvelope(bytes) {
|
|
|
2793
2960
|
const s = decodeUtf8(bytes);
|
|
2794
2961
|
try {
|
|
2795
2962
|
const o = JSON.parse(s);
|
|
2963
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2964
|
+
return {
|
|
2965
|
+
type: "delete",
|
|
2966
|
+
text: null,
|
|
2967
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2968
|
+
replyTo: null,
|
|
2969
|
+
delete: {
|
|
2970
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2971
|
+
scope: o.scope ?? "everyone"
|
|
2972
|
+
}
|
|
2973
|
+
};
|
|
2974
|
+
}
|
|
2796
2975
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2797
2976
|
return {
|
|
2798
2977
|
type: "reaction",
|
|
@@ -2806,6 +2985,18 @@ function decodeEnvelope(bytes) {
|
|
|
2806
2985
|
}
|
|
2807
2986
|
};
|
|
2808
2987
|
}
|
|
2988
|
+
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2989
|
+
return {
|
|
2990
|
+
type: "edit",
|
|
2991
|
+
text: null,
|
|
2992
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2993
|
+
replyTo: null,
|
|
2994
|
+
edit: {
|
|
2995
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2996
|
+
newText: o.new_text ?? ""
|
|
2997
|
+
}
|
|
2998
|
+
};
|
|
2999
|
+
}
|
|
2809
3000
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2810
3001
|
return {
|
|
2811
3002
|
type: "text",
|
|
@@ -3120,6 +3311,103 @@ var GroupMessaging = class {
|
|
|
3120
3311
|
clientMsgId: args.clientMsgId
|
|
3121
3312
|
};
|
|
3122
3313
|
}
|
|
3314
|
+
/** Send an edit (edit-by-supersession on a target message). Encrypts a
|
|
3315
|
+
* `type:'edit'` envelope at the current epoch and sends through the SAME MLS
|
|
3316
|
+
* application path as `sendText` (the server stays blind — an edit is just
|
|
3317
|
+
* another application message). Persists the outgoing edit row so it re-folds
|
|
3318
|
+
* onto its target's text after a reload (the own-send half of the reload
|
|
3319
|
+
* parity). NEVER rebases (epoch-bound like any application message). */
|
|
3320
|
+
async sendEdit(group, args) {
|
|
3321
|
+
const plaintext = encodeEdit({
|
|
3322
|
+
clientMsgId: args.clientMsgId,
|
|
3323
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3324
|
+
newText: args.newText
|
|
3325
|
+
});
|
|
3326
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3327
|
+
const body = {
|
|
3328
|
+
ciphertext_b64: toBase64(ct),
|
|
3329
|
+
client_idem_key: randomId()
|
|
3330
|
+
};
|
|
3331
|
+
const wire = await palbeRequest(
|
|
3332
|
+
this.rt,
|
|
3333
|
+
"POST",
|
|
3334
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3335
|
+
{ body }
|
|
3336
|
+
);
|
|
3337
|
+
const stored = {
|
|
3338
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3339
|
+
direction: "outgoing",
|
|
3340
|
+
text: null,
|
|
3341
|
+
senderDeviceId: this.selfDeviceId,
|
|
3342
|
+
epoch: wire.epoch,
|
|
3343
|
+
serverSeq: wire.server_seq,
|
|
3344
|
+
at: Date.now(),
|
|
3345
|
+
clientMsgId: args.clientMsgId,
|
|
3346
|
+
replyTo: null,
|
|
3347
|
+
envelopeType: "edit",
|
|
3348
|
+
edit: {
|
|
3349
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3350
|
+
newText: args.newText
|
|
3351
|
+
}
|
|
3352
|
+
};
|
|
3353
|
+
try {
|
|
3354
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3355
|
+
} catch {
|
|
3356
|
+
}
|
|
3357
|
+
return {
|
|
3358
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3359
|
+
clientMsgId: args.clientMsgId
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3363
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3364
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3365
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3366
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3367
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3368
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3369
|
+
* rebases (epoch-bound like any application message). */
|
|
3370
|
+
async sendDelete(group, args) {
|
|
3371
|
+
const plaintext = encodeDelete({
|
|
3372
|
+
clientMsgId: args.clientMsgId,
|
|
3373
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3374
|
+
});
|
|
3375
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3376
|
+
const body = {
|
|
3377
|
+
ciphertext_b64: toBase64(ct),
|
|
3378
|
+
client_idem_key: randomId()
|
|
3379
|
+
};
|
|
3380
|
+
const wire = await palbeRequest(
|
|
3381
|
+
this.rt,
|
|
3382
|
+
"POST",
|
|
3383
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3384
|
+
{ body }
|
|
3385
|
+
);
|
|
3386
|
+
const stored = {
|
|
3387
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3388
|
+
direction: "outgoing",
|
|
3389
|
+
text: null,
|
|
3390
|
+
senderDeviceId: this.selfDeviceId,
|
|
3391
|
+
epoch: wire.epoch,
|
|
3392
|
+
serverSeq: wire.server_seq,
|
|
3393
|
+
at: Date.now(),
|
|
3394
|
+
clientMsgId: args.clientMsgId,
|
|
3395
|
+
replyTo: null,
|
|
3396
|
+
envelopeType: "delete",
|
|
3397
|
+
delete: {
|
|
3398
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3399
|
+
scope: "everyone"
|
|
3400
|
+
}
|
|
3401
|
+
};
|
|
3402
|
+
try {
|
|
3403
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3404
|
+
} catch {
|
|
3405
|
+
}
|
|
3406
|
+
return {
|
|
3407
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3408
|
+
clientMsgId: args.clientMsgId
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3123
3411
|
// ── The rebase loop ──
|
|
3124
3412
|
async commitWithRebase(rfcGroupId, build) {
|
|
3125
3413
|
const gidBytes = fromBase64(rfcGroupId);
|
|
@@ -3237,6 +3525,7 @@ var ReactionFold = class {
|
|
|
3237
3525
|
};
|
|
3238
3526
|
|
|
3239
3527
|
// src/messaging/chat.ts
|
|
3528
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3240
3529
|
var Chat = class {
|
|
3241
3530
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3242
3531
|
id;
|
|
@@ -3256,6 +3545,23 @@ var Chat = class {
|
|
|
3256
3545
|
byClientMsgId = /* @__PURE__ */ new Map();
|
|
3257
3546
|
/** The single authoritative reaction fold for this chat (live + own-send + history). */
|
|
3258
3547
|
reactionFold = new ReactionFold();
|
|
3548
|
+
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3549
|
+
editFold = new EditFold();
|
|
3550
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3551
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3552
|
+
deleteFold = new DeleteFold();
|
|
3553
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3554
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3555
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3556
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3557
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3558
|
+
suppressedLoaded = false;
|
|
3559
|
+
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3560
|
+
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3561
|
+
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
3562
|
+
/** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
|
|
3563
|
+
* projection time from senderUserId; '' = resolved-but-unknown peer). */
|
|
3564
|
+
authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
3259
3565
|
loadedEarliestSeq = null;
|
|
3260
3566
|
historyLoaded = false;
|
|
3261
3567
|
wired = false;
|
|
@@ -3298,7 +3604,7 @@ var Chat = class {
|
|
|
3298
3604
|
return this.kind === "direct";
|
|
3299
3605
|
}
|
|
3300
3606
|
get messages() {
|
|
3301
|
-
return this.
|
|
3607
|
+
return this.surfaced();
|
|
3302
3608
|
}
|
|
3303
3609
|
get members() {
|
|
3304
3610
|
return this.memberCache;
|
|
@@ -3307,13 +3613,49 @@ var Chat = class {
|
|
|
3307
3613
|
return this.typingList;
|
|
3308
3614
|
}
|
|
3309
3615
|
get lastMessage() {
|
|
3310
|
-
return this.
|
|
3616
|
+
return this.surfaced().at(-1) ?? null;
|
|
3311
3617
|
}
|
|
3312
3618
|
get unreadCount() {
|
|
3313
|
-
return this.
|
|
3314
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3619
|
+
return this.surfaced().filter(
|
|
3620
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3315
3621
|
).length;
|
|
3316
3622
|
}
|
|
3623
|
+
/**
|
|
3624
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3625
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3626
|
+
* folded edit text + reactions + reply):
|
|
3627
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3628
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3629
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3630
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3631
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3632
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3633
|
+
*/
|
|
3634
|
+
surfaced() {
|
|
3635
|
+
const out = [];
|
|
3636
|
+
for (const m of this.messageList) {
|
|
3637
|
+
const key = this.suppressionKey(m);
|
|
3638
|
+
if (this.suppressed.has(key)) continue;
|
|
3639
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3640
|
+
if (tombstoned) {
|
|
3641
|
+
out.push({
|
|
3642
|
+
...m,
|
|
3643
|
+
text: DELETED_DESCRIPTOR,
|
|
3644
|
+
reactions: {},
|
|
3645
|
+
replyTo: null,
|
|
3646
|
+
edited: false,
|
|
3647
|
+
isDeleted: true
|
|
3648
|
+
});
|
|
3649
|
+
continue;
|
|
3650
|
+
}
|
|
3651
|
+
out.push(m);
|
|
3652
|
+
}
|
|
3653
|
+
return out;
|
|
3654
|
+
}
|
|
3655
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3656
|
+
suppressionKey(m) {
|
|
3657
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3658
|
+
}
|
|
3317
3659
|
get title() {
|
|
3318
3660
|
if (this.titleOverride) return this.titleOverride;
|
|
3319
3661
|
if (this._group?.name) return this._group.name;
|
|
@@ -3337,9 +3679,28 @@ var Chat = class {
|
|
|
3337
3679
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3338
3680
|
this.wired = true;
|
|
3339
3681
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3682
|
+
void this.loadSuppressed();
|
|
3340
3683
|
void this.hydrateHistory();
|
|
3341
3684
|
void this.refreshMembers();
|
|
3342
3685
|
}
|
|
3686
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3687
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3688
|
+
async loadSuppressed() {
|
|
3689
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3690
|
+
this.suppressedLoaded = true;
|
|
3691
|
+
try {
|
|
3692
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3693
|
+
let changed = false;
|
|
3694
|
+
for (const k of keys) {
|
|
3695
|
+
if (!this.suppressed.has(k)) {
|
|
3696
|
+
this.suppressed.add(k);
|
|
3697
|
+
changed = true;
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
if (changed) this.emit();
|
|
3701
|
+
} catch {
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3343
3704
|
async hydrateHistory() {
|
|
3344
3705
|
if (this.historyLoaded || !this._group) return;
|
|
3345
3706
|
this.historyLoaded = true;
|
|
@@ -3350,19 +3711,26 @@ var Chat = class {
|
|
|
3350
3711
|
let changed = false;
|
|
3351
3712
|
for (const m of incoming) {
|
|
3352
3713
|
if (m.serverSeq <= 0) continue;
|
|
3353
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3714
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3354
3715
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3355
3716
|
text: m.text,
|
|
3356
3717
|
senderUserId: m.senderUserId ?? ""
|
|
3357
3718
|
});
|
|
3358
3719
|
}
|
|
3720
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3721
|
+
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3722
|
+
}
|
|
3359
3723
|
}
|
|
3724
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3360
3725
|
for (const m of incoming) {
|
|
3361
3726
|
if (m.serverSeq <= 0) continue;
|
|
3362
3727
|
const key = this.internalKey(m.serverSeq);
|
|
3363
3728
|
if (this.seenKeys.has(key)) continue;
|
|
3364
3729
|
this.seenKeys.add(key);
|
|
3365
|
-
|
|
3730
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3731
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3732
|
+
}
|
|
3733
|
+
this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
|
|
3366
3734
|
changed = true;
|
|
3367
3735
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3368
3736
|
}
|
|
@@ -3403,6 +3771,37 @@ var Chat = class {
|
|
|
3403
3771
|
}
|
|
3404
3772
|
return;
|
|
3405
3773
|
}
|
|
3774
|
+
if (incoming.envelopeType === "edit" && incoming.edit) {
|
|
3775
|
+
const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3776
|
+
this.editFold.ingest(
|
|
3777
|
+
{
|
|
3778
|
+
targetClientMsgId: incoming.edit.targetClientMsgId,
|
|
3779
|
+
editorUserId,
|
|
3780
|
+
newText: incoming.edit.newText,
|
|
3781
|
+
epoch: incoming.epoch,
|
|
3782
|
+
serverSeq: incoming.serverSeq,
|
|
3783
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3784
|
+
},
|
|
3785
|
+
this.authorOfTarget
|
|
3786
|
+
);
|
|
3787
|
+
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3791
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3792
|
+
this.deleteFold.ingest(
|
|
3793
|
+
{
|
|
3794
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3795
|
+
actorUserId,
|
|
3796
|
+
epoch: incoming.epoch,
|
|
3797
|
+
serverSeq: incoming.serverSeq,
|
|
3798
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3799
|
+
},
|
|
3800
|
+
this.authorOfTarget
|
|
3801
|
+
);
|
|
3802
|
+
this.emit();
|
|
3803
|
+
return;
|
|
3804
|
+
}
|
|
3406
3805
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3407
3806
|
const incomingReplyRef = incoming.replyRef;
|
|
3408
3807
|
let resolvedReplyTo = null;
|
|
@@ -3421,7 +3820,11 @@ var Chat = class {
|
|
|
3421
3820
|
replyTo: resolvedReplyTo,
|
|
3422
3821
|
// Attach any tally already folded for this message (a reaction that arrived
|
|
3423
3822
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3424
|
-
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
|
|
3823
|
+
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3824
|
+
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3825
|
+
edited: false,
|
|
3826
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3827
|
+
isDeleted: false
|
|
3425
3828
|
};
|
|
3426
3829
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3427
3830
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
@@ -3429,7 +3832,12 @@ var Chat = class {
|
|
|
3429
3832
|
senderUserId: senderUser ?? ""
|
|
3430
3833
|
});
|
|
3431
3834
|
}
|
|
3432
|
-
|
|
3835
|
+
if (incomingClientMsgId) {
|
|
3836
|
+
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3837
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3838
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3839
|
+
}
|
|
3840
|
+
this.messageList.push(this.applyEditOverlay(msg));
|
|
3433
3841
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3434
3842
|
this.loadedEarliestSeq = Math.min(
|
|
3435
3843
|
this.loadedEarliestSeq ?? incoming.serverSeq,
|
|
@@ -3437,6 +3845,19 @@ var Chat = class {
|
|
|
3437
3845
|
);
|
|
3438
3846
|
this.emit();
|
|
3439
3847
|
}
|
|
3848
|
+
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3849
|
+
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3850
|
+
* so it can be passed to the pure EditFold. */
|
|
3851
|
+
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3852
|
+
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3853
|
+
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3854
|
+
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
3855
|
+
seedEditBase(clientMsgId, text, author) {
|
|
3856
|
+
if (!this.originalTextByClientMsgId.has(clientMsgId)) {
|
|
3857
|
+
this.originalTextByClientMsgId.set(clientMsgId, text);
|
|
3858
|
+
}
|
|
3859
|
+
if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
|
|
3860
|
+
}
|
|
3440
3861
|
/**
|
|
3441
3862
|
* Rebuild the target message's `reactions` from the authoritative fold and
|
|
3442
3863
|
* re-emit. No-op when the target isn't present yet (its tally is attached the
|
|
@@ -3466,6 +3887,46 @@ var Chat = class {
|
|
|
3466
3887
|
if (sameReactions(m.reactions, tally)) return m;
|
|
3467
3888
|
return { ...m, reactions: tally };
|
|
3468
3889
|
}
|
|
3890
|
+
/**
|
|
3891
|
+
* Rebuild the target message's rendered `text` + `edited` flag from the
|
|
3892
|
+
* authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
|
|
3893
|
+
* (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
|
|
3894
|
+
* base`; base is the seeded original so a forged/ignored edit leaves it intact.
|
|
3895
|
+
* No-op when the target isn't present yet (the fold already recorded it; the
|
|
3896
|
+
* overlay applies the moment the target lands) or when unchanged.
|
|
3897
|
+
*/
|
|
3898
|
+
recomputeEdit(targetClientMsgId) {
|
|
3899
|
+
if (!targetClientMsgId) return;
|
|
3900
|
+
const editText = this.editFold.text(targetClientMsgId);
|
|
3901
|
+
const foldEdited = this.editFold.isEdited(targetClientMsgId);
|
|
3902
|
+
let changed = false;
|
|
3903
|
+
this.messageList = this.messageList.map((m) => {
|
|
3904
|
+
if (m.clientMsgId !== targetClientMsgId) return m;
|
|
3905
|
+
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3906
|
+
const text = editText ?? base;
|
|
3907
|
+
const edited = foldEdited || m.edited;
|
|
3908
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3909
|
+
changed = true;
|
|
3910
|
+
return { ...m, text, edited };
|
|
3911
|
+
});
|
|
3912
|
+
if (changed) this.emit();
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* Overlay the authoritative edit fold's winning text + flag onto a message as it
|
|
3916
|
+
* is appended/merged. The fold WINS when it has an edit for this target;
|
|
3917
|
+
* otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
|
|
3918
|
+
* history fold) is preserved. PRESERVES reactions + replyTo.
|
|
3919
|
+
*/
|
|
3920
|
+
applyEditOverlay(m) {
|
|
3921
|
+
if (!m.clientMsgId) return m;
|
|
3922
|
+
const editText = this.editFold.text(m.clientMsgId);
|
|
3923
|
+
const foldEdited = this.editFold.isEdited(m.clientMsgId);
|
|
3924
|
+
if (editText === null && !foldEdited) return m;
|
|
3925
|
+
const text = editText ?? m.text;
|
|
3926
|
+
const edited = foldEdited || m.edited;
|
|
3927
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3928
|
+
return { ...m, text, edited };
|
|
3929
|
+
}
|
|
3469
3930
|
/** @internal — called by the backend's conv subscription. */
|
|
3470
3931
|
applyConv(event, payload) {
|
|
3471
3932
|
const userId = typeof payload.user_id === "string" ? payload.user_id : null;
|
|
@@ -3520,6 +3981,8 @@ var Chat = class {
|
|
|
3520
3981
|
this.memberCache = m;
|
|
3521
3982
|
this.emit();
|
|
3522
3983
|
}
|
|
3984
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3985
|
+
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3523
3986
|
}
|
|
3524
3987
|
seedMembersFromGroup(group) {
|
|
3525
3988
|
const seed = [
|
|
@@ -3598,6 +4061,7 @@ var Chat = class {
|
|
|
3598
4061
|
this.seenKeys.add(key);
|
|
3599
4062
|
if (clientMsgId) {
|
|
3600
4063
|
this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
|
|
4064
|
+
this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
|
|
3601
4065
|
}
|
|
3602
4066
|
this.messageList.push({
|
|
3603
4067
|
id: this.publicId(receipt.serverSeq),
|
|
@@ -3611,7 +4075,11 @@ var Chat = class {
|
|
|
3611
4075
|
replyTo: resolvedReplyTo,
|
|
3612
4076
|
// Attach any tally already folded for this own-sent message (rare, but keeps
|
|
3613
4077
|
// the dangling-target invariant uniform across every append path).
|
|
3614
|
-
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
|
|
4078
|
+
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
4079
|
+
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
4080
|
+
edited: false,
|
|
4081
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4082
|
+
isDeleted: false
|
|
3615
4083
|
});
|
|
3616
4084
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3617
4085
|
this.emit();
|
|
@@ -3692,6 +4160,83 @@ var Chat = class {
|
|
|
3692
4160
|
});
|
|
3693
4161
|
this.recomputeReactions(message.clientMsgId);
|
|
3694
4162
|
}
|
|
4163
|
+
// ── Edit ──
|
|
4164
|
+
/** Edit an own text message (edit-by-supersession). No-op if the message isn't
|
|
4165
|
+
* editable (empty clientMsgId, or not a `text` kind). The edit folds locally
|
|
4166
|
+
* with the server receipt's `(epoch, serverSeq)` so the target's text updates
|
|
4167
|
+
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
4168
|
+
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
4169
|
+
* reactions + reply context. Only the original author's edits count — for an own
|
|
4170
|
+
* message self IS the author, so the author-gate passes. */
|
|
4171
|
+
async edit(message, newText) {
|
|
4172
|
+
if (!message.clientMsgId || message.kind !== "text") return;
|
|
4173
|
+
const group = await this.materializeIfNeeded();
|
|
4174
|
+
const clientMsgId = mintClientMsgId();
|
|
4175
|
+
const { receipt } = await this.backend.sendEdit(group, {
|
|
4176
|
+
clientMsgId,
|
|
4177
|
+
targetClientMsgId: message.clientMsgId,
|
|
4178
|
+
newText
|
|
4179
|
+
});
|
|
4180
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4181
|
+
this.editFold.ingest(
|
|
4182
|
+
{
|
|
4183
|
+
targetClientMsgId: message.clientMsgId,
|
|
4184
|
+
editorUserId: this.backend.selfUserId,
|
|
4185
|
+
newText,
|
|
4186
|
+
epoch: receipt.epoch,
|
|
4187
|
+
serverSeq: receipt.serverSeq,
|
|
4188
|
+
eventClientMsgId: clientMsgId
|
|
4189
|
+
},
|
|
4190
|
+
this.authorOfTarget
|
|
4191
|
+
);
|
|
4192
|
+
this.recomputeEdit(message.clientMsgId);
|
|
4193
|
+
}
|
|
4194
|
+
// ── Delete ──
|
|
4195
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
4196
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
4197
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
4198
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
4199
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
4200
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
4201
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
4202
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
4203
|
+
async deleteForEveryone(message) {
|
|
4204
|
+
if (!message.clientMsgId) return;
|
|
4205
|
+
const group = await this.materializeIfNeeded();
|
|
4206
|
+
const clientMsgId = mintClientMsgId();
|
|
4207
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
4208
|
+
clientMsgId,
|
|
4209
|
+
targetClientMsgId: message.clientMsgId
|
|
4210
|
+
});
|
|
4211
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4212
|
+
this.deleteFold.ingest(
|
|
4213
|
+
{
|
|
4214
|
+
targetClientMsgId: message.clientMsgId,
|
|
4215
|
+
actorUserId: this.backend.selfUserId,
|
|
4216
|
+
epoch: receipt.epoch,
|
|
4217
|
+
serverSeq: receipt.serverSeq,
|
|
4218
|
+
eventClientMsgId: clientMsgId
|
|
4219
|
+
},
|
|
4220
|
+
this.authorOfTarget
|
|
4221
|
+
);
|
|
4222
|
+
this.emit();
|
|
4223
|
+
}
|
|
4224
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4225
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4226
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4227
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4228
|
+
async deleteForMe(message) {
|
|
4229
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4230
|
+
if (this.suppressed.has(key)) return;
|
|
4231
|
+
this.suppressed.add(key);
|
|
4232
|
+
this.emit();
|
|
4233
|
+
if (this._group) {
|
|
4234
|
+
try {
|
|
4235
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4236
|
+
} catch {
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
3695
4240
|
};
|
|
3696
4241
|
function sameReactions(a, b) {
|
|
3697
4242
|
const ak = Object.keys(a);
|
|
@@ -3871,6 +4416,8 @@ var MessageDeliverySource = class {
|
|
|
3871
4416
|
const decoded = decodeEnvelope(received.data);
|
|
3872
4417
|
const { text, clientMsgId, replyTo } = decoded;
|
|
3873
4418
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4419
|
+
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4420
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
3874
4421
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3875
4422
|
const stored = {
|
|
3876
4423
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3898,6 +4445,29 @@ var MessageDeliverySource = class {
|
|
|
3898
4445
|
emoji: decoded.reaction.emoji,
|
|
3899
4446
|
op: decoded.reaction.op
|
|
3900
4447
|
}
|
|
4448
|
+
} : {},
|
|
4449
|
+
// Thread the edit discriminator + new text through the persisted row so an
|
|
4450
|
+
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4451
|
+
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4452
|
+
// `'text'`/no-edit (backward-compat).
|
|
4453
|
+
...isEdit && decoded.edit ? {
|
|
4454
|
+
envelopeType: "edit",
|
|
4455
|
+
edit: {
|
|
4456
|
+
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4457
|
+
newText: decoded.edit.newText
|
|
4458
|
+
}
|
|
4459
|
+
} : {},
|
|
4460
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4461
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4462
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4463
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4464
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4465
|
+
...isDelete && decoded.delete ? {
|
|
4466
|
+
envelopeType: "delete",
|
|
4467
|
+
delete: {
|
|
4468
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4469
|
+
scope: decoded.delete.scope
|
|
4470
|
+
}
|
|
3901
4471
|
} : {}
|
|
3902
4472
|
};
|
|
3903
4473
|
try {
|
|
@@ -3916,7 +4486,9 @@ var MessageDeliverySource = class {
|
|
|
3916
4486
|
clientMsgId,
|
|
3917
4487
|
replyRef: replyTo,
|
|
3918
4488
|
envelopeType: decoded.type ?? "text",
|
|
3919
|
-
reaction: isReaction ? decoded.reaction : null
|
|
4489
|
+
reaction: isReaction ? decoded.reaction : null,
|
|
4490
|
+
edit: isEdit ? decoded.edit : null,
|
|
4491
|
+
delete: isDelete ? decoded.delete : null
|
|
3920
4492
|
});
|
|
3921
4493
|
return true;
|
|
3922
4494
|
}
|
|
@@ -5738,6 +6310,36 @@ var SignatureKeyStore = class {
|
|
|
5738
6310
|
}
|
|
5739
6311
|
};
|
|
5740
6312
|
|
|
6313
|
+
// src/messaging/suppression.ts
|
|
6314
|
+
var SuppressionStore = class {
|
|
6315
|
+
constructor(kv) {
|
|
6316
|
+
this.kv = kv;
|
|
6317
|
+
}
|
|
6318
|
+
kv;
|
|
6319
|
+
key(rfcGroupId) {
|
|
6320
|
+
return `supp:${rfcGroupId}`;
|
|
6321
|
+
}
|
|
6322
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6323
|
+
async load(rfcGroupId) {
|
|
6324
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6325
|
+
if (!raw) return [];
|
|
6326
|
+
try {
|
|
6327
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6328
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6329
|
+
} catch {
|
|
6330
|
+
return [];
|
|
6331
|
+
}
|
|
6332
|
+
}
|
|
6333
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6334
|
+
async save(rfcGroupId, keys) {
|
|
6335
|
+
const sorted = [...new Set(keys)].sort();
|
|
6336
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6337
|
+
}
|
|
6338
|
+
async wipe() {
|
|
6339
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6340
|
+
}
|
|
6341
|
+
};
|
|
6342
|
+
|
|
5741
6343
|
// src/messaging/coordinator.ts
|
|
5742
6344
|
var MessagingCoordinator = class {
|
|
5743
6345
|
constructor(rt) {
|
|
@@ -5747,6 +6349,7 @@ var MessagingCoordinator = class {
|
|
|
5747
6349
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
5748
6350
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5749
6351
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6352
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5750
6353
|
this.registry.attachChatList(
|
|
5751
6354
|
(chats) => {
|
|
5752
6355
|
this.chatList = chats;
|
|
@@ -5761,6 +6364,7 @@ var MessagingCoordinator = class {
|
|
|
5761
6364
|
sigStore;
|
|
5762
6365
|
groupStore;
|
|
5763
6366
|
kpStore;
|
|
6367
|
+
suppressionStore;
|
|
5764
6368
|
registry = new GroupRegistry();
|
|
5765
6369
|
resolved = null;
|
|
5766
6370
|
resolvePromise = null;
|
|
@@ -5924,6 +6528,22 @@ var MessagingCoordinator = class {
|
|
|
5924
6528
|
const r = await this.resolve();
|
|
5925
6529
|
return r.groups.sendReaction(group, args);
|
|
5926
6530
|
}
|
|
6531
|
+
async sendEdit(group, args) {
|
|
6532
|
+
const r = await this.resolve();
|
|
6533
|
+
return r.groups.sendEdit(group, args);
|
|
6534
|
+
}
|
|
6535
|
+
async sendDelete(group, args) {
|
|
6536
|
+
const r = await this.resolve();
|
|
6537
|
+
return r.groups.sendDelete(group, args);
|
|
6538
|
+
}
|
|
6539
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6540
|
+
loadSuppressed(group) {
|
|
6541
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6542
|
+
}
|
|
6543
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6544
|
+
saveSuppressed(group, keys) {
|
|
6545
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6546
|
+
}
|
|
5927
6547
|
async history(group, limit, before) {
|
|
5928
6548
|
const r = await this.resolve();
|
|
5929
6549
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6020,9 +6640,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6020
6640
|
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6021
6641
|
});
|
|
6022
6642
|
}
|
|
6643
|
+
const editFold = new EditFold();
|
|
6644
|
+
const deleteFold = new DeleteFold();
|
|
6645
|
+
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6646
|
+
for (const s of rows) {
|
|
6647
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6648
|
+
continue;
|
|
6649
|
+
const cid = s.clientMsgId ?? "";
|
|
6650
|
+
if (!cid) continue;
|
|
6651
|
+
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6652
|
+
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6653
|
+
}
|
|
6654
|
+
const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
|
|
6655
|
+
for (const s of rows) {
|
|
6656
|
+
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6657
|
+
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6658
|
+
editFold.ingest(
|
|
6659
|
+
{
|
|
6660
|
+
targetClientMsgId: s.edit.targetClientMsgId,
|
|
6661
|
+
editorUserId: editor,
|
|
6662
|
+
newText: s.edit.newText,
|
|
6663
|
+
epoch: s.epoch,
|
|
6664
|
+
serverSeq: s.serverSeq,
|
|
6665
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6666
|
+
},
|
|
6667
|
+
authorOfTarget
|
|
6668
|
+
);
|
|
6669
|
+
}
|
|
6670
|
+
editFold.reevaluateHeld(authorOfTarget);
|
|
6671
|
+
for (const s of rows) {
|
|
6672
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6673
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6674
|
+
deleteFold.ingest(
|
|
6675
|
+
{
|
|
6676
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6677
|
+
actorUserId: actor,
|
|
6678
|
+
epoch: s.epoch,
|
|
6679
|
+
serverSeq: s.serverSeq,
|
|
6680
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6681
|
+
},
|
|
6682
|
+
authorOfTarget
|
|
6683
|
+
);
|
|
6684
|
+
}
|
|
6685
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
6023
6686
|
const lookup = /* @__PURE__ */ new Map();
|
|
6024
6687
|
for (const s of rows) {
|
|
6025
|
-
if (s.envelopeType === "reaction")
|
|
6688
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6689
|
+
continue;
|
|
6026
6690
|
const cid = s.clientMsgId ?? "";
|
|
6027
6691
|
if (cid && s.text !== null) {
|
|
6028
6692
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -6031,8 +6695,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6031
6695
|
}
|
|
6032
6696
|
const out = [];
|
|
6033
6697
|
for (const s of rows) {
|
|
6034
|
-
if (s.envelopeType === "reaction")
|
|
6698
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6699
|
+
continue;
|
|
6035
6700
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6701
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6702
|
+
if (isDeleted) {
|
|
6703
|
+
out.push({
|
|
6704
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6705
|
+
kind: "text",
|
|
6706
|
+
direction: s.direction,
|
|
6707
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6708
|
+
text: DELETED_DESCRIPTOR,
|
|
6709
|
+
serverSeq: s.serverSeq,
|
|
6710
|
+
sentAt: new Date(s.at),
|
|
6711
|
+
clientMsgId,
|
|
6712
|
+
replyTo: null,
|
|
6713
|
+
reactions: {},
|
|
6714
|
+
edited: false,
|
|
6715
|
+
isDeleted: true
|
|
6716
|
+
});
|
|
6717
|
+
continue;
|
|
6718
|
+
}
|
|
6036
6719
|
let replyTo = null;
|
|
6037
6720
|
if (s.replyTo) {
|
|
6038
6721
|
const ref = {
|
|
@@ -6047,17 +6730,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6047
6730
|
};
|
|
6048
6731
|
replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
|
|
6049
6732
|
}
|
|
6733
|
+
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6734
|
+
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
6050
6735
|
out.push({
|
|
6051
6736
|
id: `${displayId}#${s.serverSeq}`,
|
|
6052
6737
|
kind: s.text != null ? "text" : "system",
|
|
6053
6738
|
direction: s.direction,
|
|
6054
6739
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6055
|
-
text: s.text,
|
|
6740
|
+
text: editText ?? s.text,
|
|
6056
6741
|
serverSeq: s.serverSeq,
|
|
6057
6742
|
sentAt: new Date(s.at),
|
|
6058
6743
|
clientMsgId,
|
|
6059
6744
|
replyTo,
|
|
6060
|
-
reactions: clientMsgId ? fold.tally(clientMsgId) : {}
|
|
6745
|
+
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6746
|
+
edited,
|
|
6747
|
+
isDeleted: false
|
|
6061
6748
|
});
|
|
6062
6749
|
}
|
|
6063
6750
|
return out;
|
|
@@ -6795,7 +7482,7 @@ function defaultSessionStorage(key) {
|
|
|
6795
7482
|
}
|
|
6796
7483
|
|
|
6797
7484
|
// src/version.ts
|
|
6798
|
-
var VERSION = "1.
|
|
7485
|
+
var VERSION = "1.4.0";
|
|
6799
7486
|
|
|
6800
7487
|
// src/runtime.ts
|
|
6801
7488
|
function buildRuntime(config) {
|