@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
|
@@ -2591,6 +2591,151 @@ var PalbeFlags = class {
|
|
|
2591
2591
|
}
|
|
2592
2592
|
};
|
|
2593
2593
|
|
|
2594
|
+
// src/messaging/delete-fold.ts
|
|
2595
|
+
var DeleteFold = class {
|
|
2596
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2597
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2598
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2599
|
+
pending = /* @__PURE__ */ new Map();
|
|
2600
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2601
|
+
seen = /* @__PURE__ */ new Set();
|
|
2602
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2603
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2604
|
+
held = [];
|
|
2605
|
+
/**
|
|
2606
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2607
|
+
* userId (null = target absent locally → defer).
|
|
2608
|
+
*/
|
|
2609
|
+
ingest(e, authorOfTarget) {
|
|
2610
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2611
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2612
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2613
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2614
|
+
if (author !== null) {
|
|
2615
|
+
this.seen.add(e.eventClientMsgId);
|
|
2616
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2617
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2618
|
+
} else if (e.actorUserId !== null) {
|
|
2619
|
+
this.seen.add(e.eventClientMsgId);
|
|
2620
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2621
|
+
} else {
|
|
2622
|
+
this.held.push(e);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2626
|
+
isTombstoned(targetClientMsgId) {
|
|
2627
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2631
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2632
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2633
|
+
* the in-order path.
|
|
2634
|
+
*/
|
|
2635
|
+
reevaluatePending(target, author) {
|
|
2636
|
+
const actor = this.pending.get(target);
|
|
2637
|
+
if (actor !== void 0) {
|
|
2638
|
+
if (author !== null && actor === author) {
|
|
2639
|
+
this.tombstoned.add(target);
|
|
2640
|
+
this.pending.delete(target);
|
|
2641
|
+
} else if (author !== null) {
|
|
2642
|
+
this.pending.delete(target);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
if (this.held.length === 0) return;
|
|
2646
|
+
const pendingHeld = this.held;
|
|
2647
|
+
this.held = [];
|
|
2648
|
+
for (const e of pendingHeld) {
|
|
2649
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
heldContains(eventClientMsgId) {
|
|
2653
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
|
|
2657
|
+
// src/messaging/edit-fold.ts
|
|
2658
|
+
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2659
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
2660
|
+
return aSeq < bSeq;
|
|
2661
|
+
}
|
|
2662
|
+
function orderEq(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2663
|
+
return aEpoch === bEpoch && aSeq === bSeq;
|
|
2664
|
+
}
|
|
2665
|
+
var EditFold = class {
|
|
2666
|
+
// target → winning edit state
|
|
2667
|
+
states = /* @__PURE__ */ new Map();
|
|
2668
|
+
// dedup of real wire events that reached (and were resolvable enough to evaluate at) the fold
|
|
2669
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
2670
|
+
// events parked because target/author or sender was unresolved at ingest time
|
|
2671
|
+
held = [];
|
|
2672
|
+
// targets that have had ≥1 valid edit applied (write-once)
|
|
2673
|
+
editedTargets = /* @__PURE__ */ new Set();
|
|
2674
|
+
/**
|
|
2675
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's AUTHOR userId
|
|
2676
|
+
* (null = target unknown/dangling → HOLD).
|
|
2677
|
+
*/
|
|
2678
|
+
ingest(e, authorOfTarget) {
|
|
2679
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2680
|
+
if (author === null) {
|
|
2681
|
+
this.holdIfNew(e);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
if (e.editorUserId === null) {
|
|
2685
|
+
this.holdIfNew(e);
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
if (e.editorUserId !== author) return;
|
|
2689
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
2690
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2691
|
+
const prev = this.states.get(e.targetClientMsgId);
|
|
2692
|
+
if (prev !== void 0) {
|
|
2693
|
+
if (orderLt(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
|
|
2694
|
+
if (orderEq(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq) && e.eventClientMsgId <= prev.lastEventId) {
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
this.states.set(e.targetClientMsgId, {
|
|
2699
|
+
orderEpoch: e.epoch,
|
|
2700
|
+
orderSeq: e.serverSeq,
|
|
2701
|
+
lastEventId: e.eventClientMsgId,
|
|
2702
|
+
text: e.newText
|
|
2703
|
+
});
|
|
2704
|
+
this.editedTargets.add(e.targetClientMsgId);
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Park an event for later re-attempt, deduping held re-deliveries by
|
|
2708
|
+
* eventClientMsgId so a repeatedly-delivered unresolvable edit is held exactly
|
|
2709
|
+
* once (and never double-applies when it finally resolves on reevaluate).
|
|
2710
|
+
*/
|
|
2711
|
+
holdIfNew(e) {
|
|
2712
|
+
if (this.held.some((h) => h.eventClientMsgId === e.eventClientMsgId)) return;
|
|
2713
|
+
this.held.push(e);
|
|
2714
|
+
}
|
|
2715
|
+
/** The winning edit text for a target, or null if no valid edit has applied. */
|
|
2716
|
+
text(targetClientMsgId) {
|
|
2717
|
+
return this.states.get(targetClientMsgId)?.text ?? null;
|
|
2718
|
+
}
|
|
2719
|
+
/** Write-once: true once any valid edit applied to the target. */
|
|
2720
|
+
isEdited(targetClientMsgId) {
|
|
2721
|
+
return this.editedTargets.has(targetClientMsgId);
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2725
|
+
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
2726
|
+
* event with the fresh `authorOfTarget` — events that still don't resolve are
|
|
2727
|
+
* simply re-held; events that now resolve fold via the normal LWW path.
|
|
2728
|
+
* Idempotent: re-ingest is deduped by `seenEvents` (applied events) and by
|
|
2729
|
+
* `holdIfNew` (still-held events), so reevaluating repeatedly can neither
|
|
2730
|
+
* double-apply nor lose an edit.
|
|
2731
|
+
*/
|
|
2732
|
+
reevaluateHeld(authorOfTarget) {
|
|
2733
|
+
const pending = this.held;
|
|
2734
|
+
this.held = [];
|
|
2735
|
+
for (const e of pending) this.ingest(e, authorOfTarget);
|
|
2736
|
+
}
|
|
2737
|
+
};
|
|
2738
|
+
|
|
2594
2739
|
// src/messaging/util.ts
|
|
2595
2740
|
function toBase64(bytes) {
|
|
2596
2741
|
if (typeof Buffer !== "undefined") {
|
|
@@ -2730,6 +2875,28 @@ async function listDevices(rt, userId) {
|
|
|
2730
2875
|
}
|
|
2731
2876
|
|
|
2732
2877
|
// src/messaging/group-messaging.ts
|
|
2878
|
+
function encodeDelete(args) {
|
|
2879
|
+
return encodeUtf8(
|
|
2880
|
+
JSON.stringify({
|
|
2881
|
+
v: 1,
|
|
2882
|
+
type: "delete",
|
|
2883
|
+
client_msg_id: args.clientMsgId,
|
|
2884
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2885
|
+
scope: "everyone"
|
|
2886
|
+
})
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
function encodeEdit(args) {
|
|
2890
|
+
return encodeUtf8(
|
|
2891
|
+
JSON.stringify({
|
|
2892
|
+
v: 1,
|
|
2893
|
+
type: "edit",
|
|
2894
|
+
client_msg_id: args.clientMsgId,
|
|
2895
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2896
|
+
new_text: args.newText
|
|
2897
|
+
})
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2733
2900
|
function encodeReaction(args) {
|
|
2734
2901
|
return encodeUtf8(
|
|
2735
2902
|
JSON.stringify({
|
|
@@ -2756,6 +2923,18 @@ function decodeEnvelope(bytes) {
|
|
|
2756
2923
|
const s = decodeUtf8(bytes);
|
|
2757
2924
|
try {
|
|
2758
2925
|
const o = JSON.parse(s);
|
|
2926
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2927
|
+
return {
|
|
2928
|
+
type: "delete",
|
|
2929
|
+
text: null,
|
|
2930
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2931
|
+
replyTo: null,
|
|
2932
|
+
delete: {
|
|
2933
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2934
|
+
scope: o.scope ?? "everyone"
|
|
2935
|
+
}
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2759
2938
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2760
2939
|
return {
|
|
2761
2940
|
type: "reaction",
|
|
@@ -2769,6 +2948,18 @@ function decodeEnvelope(bytes) {
|
|
|
2769
2948
|
}
|
|
2770
2949
|
};
|
|
2771
2950
|
}
|
|
2951
|
+
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2952
|
+
return {
|
|
2953
|
+
type: "edit",
|
|
2954
|
+
text: null,
|
|
2955
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2956
|
+
replyTo: null,
|
|
2957
|
+
edit: {
|
|
2958
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2959
|
+
newText: o.new_text ?? ""
|
|
2960
|
+
}
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2772
2963
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2773
2964
|
return {
|
|
2774
2965
|
type: "text",
|
|
@@ -3083,6 +3274,103 @@ var GroupMessaging = class {
|
|
|
3083
3274
|
clientMsgId: args.clientMsgId
|
|
3084
3275
|
};
|
|
3085
3276
|
}
|
|
3277
|
+
/** Send an edit (edit-by-supersession on a target message). Encrypts a
|
|
3278
|
+
* `type:'edit'` envelope at the current epoch and sends through the SAME MLS
|
|
3279
|
+
* application path as `sendText` (the server stays blind — an edit is just
|
|
3280
|
+
* another application message). Persists the outgoing edit row so it re-folds
|
|
3281
|
+
* onto its target's text after a reload (the own-send half of the reload
|
|
3282
|
+
* parity). NEVER rebases (epoch-bound like any application message). */
|
|
3283
|
+
async sendEdit(group, args) {
|
|
3284
|
+
const plaintext = encodeEdit({
|
|
3285
|
+
clientMsgId: args.clientMsgId,
|
|
3286
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3287
|
+
newText: args.newText
|
|
3288
|
+
});
|
|
3289
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3290
|
+
const body = {
|
|
3291
|
+
ciphertext_b64: toBase64(ct),
|
|
3292
|
+
client_idem_key: randomId()
|
|
3293
|
+
};
|
|
3294
|
+
const wire = await palbeRequest(
|
|
3295
|
+
this.rt,
|
|
3296
|
+
"POST",
|
|
3297
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3298
|
+
{ body }
|
|
3299
|
+
);
|
|
3300
|
+
const stored = {
|
|
3301
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3302
|
+
direction: "outgoing",
|
|
3303
|
+
text: null,
|
|
3304
|
+
senderDeviceId: this.selfDeviceId,
|
|
3305
|
+
epoch: wire.epoch,
|
|
3306
|
+
serverSeq: wire.server_seq,
|
|
3307
|
+
at: Date.now(),
|
|
3308
|
+
clientMsgId: args.clientMsgId,
|
|
3309
|
+
replyTo: null,
|
|
3310
|
+
envelopeType: "edit",
|
|
3311
|
+
edit: {
|
|
3312
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3313
|
+
newText: args.newText
|
|
3314
|
+
}
|
|
3315
|
+
};
|
|
3316
|
+
try {
|
|
3317
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3318
|
+
} catch {
|
|
3319
|
+
}
|
|
3320
|
+
return {
|
|
3321
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3322
|
+
clientMsgId: args.clientMsgId
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3326
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3327
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3328
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3329
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3330
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3331
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3332
|
+
* rebases (epoch-bound like any application message). */
|
|
3333
|
+
async sendDelete(group, args) {
|
|
3334
|
+
const plaintext = encodeDelete({
|
|
3335
|
+
clientMsgId: args.clientMsgId,
|
|
3336
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3337
|
+
});
|
|
3338
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3339
|
+
const body = {
|
|
3340
|
+
ciphertext_b64: toBase64(ct),
|
|
3341
|
+
client_idem_key: randomId()
|
|
3342
|
+
};
|
|
3343
|
+
const wire = await palbeRequest(
|
|
3344
|
+
this.rt,
|
|
3345
|
+
"POST",
|
|
3346
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3347
|
+
{ body }
|
|
3348
|
+
);
|
|
3349
|
+
const stored = {
|
|
3350
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3351
|
+
direction: "outgoing",
|
|
3352
|
+
text: null,
|
|
3353
|
+
senderDeviceId: this.selfDeviceId,
|
|
3354
|
+
epoch: wire.epoch,
|
|
3355
|
+
serverSeq: wire.server_seq,
|
|
3356
|
+
at: Date.now(),
|
|
3357
|
+
clientMsgId: args.clientMsgId,
|
|
3358
|
+
replyTo: null,
|
|
3359
|
+
envelopeType: "delete",
|
|
3360
|
+
delete: {
|
|
3361
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3362
|
+
scope: "everyone"
|
|
3363
|
+
}
|
|
3364
|
+
};
|
|
3365
|
+
try {
|
|
3366
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3367
|
+
} catch {
|
|
3368
|
+
}
|
|
3369
|
+
return {
|
|
3370
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3371
|
+
clientMsgId: args.clientMsgId
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3086
3374
|
// ── The rebase loop ──
|
|
3087
3375
|
async commitWithRebase(rfcGroupId, build) {
|
|
3088
3376
|
const gidBytes = fromBase64(rfcGroupId);
|
|
@@ -3200,6 +3488,7 @@ var ReactionFold = class {
|
|
|
3200
3488
|
};
|
|
3201
3489
|
|
|
3202
3490
|
// src/messaging/chat.ts
|
|
3491
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3203
3492
|
var Chat = class {
|
|
3204
3493
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3205
3494
|
id;
|
|
@@ -3219,6 +3508,23 @@ var Chat = class {
|
|
|
3219
3508
|
byClientMsgId = /* @__PURE__ */ new Map();
|
|
3220
3509
|
/** The single authoritative reaction fold for this chat (live + own-send + history). */
|
|
3221
3510
|
reactionFold = new ReactionFold();
|
|
3511
|
+
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3512
|
+
editFold = new EditFold();
|
|
3513
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3514
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3515
|
+
deleteFold = new DeleteFold();
|
|
3516
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3517
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3518
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3519
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3520
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3521
|
+
suppressedLoaded = false;
|
|
3522
|
+
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3523
|
+
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3524
|
+
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
3525
|
+
/** Per-target AUTHOR userId — the EditFold author-gate input (filled at bubble
|
|
3526
|
+
* projection time from senderUserId; '' = resolved-but-unknown peer). */
|
|
3527
|
+
authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
3222
3528
|
loadedEarliestSeq = null;
|
|
3223
3529
|
historyLoaded = false;
|
|
3224
3530
|
wired = false;
|
|
@@ -3261,7 +3567,7 @@ var Chat = class {
|
|
|
3261
3567
|
return this.kind === "direct";
|
|
3262
3568
|
}
|
|
3263
3569
|
get messages() {
|
|
3264
|
-
return this.
|
|
3570
|
+
return this.surfaced();
|
|
3265
3571
|
}
|
|
3266
3572
|
get members() {
|
|
3267
3573
|
return this.memberCache;
|
|
@@ -3270,13 +3576,49 @@ var Chat = class {
|
|
|
3270
3576
|
return this.typingList;
|
|
3271
3577
|
}
|
|
3272
3578
|
get lastMessage() {
|
|
3273
|
-
return this.
|
|
3579
|
+
return this.surfaced().at(-1) ?? null;
|
|
3274
3580
|
}
|
|
3275
3581
|
get unreadCount() {
|
|
3276
|
-
return this.
|
|
3277
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3582
|
+
return this.surfaced().filter(
|
|
3583
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3278
3584
|
).length;
|
|
3279
3585
|
}
|
|
3586
|
+
/**
|
|
3587
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3588
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3589
|
+
* folded edit text + reactions + reply):
|
|
3590
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3591
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3592
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3593
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3594
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3595
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3596
|
+
*/
|
|
3597
|
+
surfaced() {
|
|
3598
|
+
const out = [];
|
|
3599
|
+
for (const m of this.messageList) {
|
|
3600
|
+
const key = this.suppressionKey(m);
|
|
3601
|
+
if (this.suppressed.has(key)) continue;
|
|
3602
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3603
|
+
if (tombstoned) {
|
|
3604
|
+
out.push({
|
|
3605
|
+
...m,
|
|
3606
|
+
text: DELETED_DESCRIPTOR,
|
|
3607
|
+
reactions: {},
|
|
3608
|
+
replyTo: null,
|
|
3609
|
+
edited: false,
|
|
3610
|
+
isDeleted: true
|
|
3611
|
+
});
|
|
3612
|
+
continue;
|
|
3613
|
+
}
|
|
3614
|
+
out.push(m);
|
|
3615
|
+
}
|
|
3616
|
+
return out;
|
|
3617
|
+
}
|
|
3618
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3619
|
+
suppressionKey(m) {
|
|
3620
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3621
|
+
}
|
|
3280
3622
|
get title() {
|
|
3281
3623
|
if (this.titleOverride) return this.titleOverride;
|
|
3282
3624
|
if (this._group?.name) return this._group.name;
|
|
@@ -3300,9 +3642,28 @@ var Chat = class {
|
|
|
3300
3642
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3301
3643
|
this.wired = true;
|
|
3302
3644
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3645
|
+
void this.loadSuppressed();
|
|
3303
3646
|
void this.hydrateHistory();
|
|
3304
3647
|
void this.refreshMembers();
|
|
3305
3648
|
}
|
|
3649
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3650
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3651
|
+
async loadSuppressed() {
|
|
3652
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3653
|
+
this.suppressedLoaded = true;
|
|
3654
|
+
try {
|
|
3655
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3656
|
+
let changed = false;
|
|
3657
|
+
for (const k of keys) {
|
|
3658
|
+
if (!this.suppressed.has(k)) {
|
|
3659
|
+
this.suppressed.add(k);
|
|
3660
|
+
changed = true;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
if (changed) this.emit();
|
|
3664
|
+
} catch {
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3306
3667
|
async hydrateHistory() {
|
|
3307
3668
|
if (this.historyLoaded || !this._group) return;
|
|
3308
3669
|
this.historyLoaded = true;
|
|
@@ -3313,19 +3674,26 @@ var Chat = class {
|
|
|
3313
3674
|
let changed = false;
|
|
3314
3675
|
for (const m of incoming) {
|
|
3315
3676
|
if (m.serverSeq <= 0) continue;
|
|
3316
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3677
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3317
3678
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3318
3679
|
text: m.text,
|
|
3319
3680
|
senderUserId: m.senderUserId ?? ""
|
|
3320
3681
|
});
|
|
3321
3682
|
}
|
|
3683
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3684
|
+
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3685
|
+
}
|
|
3322
3686
|
}
|
|
3687
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3323
3688
|
for (const m of incoming) {
|
|
3324
3689
|
if (m.serverSeq <= 0) continue;
|
|
3325
3690
|
const key = this.internalKey(m.serverSeq);
|
|
3326
3691
|
if (this.seenKeys.has(key)) continue;
|
|
3327
3692
|
this.seenKeys.add(key);
|
|
3328
|
-
|
|
3693
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3694
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3695
|
+
}
|
|
3696
|
+
this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
|
|
3329
3697
|
changed = true;
|
|
3330
3698
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3331
3699
|
}
|
|
@@ -3366,6 +3734,37 @@ var Chat = class {
|
|
|
3366
3734
|
}
|
|
3367
3735
|
return;
|
|
3368
3736
|
}
|
|
3737
|
+
if (incoming.envelopeType === "edit" && incoming.edit) {
|
|
3738
|
+
const editorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3739
|
+
this.editFold.ingest(
|
|
3740
|
+
{
|
|
3741
|
+
targetClientMsgId: incoming.edit.targetClientMsgId,
|
|
3742
|
+
editorUserId,
|
|
3743
|
+
newText: incoming.edit.newText,
|
|
3744
|
+
epoch: incoming.epoch,
|
|
3745
|
+
serverSeq: incoming.serverSeq,
|
|
3746
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3747
|
+
},
|
|
3748
|
+
this.authorOfTarget
|
|
3749
|
+
);
|
|
3750
|
+
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3751
|
+
return;
|
|
3752
|
+
}
|
|
3753
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3754
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3755
|
+
this.deleteFold.ingest(
|
|
3756
|
+
{
|
|
3757
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3758
|
+
actorUserId,
|
|
3759
|
+
epoch: incoming.epoch,
|
|
3760
|
+
serverSeq: incoming.serverSeq,
|
|
3761
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3762
|
+
},
|
|
3763
|
+
this.authorOfTarget
|
|
3764
|
+
);
|
|
3765
|
+
this.emit();
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3369
3768
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3370
3769
|
const incomingReplyRef = incoming.replyRef;
|
|
3371
3770
|
let resolvedReplyTo = null;
|
|
@@ -3384,7 +3783,11 @@ var Chat = class {
|
|
|
3384
3783
|
replyTo: resolvedReplyTo,
|
|
3385
3784
|
// Attach any tally already folded for this message (a reaction that arrived
|
|
3386
3785
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3387
|
-
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
|
|
3786
|
+
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3787
|
+
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3788
|
+
edited: false,
|
|
3789
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3790
|
+
isDeleted: false
|
|
3388
3791
|
};
|
|
3389
3792
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3390
3793
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
@@ -3392,7 +3795,12 @@ var Chat = class {
|
|
|
3392
3795
|
senderUserId: senderUser ?? ""
|
|
3393
3796
|
});
|
|
3394
3797
|
}
|
|
3395
|
-
|
|
3798
|
+
if (incomingClientMsgId) {
|
|
3799
|
+
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3800
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3801
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3802
|
+
}
|
|
3803
|
+
this.messageList.push(this.applyEditOverlay(msg));
|
|
3396
3804
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3397
3805
|
this.loadedEarliestSeq = Math.min(
|
|
3398
3806
|
this.loadedEarliestSeq ?? incoming.serverSeq,
|
|
@@ -3400,6 +3808,19 @@ var Chat = class {
|
|
|
3400
3808
|
);
|
|
3401
3809
|
this.emit();
|
|
3402
3810
|
}
|
|
3811
|
+
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3812
|
+
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3813
|
+
* so it can be passed to the pure EditFold. */
|
|
3814
|
+
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3815
|
+
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3816
|
+
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3817
|
+
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
3818
|
+
seedEditBase(clientMsgId, text, author) {
|
|
3819
|
+
if (!this.originalTextByClientMsgId.has(clientMsgId)) {
|
|
3820
|
+
this.originalTextByClientMsgId.set(clientMsgId, text);
|
|
3821
|
+
}
|
|
3822
|
+
if (author !== null) this.authorByClientMsgId.set(clientMsgId, author);
|
|
3823
|
+
}
|
|
3403
3824
|
/**
|
|
3404
3825
|
* Rebuild the target message's `reactions` from the authoritative fold and
|
|
3405
3826
|
* re-emit. No-op when the target isn't present yet (its tally is attached the
|
|
@@ -3429,6 +3850,46 @@ var Chat = class {
|
|
|
3429
3850
|
if (sameReactions(m.reactions, tally)) return m;
|
|
3430
3851
|
return { ...m, reactions: tally };
|
|
3431
3852
|
}
|
|
3853
|
+
/**
|
|
3854
|
+
* Rebuild the target message's rendered `text` + `edited` flag from the
|
|
3855
|
+
* authoritative edit fold and re-emit, PRESERVING `.reactions` and `.replyTo`
|
|
3856
|
+
* (the reaction-polish lesson — never clobber). text = `editFold.text(cid) ??
|
|
3857
|
+
* base`; base is the seeded original so a forged/ignored edit leaves it intact.
|
|
3858
|
+
* No-op when the target isn't present yet (the fold already recorded it; the
|
|
3859
|
+
* overlay applies the moment the target lands) or when unchanged.
|
|
3860
|
+
*/
|
|
3861
|
+
recomputeEdit(targetClientMsgId) {
|
|
3862
|
+
if (!targetClientMsgId) return;
|
|
3863
|
+
const editText = this.editFold.text(targetClientMsgId);
|
|
3864
|
+
const foldEdited = this.editFold.isEdited(targetClientMsgId);
|
|
3865
|
+
let changed = false;
|
|
3866
|
+
this.messageList = this.messageList.map((m) => {
|
|
3867
|
+
if (m.clientMsgId !== targetClientMsgId) return m;
|
|
3868
|
+
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3869
|
+
const text = editText ?? base;
|
|
3870
|
+
const edited = foldEdited || m.edited;
|
|
3871
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3872
|
+
changed = true;
|
|
3873
|
+
return { ...m, text, edited };
|
|
3874
|
+
});
|
|
3875
|
+
if (changed) this.emit();
|
|
3876
|
+
}
|
|
3877
|
+
/**
|
|
3878
|
+
* Overlay the authoritative edit fold's winning text + flag onto a message as it
|
|
3879
|
+
* is appended/merged. The fold WINS when it has an edit for this target;
|
|
3880
|
+
* otherwise the upstream `text`/`edited` (e.g. the coordinator's page-local
|
|
3881
|
+
* history fold) is preserved. PRESERVES reactions + replyTo.
|
|
3882
|
+
*/
|
|
3883
|
+
applyEditOverlay(m) {
|
|
3884
|
+
if (!m.clientMsgId) return m;
|
|
3885
|
+
const editText = this.editFold.text(m.clientMsgId);
|
|
3886
|
+
const foldEdited = this.editFold.isEdited(m.clientMsgId);
|
|
3887
|
+
if (editText === null && !foldEdited) return m;
|
|
3888
|
+
const text = editText ?? m.text;
|
|
3889
|
+
const edited = foldEdited || m.edited;
|
|
3890
|
+
if (m.text === text && m.edited === edited) return m;
|
|
3891
|
+
return { ...m, text, edited };
|
|
3892
|
+
}
|
|
3432
3893
|
/** @internal — called by the backend's conv subscription. */
|
|
3433
3894
|
applyConv(event, payload) {
|
|
3434
3895
|
const userId = typeof payload.user_id === "string" ? payload.user_id : null;
|
|
@@ -3483,6 +3944,8 @@ var Chat = class {
|
|
|
3483
3944
|
this.memberCache = m;
|
|
3484
3945
|
this.emit();
|
|
3485
3946
|
}
|
|
3947
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3948
|
+
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3486
3949
|
}
|
|
3487
3950
|
seedMembersFromGroup(group) {
|
|
3488
3951
|
const seed = [
|
|
@@ -3561,6 +4024,7 @@ var Chat = class {
|
|
|
3561
4024
|
this.seenKeys.add(key);
|
|
3562
4025
|
if (clientMsgId) {
|
|
3563
4026
|
this.byClientMsgId.set(clientMsgId, { text, senderUserId: this.backend.selfUserId });
|
|
4027
|
+
this.seedEditBase(clientMsgId, text, this.backend.selfUserId);
|
|
3564
4028
|
}
|
|
3565
4029
|
this.messageList.push({
|
|
3566
4030
|
id: this.publicId(receipt.serverSeq),
|
|
@@ -3574,7 +4038,11 @@ var Chat = class {
|
|
|
3574
4038
|
replyTo: resolvedReplyTo,
|
|
3575
4039
|
// Attach any tally already folded for this own-sent message (rare, but keeps
|
|
3576
4040
|
// the dangling-target invariant uniform across every append path).
|
|
3577
|
-
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
|
|
4041
|
+
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
4042
|
+
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
4043
|
+
edited: false,
|
|
4044
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4045
|
+
isDeleted: false
|
|
3578
4046
|
});
|
|
3579
4047
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3580
4048
|
this.emit();
|
|
@@ -3655,6 +4123,83 @@ var Chat = class {
|
|
|
3655
4123
|
});
|
|
3656
4124
|
this.recomputeReactions(message.clientMsgId);
|
|
3657
4125
|
}
|
|
4126
|
+
// ── Edit ──
|
|
4127
|
+
/** Edit an own text message (edit-by-supersession). No-op if the message isn't
|
|
4128
|
+
* editable (empty clientMsgId, or not a `text` kind). The edit folds locally
|
|
4129
|
+
* with the server receipt's `(epoch, serverSeq)` so the target's text updates
|
|
4130
|
+
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
4131
|
+
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
4132
|
+
* reactions + reply context. Only the original author's edits count — for an own
|
|
4133
|
+
* message self IS the author, so the author-gate passes. */
|
|
4134
|
+
async edit(message, newText) {
|
|
4135
|
+
if (!message.clientMsgId || message.kind !== "text") return;
|
|
4136
|
+
const group = await this.materializeIfNeeded();
|
|
4137
|
+
const clientMsgId = mintClientMsgId();
|
|
4138
|
+
const { receipt } = await this.backend.sendEdit(group, {
|
|
4139
|
+
clientMsgId,
|
|
4140
|
+
targetClientMsgId: message.clientMsgId,
|
|
4141
|
+
newText
|
|
4142
|
+
});
|
|
4143
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4144
|
+
this.editFold.ingest(
|
|
4145
|
+
{
|
|
4146
|
+
targetClientMsgId: message.clientMsgId,
|
|
4147
|
+
editorUserId: this.backend.selfUserId,
|
|
4148
|
+
newText,
|
|
4149
|
+
epoch: receipt.epoch,
|
|
4150
|
+
serverSeq: receipt.serverSeq,
|
|
4151
|
+
eventClientMsgId: clientMsgId
|
|
4152
|
+
},
|
|
4153
|
+
this.authorOfTarget
|
|
4154
|
+
);
|
|
4155
|
+
this.recomputeEdit(message.clientMsgId);
|
|
4156
|
+
}
|
|
4157
|
+
// ── Delete ──
|
|
4158
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
4159
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
4160
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
4161
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
4162
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
4163
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
4164
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
4165
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
4166
|
+
async deleteForEveryone(message) {
|
|
4167
|
+
if (!message.clientMsgId) return;
|
|
4168
|
+
const group = await this.materializeIfNeeded();
|
|
4169
|
+
const clientMsgId = mintClientMsgId();
|
|
4170
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
4171
|
+
clientMsgId,
|
|
4172
|
+
targetClientMsgId: message.clientMsgId
|
|
4173
|
+
});
|
|
4174
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4175
|
+
this.deleteFold.ingest(
|
|
4176
|
+
{
|
|
4177
|
+
targetClientMsgId: message.clientMsgId,
|
|
4178
|
+
actorUserId: this.backend.selfUserId,
|
|
4179
|
+
epoch: receipt.epoch,
|
|
4180
|
+
serverSeq: receipt.serverSeq,
|
|
4181
|
+
eventClientMsgId: clientMsgId
|
|
4182
|
+
},
|
|
4183
|
+
this.authorOfTarget
|
|
4184
|
+
);
|
|
4185
|
+
this.emit();
|
|
4186
|
+
}
|
|
4187
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4188
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4189
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4190
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4191
|
+
async deleteForMe(message) {
|
|
4192
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4193
|
+
if (this.suppressed.has(key)) return;
|
|
4194
|
+
this.suppressed.add(key);
|
|
4195
|
+
this.emit();
|
|
4196
|
+
if (this._group) {
|
|
4197
|
+
try {
|
|
4198
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4199
|
+
} catch {
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
3658
4203
|
};
|
|
3659
4204
|
function sameReactions(a, b) {
|
|
3660
4205
|
const ak = Object.keys(a);
|
|
@@ -3834,6 +4379,8 @@ var MessageDeliverySource = class {
|
|
|
3834
4379
|
const decoded = decodeEnvelope(received.data);
|
|
3835
4380
|
const { text, clientMsgId, replyTo } = decoded;
|
|
3836
4381
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4382
|
+
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4383
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
3837
4384
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3838
4385
|
const stored = {
|
|
3839
4386
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3861,6 +4408,29 @@ var MessageDeliverySource = class {
|
|
|
3861
4408
|
emoji: decoded.reaction.emoji,
|
|
3862
4409
|
op: decoded.reaction.op
|
|
3863
4410
|
}
|
|
4411
|
+
} : {},
|
|
4412
|
+
// Thread the edit discriminator + new text through the persisted row so an
|
|
4413
|
+
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4414
|
+
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4415
|
+
// `'text'`/no-edit (backward-compat).
|
|
4416
|
+
...isEdit && decoded.edit ? {
|
|
4417
|
+
envelopeType: "edit",
|
|
4418
|
+
edit: {
|
|
4419
|
+
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4420
|
+
newText: decoded.edit.newText
|
|
4421
|
+
}
|
|
4422
|
+
} : {},
|
|
4423
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4424
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4425
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4426
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4427
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4428
|
+
...isDelete && decoded.delete ? {
|
|
4429
|
+
envelopeType: "delete",
|
|
4430
|
+
delete: {
|
|
4431
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4432
|
+
scope: decoded.delete.scope
|
|
4433
|
+
}
|
|
3864
4434
|
} : {}
|
|
3865
4435
|
};
|
|
3866
4436
|
try {
|
|
@@ -3879,7 +4449,9 @@ var MessageDeliverySource = class {
|
|
|
3879
4449
|
clientMsgId,
|
|
3880
4450
|
replyRef: replyTo,
|
|
3881
4451
|
envelopeType: decoded.type ?? "text",
|
|
3882
|
-
reaction: isReaction ? decoded.reaction : null
|
|
4452
|
+
reaction: isReaction ? decoded.reaction : null,
|
|
4453
|
+
edit: isEdit ? decoded.edit : null,
|
|
4454
|
+
delete: isDelete ? decoded.delete : null
|
|
3883
4455
|
});
|
|
3884
4456
|
return true;
|
|
3885
4457
|
}
|
|
@@ -5700,6 +6272,36 @@ var SignatureKeyStore = class {
|
|
|
5700
6272
|
}
|
|
5701
6273
|
};
|
|
5702
6274
|
|
|
6275
|
+
// src/messaging/suppression.ts
|
|
6276
|
+
var SuppressionStore = class {
|
|
6277
|
+
constructor(kv) {
|
|
6278
|
+
this.kv = kv;
|
|
6279
|
+
}
|
|
6280
|
+
kv;
|
|
6281
|
+
key(rfcGroupId) {
|
|
6282
|
+
return `supp:${rfcGroupId}`;
|
|
6283
|
+
}
|
|
6284
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6285
|
+
async load(rfcGroupId) {
|
|
6286
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6287
|
+
if (!raw) return [];
|
|
6288
|
+
try {
|
|
6289
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6290
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6291
|
+
} catch {
|
|
6292
|
+
return [];
|
|
6293
|
+
}
|
|
6294
|
+
}
|
|
6295
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6296
|
+
async save(rfcGroupId, keys) {
|
|
6297
|
+
const sorted = [...new Set(keys)].sort();
|
|
6298
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6299
|
+
}
|
|
6300
|
+
async wipe() {
|
|
6301
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6302
|
+
}
|
|
6303
|
+
};
|
|
6304
|
+
|
|
5703
6305
|
// src/messaging/coordinator.ts
|
|
5704
6306
|
var MessagingCoordinator = class {
|
|
5705
6307
|
constructor(rt) {
|
|
@@ -5709,6 +6311,7 @@ var MessagingCoordinator = class {
|
|
|
5709
6311
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
5710
6312
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5711
6313
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6314
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5712
6315
|
this.registry.attachChatList(
|
|
5713
6316
|
(chats) => {
|
|
5714
6317
|
this.chatList = chats;
|
|
@@ -5723,6 +6326,7 @@ var MessagingCoordinator = class {
|
|
|
5723
6326
|
sigStore;
|
|
5724
6327
|
groupStore;
|
|
5725
6328
|
kpStore;
|
|
6329
|
+
suppressionStore;
|
|
5726
6330
|
registry = new GroupRegistry();
|
|
5727
6331
|
resolved = null;
|
|
5728
6332
|
resolvePromise = null;
|
|
@@ -5886,6 +6490,22 @@ var MessagingCoordinator = class {
|
|
|
5886
6490
|
const r = await this.resolve();
|
|
5887
6491
|
return r.groups.sendReaction(group, args);
|
|
5888
6492
|
}
|
|
6493
|
+
async sendEdit(group, args) {
|
|
6494
|
+
const r = await this.resolve();
|
|
6495
|
+
return r.groups.sendEdit(group, args);
|
|
6496
|
+
}
|
|
6497
|
+
async sendDelete(group, args) {
|
|
6498
|
+
const r = await this.resolve();
|
|
6499
|
+
return r.groups.sendDelete(group, args);
|
|
6500
|
+
}
|
|
6501
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6502
|
+
loadSuppressed(group) {
|
|
6503
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6504
|
+
}
|
|
6505
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6506
|
+
saveSuppressed(group, keys) {
|
|
6507
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6508
|
+
}
|
|
5889
6509
|
async history(group, limit, before) {
|
|
5890
6510
|
const r = await this.resolve();
|
|
5891
6511
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -5982,9 +6602,53 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5982
6602
|
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
5983
6603
|
});
|
|
5984
6604
|
}
|
|
6605
|
+
const editFold = new EditFold();
|
|
6606
|
+
const deleteFold = new DeleteFold();
|
|
6607
|
+
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6608
|
+
for (const s of rows) {
|
|
6609
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6610
|
+
continue;
|
|
6611
|
+
const cid = s.clientMsgId ?? "";
|
|
6612
|
+
if (!cid) continue;
|
|
6613
|
+
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6614
|
+
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6615
|
+
}
|
|
6616
|
+
const authorOfTarget = (cid) => authorByClientMsgId.get(cid) ?? null;
|
|
6617
|
+
for (const s of rows) {
|
|
6618
|
+
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6619
|
+
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6620
|
+
editFold.ingest(
|
|
6621
|
+
{
|
|
6622
|
+
targetClientMsgId: s.edit.targetClientMsgId,
|
|
6623
|
+
editorUserId: editor,
|
|
6624
|
+
newText: s.edit.newText,
|
|
6625
|
+
epoch: s.epoch,
|
|
6626
|
+
serverSeq: s.serverSeq,
|
|
6627
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6628
|
+
},
|
|
6629
|
+
authorOfTarget
|
|
6630
|
+
);
|
|
6631
|
+
}
|
|
6632
|
+
editFold.reevaluateHeld(authorOfTarget);
|
|
6633
|
+
for (const s of rows) {
|
|
6634
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6635
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6636
|
+
deleteFold.ingest(
|
|
6637
|
+
{
|
|
6638
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6639
|
+
actorUserId: actor,
|
|
6640
|
+
epoch: s.epoch,
|
|
6641
|
+
serverSeq: s.serverSeq,
|
|
6642
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6643
|
+
},
|
|
6644
|
+
authorOfTarget
|
|
6645
|
+
);
|
|
6646
|
+
}
|
|
6647
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
5985
6648
|
const lookup = /* @__PURE__ */ new Map();
|
|
5986
6649
|
for (const s of rows) {
|
|
5987
|
-
if (s.envelopeType === "reaction")
|
|
6650
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6651
|
+
continue;
|
|
5988
6652
|
const cid = s.clientMsgId ?? "";
|
|
5989
6653
|
if (cid && s.text !== null) {
|
|
5990
6654
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -5993,8 +6657,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5993
6657
|
}
|
|
5994
6658
|
const out = [];
|
|
5995
6659
|
for (const s of rows) {
|
|
5996
|
-
if (s.envelopeType === "reaction")
|
|
6660
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6661
|
+
continue;
|
|
5997
6662
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6663
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6664
|
+
if (isDeleted) {
|
|
6665
|
+
out.push({
|
|
6666
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6667
|
+
kind: "text",
|
|
6668
|
+
direction: s.direction,
|
|
6669
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6670
|
+
text: DELETED_DESCRIPTOR,
|
|
6671
|
+
serverSeq: s.serverSeq,
|
|
6672
|
+
sentAt: new Date(s.at),
|
|
6673
|
+
clientMsgId,
|
|
6674
|
+
replyTo: null,
|
|
6675
|
+
reactions: {},
|
|
6676
|
+
edited: false,
|
|
6677
|
+
isDeleted: true
|
|
6678
|
+
});
|
|
6679
|
+
continue;
|
|
6680
|
+
}
|
|
5998
6681
|
let replyTo = null;
|
|
5999
6682
|
if (s.replyTo) {
|
|
6000
6683
|
const ref = {
|
|
@@ -6009,17 +6692,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6009
6692
|
};
|
|
6010
6693
|
replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
|
|
6011
6694
|
}
|
|
6695
|
+
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6696
|
+
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
6012
6697
|
out.push({
|
|
6013
6698
|
id: `${displayId}#${s.serverSeq}`,
|
|
6014
6699
|
kind: s.text != null ? "text" : "system",
|
|
6015
6700
|
direction: s.direction,
|
|
6016
6701
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6017
|
-
text: s.text,
|
|
6702
|
+
text: editText ?? s.text,
|
|
6018
6703
|
serverSeq: s.serverSeq,
|
|
6019
6704
|
sentAt: new Date(s.at),
|
|
6020
6705
|
clientMsgId,
|
|
6021
6706
|
replyTo,
|
|
6022
|
-
reactions: clientMsgId ? fold.tally(clientMsgId) : {}
|
|
6707
|
+
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6708
|
+
edited,
|
|
6709
|
+
isDeleted: false
|
|
6023
6710
|
});
|
|
6024
6711
|
}
|
|
6025
6712
|
return out;
|
|
@@ -6757,7 +7444,7 @@ function defaultSessionStorage(key) {
|
|
|
6757
7444
|
}
|
|
6758
7445
|
|
|
6759
7446
|
// src/version.ts
|
|
6760
|
-
var VERSION = "1.
|
|
7447
|
+
var VERSION = "1.4.0";
|
|
6761
7448
|
|
|
6762
7449
|
// src/runtime.ts
|
|
6763
7450
|
function buildRuntime(config) {
|
|
@@ -7117,4 +7804,4 @@ export {
|
|
|
7117
7804
|
pb,
|
|
7118
7805
|
createBoundClient
|
|
7119
7806
|
};
|
|
7120
|
-
//# sourceMappingURL=chunk-
|
|
7807
|
+
//# sourceMappingURL=chunk-3EVGYJ5F.js.map
|