@palbase/web 1.3.0 → 1.5.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-Cq7R-VH_.d.ts → analytics-facade-BQlTfsEm.d.ts} +168 -4
- package/dist/{analytics-facade-B8UATgS4.d.cts → analytics-facade-DvsLE_qz.d.cts} +168 -4
- package/dist/{chunk-UX43AB4W.js → chunk-MBA2NAKS.js} +694 -44
- package/dist/chunk-MBA2NAKS.js.map +1 -0
- package/dist/index.cjs +693 -43
- 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 +693 -43
- 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 +693 -43
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +693 -43
- 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-DSJiH1uV.d.ts → pb-Cl7TeyCR.d.ts} +1 -1
- package/dist/{pb-DivIiUGR.d.cts → pb-a86jrG9h.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 +3 -3
- package/dist/chunk-UX43AB4W.js.map +0 -1
package/dist/internal.cjs
CHANGED
|
@@ -2612,6 +2612,69 @@ var PalbeFlags = class {
|
|
|
2612
2612
|
}
|
|
2613
2613
|
};
|
|
2614
2614
|
|
|
2615
|
+
// src/messaging/delete-fold.ts
|
|
2616
|
+
var DeleteFold = class {
|
|
2617
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2618
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2619
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2620
|
+
pending = /* @__PURE__ */ new Map();
|
|
2621
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2622
|
+
seen = /* @__PURE__ */ new Set();
|
|
2623
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2624
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2625
|
+
held = [];
|
|
2626
|
+
/**
|
|
2627
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2628
|
+
* userId (null = target absent locally → defer).
|
|
2629
|
+
*/
|
|
2630
|
+
ingest(e, authorOfTarget) {
|
|
2631
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2632
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2633
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2634
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2635
|
+
if (author !== null) {
|
|
2636
|
+
this.seen.add(e.eventClientMsgId);
|
|
2637
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2638
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2639
|
+
} else if (e.actorUserId !== null) {
|
|
2640
|
+
this.seen.add(e.eventClientMsgId);
|
|
2641
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2642
|
+
} else {
|
|
2643
|
+
this.held.push(e);
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2647
|
+
isTombstoned(targetClientMsgId) {
|
|
2648
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2652
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2653
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2654
|
+
* the in-order path.
|
|
2655
|
+
*/
|
|
2656
|
+
reevaluatePending(target, author) {
|
|
2657
|
+
const actor = this.pending.get(target);
|
|
2658
|
+
if (actor !== void 0) {
|
|
2659
|
+
if (author !== null && actor === author) {
|
|
2660
|
+
this.tombstoned.add(target);
|
|
2661
|
+
this.pending.delete(target);
|
|
2662
|
+
} else if (author !== null) {
|
|
2663
|
+
this.pending.delete(target);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
if (this.held.length === 0) return;
|
|
2667
|
+
const pendingHeld = this.held;
|
|
2668
|
+
this.held = [];
|
|
2669
|
+
for (const e of pendingHeld) {
|
|
2670
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
heldContains(eventClientMsgId) {
|
|
2674
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
|
|
2615
2678
|
// src/messaging/edit-fold.ts
|
|
2616
2679
|
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2617
2680
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -2657,7 +2720,8 @@ var EditFold = class {
|
|
|
2657
2720
|
orderEpoch: e.epoch,
|
|
2658
2721
|
orderSeq: e.serverSeq,
|
|
2659
2722
|
lastEventId: e.eventClientMsgId,
|
|
2660
|
-
text: e.newText
|
|
2723
|
+
text: e.newText,
|
|
2724
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2661
2725
|
});
|
|
2662
2726
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2663
2727
|
}
|
|
@@ -2678,6 +2742,15 @@ var EditFold = class {
|
|
|
2678
2742
|
isEdited(targetClientMsgId) {
|
|
2679
2743
|
return this.editedTargets.has(targetClientMsgId);
|
|
2680
2744
|
}
|
|
2745
|
+
/**
|
|
2746
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2747
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2748
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2749
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2750
|
+
*/
|
|
2751
|
+
bodyRanges(targetClientMsgId) {
|
|
2752
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2753
|
+
}
|
|
2681
2754
|
/**
|
|
2682
2755
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2683
2756
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2833,6 +2906,17 @@ async function listDevices(rt, userId) {
|
|
|
2833
2906
|
}
|
|
2834
2907
|
|
|
2835
2908
|
// src/messaging/group-messaging.ts
|
|
2909
|
+
function encodeDelete(args) {
|
|
2910
|
+
return encodeUtf8(
|
|
2911
|
+
JSON.stringify({
|
|
2912
|
+
v: 1,
|
|
2913
|
+
type: "delete",
|
|
2914
|
+
client_msg_id: args.clientMsgId,
|
|
2915
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2916
|
+
scope: "everyone"
|
|
2917
|
+
})
|
|
2918
|
+
);
|
|
2919
|
+
}
|
|
2836
2920
|
function encodeEdit(args) {
|
|
2837
2921
|
return encodeUtf8(
|
|
2838
2922
|
JSON.stringify({
|
|
@@ -2840,7 +2924,14 @@ function encodeEdit(args) {
|
|
|
2840
2924
|
type: "edit",
|
|
2841
2925
|
client_msg_id: args.clientMsgId,
|
|
2842
2926
|
target_client_msg_id: args.targetClientMsgId,
|
|
2843
|
-
new_text: args.newText
|
|
2927
|
+
new_text: args.newText,
|
|
2928
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2929
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2930
|
+
start: r.start,
|
|
2931
|
+
length: r.length,
|
|
2932
|
+
mentioned_user_id: r.mentionedUserId
|
|
2933
|
+
}))
|
|
2934
|
+
} : {}
|
|
2844
2935
|
})
|
|
2845
2936
|
);
|
|
2846
2937
|
}
|
|
@@ -2862,7 +2953,14 @@ function encodeEnvelope(args) {
|
|
|
2862
2953
|
type: "text",
|
|
2863
2954
|
client_msg_id: args.clientMsgId,
|
|
2864
2955
|
text: args.text,
|
|
2865
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
2956
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
2957
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2958
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2959
|
+
start: r.start,
|
|
2960
|
+
length: r.length,
|
|
2961
|
+
mentioned_user_id: r.mentionedUserId
|
|
2962
|
+
}))
|
|
2963
|
+
} : {}
|
|
2866
2964
|
};
|
|
2867
2965
|
return encodeUtf8(JSON.stringify(env));
|
|
2868
2966
|
}
|
|
@@ -2870,6 +2968,18 @@ function decodeEnvelope(bytes) {
|
|
|
2870
2968
|
const s = decodeUtf8(bytes);
|
|
2871
2969
|
try {
|
|
2872
2970
|
const o = JSON.parse(s);
|
|
2971
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2972
|
+
return {
|
|
2973
|
+
type: "delete",
|
|
2974
|
+
text: null,
|
|
2975
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2976
|
+
replyTo: null,
|
|
2977
|
+
delete: {
|
|
2978
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2979
|
+
scope: o.scope ?? "everyone"
|
|
2980
|
+
}
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2873
2983
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2874
2984
|
return {
|
|
2875
2985
|
type: "reaction",
|
|
@@ -2884,6 +2994,7 @@ function decodeEnvelope(bytes) {
|
|
|
2884
2994
|
};
|
|
2885
2995
|
}
|
|
2886
2996
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2997
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2887
2998
|
return {
|
|
2888
2999
|
type: "edit",
|
|
2889
3000
|
text: null,
|
|
@@ -2892,15 +3003,18 @@ function decodeEnvelope(bytes) {
|
|
|
2892
3003
|
edit: {
|
|
2893
3004
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2894
3005
|
newText: o.new_text ?? ""
|
|
2895
|
-
}
|
|
3006
|
+
},
|
|
3007
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2896
3008
|
};
|
|
2897
3009
|
}
|
|
2898
3010
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
3011
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2899
3012
|
return {
|
|
2900
3013
|
type: "text",
|
|
2901
3014
|
text: o.text ?? null,
|
|
2902
3015
|
clientMsgId: o.client_msg_id ?? "",
|
|
2903
|
-
replyTo: o.reply_to ?? null
|
|
3016
|
+
replyTo: o.reply_to ?? null,
|
|
3017
|
+
...textRanges ? { bodyRanges: textRanges } : {}
|
|
2904
3018
|
};
|
|
2905
3019
|
}
|
|
2906
3020
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2912,6 +3026,14 @@ function decodeEnvelope(bytes) {
|
|
|
2912
3026
|
}
|
|
2913
3027
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2914
3028
|
}
|
|
3029
|
+
function decodeBodyRanges(raw) {
|
|
3030
|
+
if (!raw || raw.length === 0) return void 0;
|
|
3031
|
+
return raw.map((r) => ({
|
|
3032
|
+
start: r.start,
|
|
3033
|
+
length: r.length,
|
|
3034
|
+
mentionedUserId: r.mentioned_user_id
|
|
3035
|
+
}));
|
|
3036
|
+
}
|
|
2915
3037
|
function resolveReply(ref, lookup) {
|
|
2916
3038
|
const parent = lookup(ref.client_msg_id);
|
|
2917
3039
|
if (parent !== null) {
|
|
@@ -3123,9 +3245,9 @@ var GroupMessaging = class {
|
|
|
3123
3245
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3124
3246
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3125
3247
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3126
|
-
async sendText(group, text, replyTo) {
|
|
3248
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
3127
3249
|
const clientMsgId = mintClientMsgId();
|
|
3128
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3250
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
3129
3251
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3130
3252
|
const body = {
|
|
3131
3253
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3151,7 +3273,10 @@ var GroupMessaging = class {
|
|
|
3151
3273
|
previewBody: replyTo.preview?.body ?? null,
|
|
3152
3274
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3153
3275
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3154
|
-
} : null
|
|
3276
|
+
} : null,
|
|
3277
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3278
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3279
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
3155
3280
|
};
|
|
3156
3281
|
try {
|
|
3157
3282
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3219,7 +3344,8 @@ var GroupMessaging = class {
|
|
|
3219
3344
|
const plaintext = encodeEdit({
|
|
3220
3345
|
clientMsgId: args.clientMsgId,
|
|
3221
3346
|
targetClientMsgId: args.targetClientMsgId,
|
|
3222
|
-
newText: args.newText
|
|
3347
|
+
newText: args.newText,
|
|
3348
|
+
bodyRanges: args.bodyRanges
|
|
3223
3349
|
});
|
|
3224
3350
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3225
3351
|
const body = {
|
|
@@ -3245,7 +3371,59 @@ var GroupMessaging = class {
|
|
|
3245
3371
|
envelopeType: "edit",
|
|
3246
3372
|
edit: {
|
|
3247
3373
|
targetClientMsgId: args.targetClientMsgId,
|
|
3248
|
-
newText: args.newText
|
|
3374
|
+
newText: args.newText,
|
|
3375
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3376
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3377
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3378
|
+
}
|
|
3379
|
+
};
|
|
3380
|
+
try {
|
|
3381
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3382
|
+
} catch {
|
|
3383
|
+
}
|
|
3384
|
+
return {
|
|
3385
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3386
|
+
clientMsgId: args.clientMsgId
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3390
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3391
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3392
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3393
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3394
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3395
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3396
|
+
* rebases (epoch-bound like any application message). */
|
|
3397
|
+
async sendDelete(group, args) {
|
|
3398
|
+
const plaintext = encodeDelete({
|
|
3399
|
+
clientMsgId: args.clientMsgId,
|
|
3400
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3401
|
+
});
|
|
3402
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3403
|
+
const body = {
|
|
3404
|
+
ciphertext_b64: toBase64(ct),
|
|
3405
|
+
client_idem_key: randomId()
|
|
3406
|
+
};
|
|
3407
|
+
const wire = await palbeRequest(
|
|
3408
|
+
this.rt,
|
|
3409
|
+
"POST",
|
|
3410
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3411
|
+
{ body }
|
|
3412
|
+
);
|
|
3413
|
+
const stored = {
|
|
3414
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3415
|
+
direction: "outgoing",
|
|
3416
|
+
text: null,
|
|
3417
|
+
senderDeviceId: this.selfDeviceId,
|
|
3418
|
+
epoch: wire.epoch,
|
|
3419
|
+
serverSeq: wire.server_seq,
|
|
3420
|
+
at: Date.now(),
|
|
3421
|
+
clientMsgId: args.clientMsgId,
|
|
3422
|
+
replyTo: null,
|
|
3423
|
+
envelopeType: "delete",
|
|
3424
|
+
delete: {
|
|
3425
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3426
|
+
scope: "everyone"
|
|
3249
3427
|
}
|
|
3250
3428
|
};
|
|
3251
3429
|
try {
|
|
@@ -3318,6 +3496,41 @@ var GroupMessaging = class {
|
|
|
3318
3496
|
}
|
|
3319
3497
|
};
|
|
3320
3498
|
|
|
3499
|
+
// src/messaging/mention-ranges.ts
|
|
3500
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3501
|
+
const n = text.length;
|
|
3502
|
+
function splitsSurrogatePair(index) {
|
|
3503
|
+
if (index <= 0 || index >= n) return false;
|
|
3504
|
+
const before = text.charCodeAt(index - 1);
|
|
3505
|
+
const at = text.charCodeAt(index);
|
|
3506
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3507
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3508
|
+
return beforeIsHigh && atIsLow;
|
|
3509
|
+
}
|
|
3510
|
+
const survivors = [];
|
|
3511
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3512
|
+
const r = ranges[idx];
|
|
3513
|
+
if (r === void 0) continue;
|
|
3514
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3515
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3516
|
+
survivors.push({ idx, range: r });
|
|
3517
|
+
}
|
|
3518
|
+
survivors.sort((lhs, rhs) => {
|
|
3519
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3520
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3521
|
+
return lhs.idx - rhs.idx;
|
|
3522
|
+
});
|
|
3523
|
+
const kept = [];
|
|
3524
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3525
|
+
for (const s of survivors) {
|
|
3526
|
+
if (s.range.start >= prevEnd) {
|
|
3527
|
+
kept.push(s.range);
|
|
3528
|
+
prevEnd = s.range.start + s.range.length;
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
return kept;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3321
3534
|
// src/messaging/reaction-fold.ts
|
|
3322
3535
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3323
3536
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3374,6 +3587,7 @@ var ReactionFold = class {
|
|
|
3374
3587
|
};
|
|
3375
3588
|
|
|
3376
3589
|
// src/messaging/chat.ts
|
|
3590
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3377
3591
|
var Chat = class {
|
|
3378
3592
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3379
3593
|
id;
|
|
@@ -3395,6 +3609,22 @@ var Chat = class {
|
|
|
3395
3609
|
reactionFold = new ReactionFold();
|
|
3396
3610
|
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3397
3611
|
editFold = new EditFold();
|
|
3612
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3613
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3614
|
+
deleteFold = new DeleteFold();
|
|
3615
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3616
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3617
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3618
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3619
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3620
|
+
suppressedLoaded = false;
|
|
3621
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3622
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3623
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3624
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3625
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3626
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3627
|
+
elevatedLoaded = false;
|
|
3398
3628
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3399
3629
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3400
3630
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3406,6 +3636,14 @@ var Chat = class {
|
|
|
3406
3636
|
wired = false;
|
|
3407
3637
|
liveUnsub = null;
|
|
3408
3638
|
listeners = /* @__PURE__ */ new Set();
|
|
3639
|
+
/**
|
|
3640
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3641
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3642
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3643
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3644
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3645
|
+
*/
|
|
3646
|
+
onMentionElevation;
|
|
3409
3647
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3410
3648
|
constructor(args) {
|
|
3411
3649
|
this.backend = args.backend;
|
|
@@ -3443,7 +3681,7 @@ var Chat = class {
|
|
|
3443
3681
|
return this.kind === "direct";
|
|
3444
3682
|
}
|
|
3445
3683
|
get messages() {
|
|
3446
|
-
return this.
|
|
3684
|
+
return this.surfaced();
|
|
3447
3685
|
}
|
|
3448
3686
|
get members() {
|
|
3449
3687
|
return this.memberCache;
|
|
@@ -3452,13 +3690,50 @@ var Chat = class {
|
|
|
3452
3690
|
return this.typingList;
|
|
3453
3691
|
}
|
|
3454
3692
|
get lastMessage() {
|
|
3455
|
-
return this.
|
|
3693
|
+
return this.surfaced().at(-1) ?? null;
|
|
3456
3694
|
}
|
|
3457
3695
|
get unreadCount() {
|
|
3458
|
-
return this.
|
|
3459
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3696
|
+
return this.surfaced().filter(
|
|
3697
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3460
3698
|
).length;
|
|
3461
3699
|
}
|
|
3700
|
+
/**
|
|
3701
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3702
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3703
|
+
* folded edit text + reactions + reply):
|
|
3704
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3705
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3706
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3707
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3708
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3709
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3710
|
+
*/
|
|
3711
|
+
surfaced() {
|
|
3712
|
+
const out = [];
|
|
3713
|
+
for (const m of this.messageList) {
|
|
3714
|
+
const key = this.suppressionKey(m);
|
|
3715
|
+
if (this.suppressed.has(key)) continue;
|
|
3716
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3717
|
+
if (tombstoned) {
|
|
3718
|
+
out.push({
|
|
3719
|
+
...m,
|
|
3720
|
+
text: DELETED_DESCRIPTOR,
|
|
3721
|
+
reactions: {},
|
|
3722
|
+
replyTo: null,
|
|
3723
|
+
edited: false,
|
|
3724
|
+
isDeleted: true,
|
|
3725
|
+
mentions: []
|
|
3726
|
+
});
|
|
3727
|
+
continue;
|
|
3728
|
+
}
|
|
3729
|
+
out.push(m);
|
|
3730
|
+
}
|
|
3731
|
+
return out;
|
|
3732
|
+
}
|
|
3733
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3734
|
+
suppressionKey(m) {
|
|
3735
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3736
|
+
}
|
|
3462
3737
|
get title() {
|
|
3463
3738
|
if (this.titleOverride) return this.titleOverride;
|
|
3464
3739
|
if (this._group?.name) return this._group.name;
|
|
@@ -3482,9 +3757,40 @@ var Chat = class {
|
|
|
3482
3757
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3483
3758
|
this.wired = true;
|
|
3484
3759
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3760
|
+
void this.loadSuppressed();
|
|
3761
|
+
void this.loadElevated();
|
|
3485
3762
|
void this.hydrateHistory();
|
|
3486
3763
|
void this.refreshMembers();
|
|
3487
3764
|
}
|
|
3765
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3766
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3767
|
+
async loadSuppressed() {
|
|
3768
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3769
|
+
this.suppressedLoaded = true;
|
|
3770
|
+
try {
|
|
3771
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3772
|
+
let changed = false;
|
|
3773
|
+
for (const k of keys) {
|
|
3774
|
+
if (!this.suppressed.has(k)) {
|
|
3775
|
+
this.suppressed.add(k);
|
|
3776
|
+
changed = true;
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
if (changed) this.emit();
|
|
3780
|
+
} catch {
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
3784
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
3785
|
+
async loadElevated() {
|
|
3786
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
3787
|
+
this.elevatedLoaded = true;
|
|
3788
|
+
try {
|
|
3789
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
3790
|
+
for (const k of keys) this.elevated.add(k);
|
|
3791
|
+
} catch {
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3488
3794
|
async hydrateHistory() {
|
|
3489
3795
|
if (this.historyLoaded || !this._group) return;
|
|
3490
3796
|
this.historyLoaded = true;
|
|
@@ -3495,13 +3801,13 @@ var Chat = class {
|
|
|
3495
3801
|
let changed = false;
|
|
3496
3802
|
for (const m of incoming) {
|
|
3497
3803
|
if (m.serverSeq <= 0) continue;
|
|
3498
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3804
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3499
3805
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3500
3806
|
text: m.text,
|
|
3501
3807
|
senderUserId: m.senderUserId ?? ""
|
|
3502
3808
|
});
|
|
3503
3809
|
}
|
|
3504
|
-
if (m.clientMsgId) {
|
|
3810
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3505
3811
|
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3506
3812
|
}
|
|
3507
3813
|
}
|
|
@@ -3511,7 +3817,12 @@ var Chat = class {
|
|
|
3511
3817
|
const key = this.internalKey(m.serverSeq);
|
|
3512
3818
|
if (this.seenKeys.has(key)) continue;
|
|
3513
3819
|
this.seenKeys.add(key);
|
|
3514
|
-
|
|
3820
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3821
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3822
|
+
}
|
|
3823
|
+
this.messageList.push(
|
|
3824
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3825
|
+
);
|
|
3515
3826
|
changed = true;
|
|
3516
3827
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3517
3828
|
}
|
|
@@ -3561,19 +3872,38 @@ var Chat = class {
|
|
|
3561
3872
|
newText: incoming.edit.newText,
|
|
3562
3873
|
epoch: incoming.epoch,
|
|
3563
3874
|
serverSeq: incoming.serverSeq,
|
|
3564
|
-
eventClientMsgId: incoming.clientMsgId
|
|
3875
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
3876
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
3877
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
3878
|
+
bodyRanges: incoming.bodyRanges
|
|
3565
3879
|
},
|
|
3566
3880
|
this.authorOfTarget
|
|
3567
3881
|
);
|
|
3568
3882
|
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3569
3883
|
return;
|
|
3570
3884
|
}
|
|
3885
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3886
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3887
|
+
this.deleteFold.ingest(
|
|
3888
|
+
{
|
|
3889
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3890
|
+
actorUserId,
|
|
3891
|
+
epoch: incoming.epoch,
|
|
3892
|
+
serverSeq: incoming.serverSeq,
|
|
3893
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3894
|
+
},
|
|
3895
|
+
this.authorOfTarget
|
|
3896
|
+
);
|
|
3897
|
+
this.emit();
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3571
3900
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3572
3901
|
const incomingReplyRef = incoming.replyRef;
|
|
3573
3902
|
let resolvedReplyTo = null;
|
|
3574
3903
|
if (incomingReplyRef) {
|
|
3575
3904
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3576
3905
|
}
|
|
3906
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3577
3907
|
const msg = {
|
|
3578
3908
|
id: this.publicId(incoming.serverSeq),
|
|
3579
3909
|
kind: this.kindOf(incoming),
|
|
@@ -3588,8 +3918,12 @@ var Chat = class {
|
|
|
3588
3918
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3589
3919
|
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3590
3920
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3591
|
-
edited: false
|
|
3921
|
+
edited: false,
|
|
3922
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3923
|
+
isDeleted: false,
|
|
3924
|
+
mentions
|
|
3592
3925
|
};
|
|
3926
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3593
3927
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3594
3928
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3595
3929
|
text: incoming.text,
|
|
@@ -3599,6 +3933,7 @@ var Chat = class {
|
|
|
3599
3933
|
if (incomingClientMsgId) {
|
|
3600
3934
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3601
3935
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3936
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3602
3937
|
}
|
|
3603
3938
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3604
3939
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3612,6 +3947,75 @@ var Chat = class {
|
|
|
3612
3947
|
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3613
3948
|
* so it can be passed to the pure EditFold. */
|
|
3614
3949
|
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3950
|
+
// ── Mentions (mentions T6) ──
|
|
3951
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3952
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
3953
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
3954
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
3955
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
3956
|
+
resolveMentions(text, bodyRanges) {
|
|
3957
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
3958
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
3959
|
+
if (normalized.length === 0) return [];
|
|
3960
|
+
return normalized.map((r) => ({
|
|
3961
|
+
start: r.start,
|
|
3962
|
+
length: r.length,
|
|
3963
|
+
mentionedUserId: r.mentionedUserId,
|
|
3964
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
3965
|
+
}));
|
|
3966
|
+
}
|
|
3967
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
3968
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
3969
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
3970
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
3971
|
+
resolveMentionNames(m) {
|
|
3972
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
3973
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
3974
|
+
}
|
|
3975
|
+
let changed = false;
|
|
3976
|
+
const reresolved = m.mentions.map((span) => {
|
|
3977
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
3978
|
+
if (name === span.displayName) return span;
|
|
3979
|
+
changed = true;
|
|
3980
|
+
return { ...span, displayName: name };
|
|
3981
|
+
});
|
|
3982
|
+
if (!changed) return m;
|
|
3983
|
+
return { ...m, mentions: reresolved };
|
|
3984
|
+
}
|
|
3985
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
3986
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
3987
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
3988
|
+
editMentions(targetClientMsgId, newText) {
|
|
3989
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
3990
|
+
if (!ranges) return [];
|
|
3991
|
+
return this.resolveMentions(newText, ranges);
|
|
3992
|
+
}
|
|
3993
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
3994
|
+
displayNameOf(userId) {
|
|
3995
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
3996
|
+
}
|
|
3997
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
3998
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
3999
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
4000
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
4001
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
4002
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
4003
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
4004
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
4005
|
+
const me = this.backend.selfUserId;
|
|
4006
|
+
if (envelopeType === "edit") return;
|
|
4007
|
+
if (senderUserId === me) return;
|
|
4008
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
4009
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4010
|
+
const key = `${me}|${idPart}`;
|
|
4011
|
+
if (this.elevated.has(key)) return;
|
|
4012
|
+
this.elevated.add(key);
|
|
4013
|
+
if (this._group) {
|
|
4014
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
this.onMentionElevation?.(message);
|
|
4018
|
+
}
|
|
3615
4019
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3616
4020
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3617
4021
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3668,9 +4072,10 @@ var Chat = class {
|
|
|
3668
4072
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3669
4073
|
const text = editText ?? base;
|
|
3670
4074
|
const edited = foldEdited || m.edited;
|
|
3671
|
-
|
|
4075
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
4076
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3672
4077
|
changed = true;
|
|
3673
|
-
return { ...m, text, edited };
|
|
4078
|
+
return { ...m, text, edited, mentions };
|
|
3674
4079
|
});
|
|
3675
4080
|
if (changed) this.emit();
|
|
3676
4081
|
}
|
|
@@ -3687,8 +4092,9 @@ var Chat = class {
|
|
|
3687
4092
|
if (editText === null && !foldEdited) return m;
|
|
3688
4093
|
const text = editText ?? m.text;
|
|
3689
4094
|
const edited = foldEdited || m.edited;
|
|
3690
|
-
|
|
3691
|
-
|
|
4095
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
4096
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
4097
|
+
return { ...m, text, edited, mentions };
|
|
3692
4098
|
}
|
|
3693
4099
|
/** @internal — called by the backend's conv subscription. */
|
|
3694
4100
|
applyConv(event, payload) {
|
|
@@ -3746,6 +4152,18 @@ var Chat = class {
|
|
|
3746
4152
|
}
|
|
3747
4153
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3748
4154
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
4155
|
+
this.reresolveAllMentionNames();
|
|
4156
|
+
}
|
|
4157
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
4158
|
+
* change). Re-emits only if any name actually changed. */
|
|
4159
|
+
reresolveAllMentionNames() {
|
|
4160
|
+
let changed = false;
|
|
4161
|
+
this.messageList = this.messageList.map((m) => {
|
|
4162
|
+
const reresolved = this.resolveMentionNames(m);
|
|
4163
|
+
if (reresolved !== m) changed = true;
|
|
4164
|
+
return reresolved;
|
|
4165
|
+
});
|
|
4166
|
+
if (changed) this.emit();
|
|
3749
4167
|
}
|
|
3750
4168
|
seedMembersFromGroup(group) {
|
|
3751
4169
|
const seed = [
|
|
@@ -3813,11 +4231,12 @@ var Chat = class {
|
|
|
3813
4231
|
};
|
|
3814
4232
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3815
4233
|
}
|
|
3816
|
-
const
|
|
3817
|
-
this.
|
|
4234
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4235
|
+
const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
|
|
4236
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
3818
4237
|
return receipt;
|
|
3819
4238
|
}
|
|
3820
|
-
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
|
|
4239
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
3821
4240
|
if (receipt.serverSeq <= 0) return;
|
|
3822
4241
|
const key = this.internalKey(receipt.serverSeq);
|
|
3823
4242
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3840,7 +4259,12 @@ var Chat = class {
|
|
|
3840
4259
|
// the dangling-target invariant uniform across every append path).
|
|
3841
4260
|
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
3842
4261
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3843
|
-
edited: false
|
|
4262
|
+
edited: false,
|
|
4263
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4264
|
+
isDeleted: false,
|
|
4265
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4266
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4267
|
+
mentions: this.resolveMentions(text, bodyRanges)
|
|
3844
4268
|
});
|
|
3845
4269
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3846
4270
|
this.emit();
|
|
@@ -3928,15 +4352,18 @@ var Chat = class {
|
|
|
3928
4352
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3929
4353
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3930
4354
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
3931
|
-
* message self IS the author, so the author-gate passes.
|
|
3932
|
-
|
|
4355
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4356
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4357
|
+
async edit(message, newText, opts) {
|
|
3933
4358
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3934
4359
|
const group = await this.materializeIfNeeded();
|
|
3935
4360
|
const clientMsgId = mintClientMsgId();
|
|
4361
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3936
4362
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
3937
4363
|
clientMsgId,
|
|
3938
4364
|
targetClientMsgId: message.clientMsgId,
|
|
3939
|
-
newText
|
|
4365
|
+
newText,
|
|
4366
|
+
bodyRanges
|
|
3940
4367
|
});
|
|
3941
4368
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3942
4369
|
this.editFold.ingest(
|
|
@@ -3946,13 +4373,72 @@ var Chat = class {
|
|
|
3946
4373
|
newText,
|
|
3947
4374
|
epoch: receipt.epoch,
|
|
3948
4375
|
serverSeq: receipt.serverSeq,
|
|
3949
|
-
eventClientMsgId: clientMsgId
|
|
4376
|
+
eventClientMsgId: clientMsgId,
|
|
4377
|
+
bodyRanges
|
|
3950
4378
|
},
|
|
3951
4379
|
this.authorOfTarget
|
|
3952
4380
|
);
|
|
3953
4381
|
this.recomputeEdit(message.clientMsgId);
|
|
3954
4382
|
}
|
|
4383
|
+
// ── Delete ──
|
|
4384
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
4385
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
4386
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
4387
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
4388
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
4389
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
4390
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
4391
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
4392
|
+
async deleteForEveryone(message) {
|
|
4393
|
+
if (!message.clientMsgId) return;
|
|
4394
|
+
const group = await this.materializeIfNeeded();
|
|
4395
|
+
const clientMsgId = mintClientMsgId();
|
|
4396
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
4397
|
+
clientMsgId,
|
|
4398
|
+
targetClientMsgId: message.clientMsgId
|
|
4399
|
+
});
|
|
4400
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4401
|
+
this.deleteFold.ingest(
|
|
4402
|
+
{
|
|
4403
|
+
targetClientMsgId: message.clientMsgId,
|
|
4404
|
+
actorUserId: this.backend.selfUserId,
|
|
4405
|
+
epoch: receipt.epoch,
|
|
4406
|
+
serverSeq: receipt.serverSeq,
|
|
4407
|
+
eventClientMsgId: clientMsgId
|
|
4408
|
+
},
|
|
4409
|
+
this.authorOfTarget
|
|
4410
|
+
);
|
|
4411
|
+
this.emit();
|
|
4412
|
+
}
|
|
4413
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4414
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4415
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4416
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4417
|
+
async deleteForMe(message) {
|
|
4418
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4419
|
+
if (this.suppressed.has(key)) return;
|
|
4420
|
+
this.suppressed.add(key);
|
|
4421
|
+
this.emit();
|
|
4422
|
+
if (this._group) {
|
|
4423
|
+
try {
|
|
4424
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4425
|
+
} catch {
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
3955
4429
|
};
|
|
4430
|
+
function sameMentions(a, b) {
|
|
4431
|
+
if (a.length !== b.length) return false;
|
|
4432
|
+
for (let i = 0; i < a.length; i++) {
|
|
4433
|
+
const x = a[i];
|
|
4434
|
+
const y = b[i];
|
|
4435
|
+
if (!x || !y) return false;
|
|
4436
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4437
|
+
return false;
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
return true;
|
|
4441
|
+
}
|
|
3956
4442
|
function sameReactions(a, b) {
|
|
3957
4443
|
const ak = Object.keys(a);
|
|
3958
4444
|
const bk = Object.keys(b);
|
|
@@ -4132,6 +4618,7 @@ var MessageDeliverySource = class {
|
|
|
4132
4618
|
const { text, clientMsgId, replyTo } = decoded;
|
|
4133
4619
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4134
4620
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4621
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
4135
4622
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4136
4623
|
const stored = {
|
|
4137
4624
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4163,12 +4650,31 @@ var MessageDeliverySource = class {
|
|
|
4163
4650
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4164
4651
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4165
4652
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4166
|
-
// `'text'`/no-edit (backward-compat).
|
|
4653
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
4654
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
4167
4655
|
...isEdit && decoded.edit ? {
|
|
4168
4656
|
envelopeType: "edit",
|
|
4169
4657
|
edit: {
|
|
4170
4658
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4171
|
-
newText: decoded.edit.newText
|
|
4659
|
+
newText: decoded.edit.newText,
|
|
4660
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4661
|
+
}
|
|
4662
|
+
} : {},
|
|
4663
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
4664
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
4665
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
4666
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
4667
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4668
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4669
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4670
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4671
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4672
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4673
|
+
...isDelete && decoded.delete ? {
|
|
4674
|
+
envelopeType: "delete",
|
|
4675
|
+
delete: {
|
|
4676
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4677
|
+
scope: decoded.delete.scope
|
|
4172
4678
|
}
|
|
4173
4679
|
} : {}
|
|
4174
4680
|
};
|
|
@@ -4189,7 +4695,11 @@ var MessageDeliverySource = class {
|
|
|
4189
4695
|
replyRef: replyTo,
|
|
4190
4696
|
envelopeType: decoded.type ?? "text",
|
|
4191
4697
|
reaction: isReaction ? decoded.reaction : null,
|
|
4192
|
-
edit: isEdit ? decoded.edit : null
|
|
4698
|
+
edit: isEdit ? decoded.edit : null,
|
|
4699
|
+
delete: isDelete ? decoded.delete : null,
|
|
4700
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4701
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4702
|
+
bodyRanges: decoded.bodyRanges ?? null
|
|
4193
4703
|
});
|
|
4194
4704
|
return true;
|
|
4195
4705
|
}
|
|
@@ -4344,6 +4854,36 @@ var GroupCatalog = class {
|
|
|
4344
4854
|
}
|
|
4345
4855
|
};
|
|
4346
4856
|
|
|
4857
|
+
// src/messaging/mention-elevation.ts
|
|
4858
|
+
var MentionElevationStore = class {
|
|
4859
|
+
constructor(kv) {
|
|
4860
|
+
this.kv = kv;
|
|
4861
|
+
}
|
|
4862
|
+
kv;
|
|
4863
|
+
key(rfcGroupId) {
|
|
4864
|
+
return `elev:${rfcGroupId}`;
|
|
4865
|
+
}
|
|
4866
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
4867
|
+
async load(rfcGroupId) {
|
|
4868
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
4869
|
+
if (!raw) return [];
|
|
4870
|
+
try {
|
|
4871
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
4872
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4873
|
+
} catch {
|
|
4874
|
+
return [];
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
4878
|
+
async save(rfcGroupId, keys) {
|
|
4879
|
+
const sorted = [...new Set(keys)].sort();
|
|
4880
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
4881
|
+
}
|
|
4882
|
+
async wipe() {
|
|
4883
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
4884
|
+
}
|
|
4885
|
+
};
|
|
4886
|
+
|
|
4347
4887
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4348
4888
|
var palbe_mls_bg_exports = {};
|
|
4349
4889
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6011,6 +6551,36 @@ var SignatureKeyStore = class {
|
|
|
6011
6551
|
}
|
|
6012
6552
|
};
|
|
6013
6553
|
|
|
6554
|
+
// src/messaging/suppression.ts
|
|
6555
|
+
var SuppressionStore = class {
|
|
6556
|
+
constructor(kv) {
|
|
6557
|
+
this.kv = kv;
|
|
6558
|
+
}
|
|
6559
|
+
kv;
|
|
6560
|
+
key(rfcGroupId) {
|
|
6561
|
+
return `supp:${rfcGroupId}`;
|
|
6562
|
+
}
|
|
6563
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6564
|
+
async load(rfcGroupId) {
|
|
6565
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6566
|
+
if (!raw) return [];
|
|
6567
|
+
try {
|
|
6568
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6569
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6570
|
+
} catch {
|
|
6571
|
+
return [];
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6575
|
+
async save(rfcGroupId, keys) {
|
|
6576
|
+
const sorted = [...new Set(keys)].sort();
|
|
6577
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6578
|
+
}
|
|
6579
|
+
async wipe() {
|
|
6580
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6581
|
+
}
|
|
6582
|
+
};
|
|
6583
|
+
|
|
6014
6584
|
// src/messaging/coordinator.ts
|
|
6015
6585
|
var MessagingCoordinator = class {
|
|
6016
6586
|
constructor(rt) {
|
|
@@ -6020,6 +6590,8 @@ var MessagingCoordinator = class {
|
|
|
6020
6590
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
6021
6591
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6022
6592
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6593
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6594
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6023
6595
|
this.registry.attachChatList(
|
|
6024
6596
|
(chats) => {
|
|
6025
6597
|
this.chatList = chats;
|
|
@@ -6034,6 +6606,8 @@ var MessagingCoordinator = class {
|
|
|
6034
6606
|
sigStore;
|
|
6035
6607
|
groupStore;
|
|
6036
6608
|
kpStore;
|
|
6609
|
+
suppressionStore;
|
|
6610
|
+
elevationStore;
|
|
6037
6611
|
registry = new GroupRegistry();
|
|
6038
6612
|
resolved = null;
|
|
6039
6613
|
resolvePromise = null;
|
|
@@ -6189,9 +6763,9 @@ var MessagingCoordinator = class {
|
|
|
6189
6763
|
});
|
|
6190
6764
|
return group;
|
|
6191
6765
|
}
|
|
6192
|
-
async sendText(group, text, replyTo) {
|
|
6766
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6193
6767
|
const r = await this.resolve();
|
|
6194
|
-
return r.groups.sendText(group, text, replyTo);
|
|
6768
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6195
6769
|
}
|
|
6196
6770
|
async sendReaction(group, args) {
|
|
6197
6771
|
const r = await this.resolve();
|
|
@@ -6201,6 +6775,26 @@ var MessagingCoordinator = class {
|
|
|
6201
6775
|
const r = await this.resolve();
|
|
6202
6776
|
return r.groups.sendEdit(group, args);
|
|
6203
6777
|
}
|
|
6778
|
+
async sendDelete(group, args) {
|
|
6779
|
+
const r = await this.resolve();
|
|
6780
|
+
return r.groups.sendDelete(group, args);
|
|
6781
|
+
}
|
|
6782
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6783
|
+
loadSuppressed(group) {
|
|
6784
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6785
|
+
}
|
|
6786
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6787
|
+
saveSuppressed(group, keys) {
|
|
6788
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6789
|
+
}
|
|
6790
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
6791
|
+
loadElevated(group) {
|
|
6792
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
6793
|
+
}
|
|
6794
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
6795
|
+
saveElevated(group, keys) {
|
|
6796
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6797
|
+
}
|
|
6204
6798
|
async history(group, limit, before) {
|
|
6205
6799
|
const r = await this.resolve();
|
|
6206
6800
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6298,9 +6892,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6298
6892
|
});
|
|
6299
6893
|
}
|
|
6300
6894
|
const editFold = new EditFold();
|
|
6895
|
+
const deleteFold = new DeleteFold();
|
|
6301
6896
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6302
6897
|
for (const s of rows) {
|
|
6303
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6898
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6899
|
+
continue;
|
|
6304
6900
|
const cid = s.clientMsgId ?? "";
|
|
6305
6901
|
if (!cid) continue;
|
|
6306
6902
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
@@ -6317,15 +6913,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6317
6913
|
newText: s.edit.newText,
|
|
6318
6914
|
epoch: s.epoch,
|
|
6319
6915
|
serverSeq: s.serverSeq,
|
|
6320
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
6916
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
6917
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
6918
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
6919
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6321
6920
|
},
|
|
6322
6921
|
authorOfTarget
|
|
6323
6922
|
);
|
|
6324
6923
|
}
|
|
6325
6924
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6925
|
+
for (const s of rows) {
|
|
6926
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6927
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6928
|
+
deleteFold.ingest(
|
|
6929
|
+
{
|
|
6930
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6931
|
+
actorUserId: actor,
|
|
6932
|
+
epoch: s.epoch,
|
|
6933
|
+
serverSeq: s.serverSeq,
|
|
6934
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6935
|
+
},
|
|
6936
|
+
authorOfTarget
|
|
6937
|
+
);
|
|
6938
|
+
}
|
|
6939
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
6326
6940
|
const lookup = /* @__PURE__ */ new Map();
|
|
6327
6941
|
for (const s of rows) {
|
|
6328
|
-
if (s.envelopeType === "reaction")
|
|
6942
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6943
|
+
continue;
|
|
6329
6944
|
const cid = s.clientMsgId ?? "";
|
|
6330
6945
|
if (cid && s.text !== null) {
|
|
6331
6946
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -6334,8 +6949,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6334
6949
|
}
|
|
6335
6950
|
const out = [];
|
|
6336
6951
|
for (const s of rows) {
|
|
6337
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6952
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6953
|
+
continue;
|
|
6338
6954
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6955
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6956
|
+
if (isDeleted) {
|
|
6957
|
+
out.push({
|
|
6958
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6959
|
+
kind: "text",
|
|
6960
|
+
direction: s.direction,
|
|
6961
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6962
|
+
text: DELETED_DESCRIPTOR,
|
|
6963
|
+
serverSeq: s.serverSeq,
|
|
6964
|
+
sentAt: new Date(s.at),
|
|
6965
|
+
clientMsgId,
|
|
6966
|
+
replyTo: null,
|
|
6967
|
+
reactions: {},
|
|
6968
|
+
edited: false,
|
|
6969
|
+
isDeleted: true,
|
|
6970
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6971
|
+
mentions: []
|
|
6972
|
+
});
|
|
6973
|
+
continue;
|
|
6974
|
+
}
|
|
6339
6975
|
let replyTo = null;
|
|
6340
6976
|
if (s.replyTo) {
|
|
6341
6977
|
const ref = {
|
|
@@ -6352,22 +6988,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6352
6988
|
}
|
|
6353
6989
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6354
6990
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
6991
|
+
const text = editText ?? s.text;
|
|
6992
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
6993
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6355
6994
|
out.push({
|
|
6356
6995
|
id: `${displayId}#${s.serverSeq}`,
|
|
6357
6996
|
kind: s.text != null ? "text" : "system",
|
|
6358
6997
|
direction: s.direction,
|
|
6359
6998
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6360
|
-
text
|
|
6999
|
+
text,
|
|
6361
7000
|
serverSeq: s.serverSeq,
|
|
6362
7001
|
sentAt: new Date(s.at),
|
|
6363
7002
|
clientMsgId,
|
|
6364
7003
|
replyTo,
|
|
6365
7004
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6366
|
-
edited
|
|
7005
|
+
edited,
|
|
7006
|
+
isDeleted: false,
|
|
7007
|
+
mentions
|
|
6367
7008
|
});
|
|
6368
7009
|
}
|
|
6369
7010
|
return out;
|
|
6370
7011
|
}
|
|
7012
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
7013
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
7014
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
7015
|
+
start: r.start,
|
|
7016
|
+
length: r.length,
|
|
7017
|
+
mentionedUserId: r.mentionedUserId,
|
|
7018
|
+
displayName: null
|
|
7019
|
+
}));
|
|
7020
|
+
}
|
|
6371
7021
|
|
|
6372
7022
|
// src/messaging/facade.ts
|
|
6373
7023
|
var PalbeMessaging = class {
|
|
@@ -7101,7 +7751,7 @@ function defaultSessionStorage(key) {
|
|
|
7101
7751
|
}
|
|
7102
7752
|
|
|
7103
7753
|
// src/version.ts
|
|
7104
|
-
var VERSION = "1.
|
|
7754
|
+
var VERSION = "1.5.0";
|
|
7105
7755
|
|
|
7106
7756
|
// src/runtime.ts
|
|
7107
7757
|
function buildRuntime(config) {
|