@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/next/index.cjs
CHANGED
|
@@ -2628,6 +2628,69 @@ var PalbeFlags = class {
|
|
|
2628
2628
|
}
|
|
2629
2629
|
};
|
|
2630
2630
|
|
|
2631
|
+
// src/messaging/delete-fold.ts
|
|
2632
|
+
var DeleteFold = class {
|
|
2633
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2634
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2635
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2636
|
+
pending = /* @__PURE__ */ new Map();
|
|
2637
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2638
|
+
seen = /* @__PURE__ */ new Set();
|
|
2639
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2640
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2641
|
+
held = [];
|
|
2642
|
+
/**
|
|
2643
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2644
|
+
* userId (null = target absent locally → defer).
|
|
2645
|
+
*/
|
|
2646
|
+
ingest(e, authorOfTarget) {
|
|
2647
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2648
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2649
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2650
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2651
|
+
if (author !== null) {
|
|
2652
|
+
this.seen.add(e.eventClientMsgId);
|
|
2653
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2654
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2655
|
+
} else if (e.actorUserId !== null) {
|
|
2656
|
+
this.seen.add(e.eventClientMsgId);
|
|
2657
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2658
|
+
} else {
|
|
2659
|
+
this.held.push(e);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2663
|
+
isTombstoned(targetClientMsgId) {
|
|
2664
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2668
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2669
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2670
|
+
* the in-order path.
|
|
2671
|
+
*/
|
|
2672
|
+
reevaluatePending(target, author) {
|
|
2673
|
+
const actor = this.pending.get(target);
|
|
2674
|
+
if (actor !== void 0) {
|
|
2675
|
+
if (author !== null && actor === author) {
|
|
2676
|
+
this.tombstoned.add(target);
|
|
2677
|
+
this.pending.delete(target);
|
|
2678
|
+
} else if (author !== null) {
|
|
2679
|
+
this.pending.delete(target);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
if (this.held.length === 0) return;
|
|
2683
|
+
const pendingHeld = this.held;
|
|
2684
|
+
this.held = [];
|
|
2685
|
+
for (const e of pendingHeld) {
|
|
2686
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
heldContains(eventClientMsgId) {
|
|
2690
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
|
|
2631
2694
|
// src/messaging/edit-fold.ts
|
|
2632
2695
|
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2633
2696
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -2673,7 +2736,8 @@ var EditFold = class {
|
|
|
2673
2736
|
orderEpoch: e.epoch,
|
|
2674
2737
|
orderSeq: e.serverSeq,
|
|
2675
2738
|
lastEventId: e.eventClientMsgId,
|
|
2676
|
-
text: e.newText
|
|
2739
|
+
text: e.newText,
|
|
2740
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2677
2741
|
});
|
|
2678
2742
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2679
2743
|
}
|
|
@@ -2694,6 +2758,15 @@ var EditFold = class {
|
|
|
2694
2758
|
isEdited(targetClientMsgId) {
|
|
2695
2759
|
return this.editedTargets.has(targetClientMsgId);
|
|
2696
2760
|
}
|
|
2761
|
+
/**
|
|
2762
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2763
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2764
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2765
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2766
|
+
*/
|
|
2767
|
+
bodyRanges(targetClientMsgId) {
|
|
2768
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2769
|
+
}
|
|
2697
2770
|
/**
|
|
2698
2771
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2699
2772
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2849,6 +2922,17 @@ async function listDevices(rt, userId) {
|
|
|
2849
2922
|
}
|
|
2850
2923
|
|
|
2851
2924
|
// src/messaging/group-messaging.ts
|
|
2925
|
+
function encodeDelete(args) {
|
|
2926
|
+
return encodeUtf8(
|
|
2927
|
+
JSON.stringify({
|
|
2928
|
+
v: 1,
|
|
2929
|
+
type: "delete",
|
|
2930
|
+
client_msg_id: args.clientMsgId,
|
|
2931
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2932
|
+
scope: "everyone"
|
|
2933
|
+
})
|
|
2934
|
+
);
|
|
2935
|
+
}
|
|
2852
2936
|
function encodeEdit(args) {
|
|
2853
2937
|
return encodeUtf8(
|
|
2854
2938
|
JSON.stringify({
|
|
@@ -2856,7 +2940,14 @@ function encodeEdit(args) {
|
|
|
2856
2940
|
type: "edit",
|
|
2857
2941
|
client_msg_id: args.clientMsgId,
|
|
2858
2942
|
target_client_msg_id: args.targetClientMsgId,
|
|
2859
|
-
new_text: args.newText
|
|
2943
|
+
new_text: args.newText,
|
|
2944
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2945
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2946
|
+
start: r.start,
|
|
2947
|
+
length: r.length,
|
|
2948
|
+
mentioned_user_id: r.mentionedUserId
|
|
2949
|
+
}))
|
|
2950
|
+
} : {}
|
|
2860
2951
|
})
|
|
2861
2952
|
);
|
|
2862
2953
|
}
|
|
@@ -2878,7 +2969,14 @@ function encodeEnvelope(args) {
|
|
|
2878
2969
|
type: "text",
|
|
2879
2970
|
client_msg_id: args.clientMsgId,
|
|
2880
2971
|
text: args.text,
|
|
2881
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
2972
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
2973
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2974
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2975
|
+
start: r.start,
|
|
2976
|
+
length: r.length,
|
|
2977
|
+
mentioned_user_id: r.mentionedUserId
|
|
2978
|
+
}))
|
|
2979
|
+
} : {}
|
|
2882
2980
|
};
|
|
2883
2981
|
return encodeUtf8(JSON.stringify(env));
|
|
2884
2982
|
}
|
|
@@ -2886,6 +2984,18 @@ function decodeEnvelope(bytes) {
|
|
|
2886
2984
|
const s = decodeUtf8(bytes);
|
|
2887
2985
|
try {
|
|
2888
2986
|
const o = JSON.parse(s);
|
|
2987
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2988
|
+
return {
|
|
2989
|
+
type: "delete",
|
|
2990
|
+
text: null,
|
|
2991
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2992
|
+
replyTo: null,
|
|
2993
|
+
delete: {
|
|
2994
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2995
|
+
scope: o.scope ?? "everyone"
|
|
2996
|
+
}
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2889
2999
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2890
3000
|
return {
|
|
2891
3001
|
type: "reaction",
|
|
@@ -2900,6 +3010,7 @@ function decodeEnvelope(bytes) {
|
|
|
2900
3010
|
};
|
|
2901
3011
|
}
|
|
2902
3012
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
3013
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2903
3014
|
return {
|
|
2904
3015
|
type: "edit",
|
|
2905
3016
|
text: null,
|
|
@@ -2908,15 +3019,18 @@ function decodeEnvelope(bytes) {
|
|
|
2908
3019
|
edit: {
|
|
2909
3020
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2910
3021
|
newText: o.new_text ?? ""
|
|
2911
|
-
}
|
|
3022
|
+
},
|
|
3023
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2912
3024
|
};
|
|
2913
3025
|
}
|
|
2914
3026
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
3027
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2915
3028
|
return {
|
|
2916
3029
|
type: "text",
|
|
2917
3030
|
text: o.text ?? null,
|
|
2918
3031
|
clientMsgId: o.client_msg_id ?? "",
|
|
2919
|
-
replyTo: o.reply_to ?? null
|
|
3032
|
+
replyTo: o.reply_to ?? null,
|
|
3033
|
+
...textRanges ? { bodyRanges: textRanges } : {}
|
|
2920
3034
|
};
|
|
2921
3035
|
}
|
|
2922
3036
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2928,6 +3042,14 @@ function decodeEnvelope(bytes) {
|
|
|
2928
3042
|
}
|
|
2929
3043
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2930
3044
|
}
|
|
3045
|
+
function decodeBodyRanges(raw) {
|
|
3046
|
+
if (!raw || raw.length === 0) return void 0;
|
|
3047
|
+
return raw.map((r) => ({
|
|
3048
|
+
start: r.start,
|
|
3049
|
+
length: r.length,
|
|
3050
|
+
mentionedUserId: r.mentioned_user_id
|
|
3051
|
+
}));
|
|
3052
|
+
}
|
|
2931
3053
|
function resolveReply(ref, lookup) {
|
|
2932
3054
|
const parent = lookup(ref.client_msg_id);
|
|
2933
3055
|
if (parent !== null) {
|
|
@@ -3139,9 +3261,9 @@ var GroupMessaging = class {
|
|
|
3139
3261
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3140
3262
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3141
3263
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3142
|
-
async sendText(group, text, replyTo) {
|
|
3264
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
3143
3265
|
const clientMsgId = mintClientMsgId();
|
|
3144
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3266
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
3145
3267
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3146
3268
|
const body = {
|
|
3147
3269
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3167,7 +3289,10 @@ var GroupMessaging = class {
|
|
|
3167
3289
|
previewBody: replyTo.preview?.body ?? null,
|
|
3168
3290
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3169
3291
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3170
|
-
} : null
|
|
3292
|
+
} : null,
|
|
3293
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3294
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3295
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
3171
3296
|
};
|
|
3172
3297
|
try {
|
|
3173
3298
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3235,7 +3360,8 @@ var GroupMessaging = class {
|
|
|
3235
3360
|
const plaintext = encodeEdit({
|
|
3236
3361
|
clientMsgId: args.clientMsgId,
|
|
3237
3362
|
targetClientMsgId: args.targetClientMsgId,
|
|
3238
|
-
newText: args.newText
|
|
3363
|
+
newText: args.newText,
|
|
3364
|
+
bodyRanges: args.bodyRanges
|
|
3239
3365
|
});
|
|
3240
3366
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3241
3367
|
const body = {
|
|
@@ -3261,7 +3387,59 @@ var GroupMessaging = class {
|
|
|
3261
3387
|
envelopeType: "edit",
|
|
3262
3388
|
edit: {
|
|
3263
3389
|
targetClientMsgId: args.targetClientMsgId,
|
|
3264
|
-
newText: args.newText
|
|
3390
|
+
newText: args.newText,
|
|
3391
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3392
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3393
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3394
|
+
}
|
|
3395
|
+
};
|
|
3396
|
+
try {
|
|
3397
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3398
|
+
} catch {
|
|
3399
|
+
}
|
|
3400
|
+
return {
|
|
3401
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3402
|
+
clientMsgId: args.clientMsgId
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3406
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3407
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3408
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3409
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3410
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3411
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3412
|
+
* rebases (epoch-bound like any application message). */
|
|
3413
|
+
async sendDelete(group, args) {
|
|
3414
|
+
const plaintext = encodeDelete({
|
|
3415
|
+
clientMsgId: args.clientMsgId,
|
|
3416
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3417
|
+
});
|
|
3418
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3419
|
+
const body = {
|
|
3420
|
+
ciphertext_b64: toBase64(ct),
|
|
3421
|
+
client_idem_key: randomId()
|
|
3422
|
+
};
|
|
3423
|
+
const wire = await palbeRequest(
|
|
3424
|
+
this.rt,
|
|
3425
|
+
"POST",
|
|
3426
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3427
|
+
{ body }
|
|
3428
|
+
);
|
|
3429
|
+
const stored = {
|
|
3430
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3431
|
+
direction: "outgoing",
|
|
3432
|
+
text: null,
|
|
3433
|
+
senderDeviceId: this.selfDeviceId,
|
|
3434
|
+
epoch: wire.epoch,
|
|
3435
|
+
serverSeq: wire.server_seq,
|
|
3436
|
+
at: Date.now(),
|
|
3437
|
+
clientMsgId: args.clientMsgId,
|
|
3438
|
+
replyTo: null,
|
|
3439
|
+
envelopeType: "delete",
|
|
3440
|
+
delete: {
|
|
3441
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3442
|
+
scope: "everyone"
|
|
3265
3443
|
}
|
|
3266
3444
|
};
|
|
3267
3445
|
try {
|
|
@@ -3334,6 +3512,41 @@ var GroupMessaging = class {
|
|
|
3334
3512
|
}
|
|
3335
3513
|
};
|
|
3336
3514
|
|
|
3515
|
+
// src/messaging/mention-ranges.ts
|
|
3516
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3517
|
+
const n = text.length;
|
|
3518
|
+
function splitsSurrogatePair(index) {
|
|
3519
|
+
if (index <= 0 || index >= n) return false;
|
|
3520
|
+
const before = text.charCodeAt(index - 1);
|
|
3521
|
+
const at = text.charCodeAt(index);
|
|
3522
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3523
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3524
|
+
return beforeIsHigh && atIsLow;
|
|
3525
|
+
}
|
|
3526
|
+
const survivors = [];
|
|
3527
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3528
|
+
const r = ranges[idx];
|
|
3529
|
+
if (r === void 0) continue;
|
|
3530
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3531
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3532
|
+
survivors.push({ idx, range: r });
|
|
3533
|
+
}
|
|
3534
|
+
survivors.sort((lhs, rhs) => {
|
|
3535
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3536
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3537
|
+
return lhs.idx - rhs.idx;
|
|
3538
|
+
});
|
|
3539
|
+
const kept = [];
|
|
3540
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3541
|
+
for (const s of survivors) {
|
|
3542
|
+
if (s.range.start >= prevEnd) {
|
|
3543
|
+
kept.push(s.range);
|
|
3544
|
+
prevEnd = s.range.start + s.range.length;
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
return kept;
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3337
3550
|
// src/messaging/reaction-fold.ts
|
|
3338
3551
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3339
3552
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3390,6 +3603,7 @@ var ReactionFold = class {
|
|
|
3390
3603
|
};
|
|
3391
3604
|
|
|
3392
3605
|
// src/messaging/chat.ts
|
|
3606
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3393
3607
|
var Chat = class {
|
|
3394
3608
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3395
3609
|
id;
|
|
@@ -3411,6 +3625,22 @@ var Chat = class {
|
|
|
3411
3625
|
reactionFold = new ReactionFold();
|
|
3412
3626
|
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3413
3627
|
editFold = new EditFold();
|
|
3628
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3629
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3630
|
+
deleteFold = new DeleteFold();
|
|
3631
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3632
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3633
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3634
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3635
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3636
|
+
suppressedLoaded = false;
|
|
3637
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3638
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3639
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3640
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3641
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3642
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3643
|
+
elevatedLoaded = false;
|
|
3414
3644
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3415
3645
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3416
3646
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3422,6 +3652,14 @@ var Chat = class {
|
|
|
3422
3652
|
wired = false;
|
|
3423
3653
|
liveUnsub = null;
|
|
3424
3654
|
listeners = /* @__PURE__ */ new Set();
|
|
3655
|
+
/**
|
|
3656
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3657
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3658
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3659
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3660
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3661
|
+
*/
|
|
3662
|
+
onMentionElevation;
|
|
3425
3663
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3426
3664
|
constructor(args) {
|
|
3427
3665
|
this.backend = args.backend;
|
|
@@ -3459,7 +3697,7 @@ var Chat = class {
|
|
|
3459
3697
|
return this.kind === "direct";
|
|
3460
3698
|
}
|
|
3461
3699
|
get messages() {
|
|
3462
|
-
return this.
|
|
3700
|
+
return this.surfaced();
|
|
3463
3701
|
}
|
|
3464
3702
|
get members() {
|
|
3465
3703
|
return this.memberCache;
|
|
@@ -3468,13 +3706,50 @@ var Chat = class {
|
|
|
3468
3706
|
return this.typingList;
|
|
3469
3707
|
}
|
|
3470
3708
|
get lastMessage() {
|
|
3471
|
-
return this.
|
|
3709
|
+
return this.surfaced().at(-1) ?? null;
|
|
3472
3710
|
}
|
|
3473
3711
|
get unreadCount() {
|
|
3474
|
-
return this.
|
|
3475
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3712
|
+
return this.surfaced().filter(
|
|
3713
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3476
3714
|
).length;
|
|
3477
3715
|
}
|
|
3716
|
+
/**
|
|
3717
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3718
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3719
|
+
* folded edit text + reactions + reply):
|
|
3720
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3721
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3722
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3723
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3724
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3725
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3726
|
+
*/
|
|
3727
|
+
surfaced() {
|
|
3728
|
+
const out = [];
|
|
3729
|
+
for (const m of this.messageList) {
|
|
3730
|
+
const key = this.suppressionKey(m);
|
|
3731
|
+
if (this.suppressed.has(key)) continue;
|
|
3732
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3733
|
+
if (tombstoned) {
|
|
3734
|
+
out.push({
|
|
3735
|
+
...m,
|
|
3736
|
+
text: DELETED_DESCRIPTOR,
|
|
3737
|
+
reactions: {},
|
|
3738
|
+
replyTo: null,
|
|
3739
|
+
edited: false,
|
|
3740
|
+
isDeleted: true,
|
|
3741
|
+
mentions: []
|
|
3742
|
+
});
|
|
3743
|
+
continue;
|
|
3744
|
+
}
|
|
3745
|
+
out.push(m);
|
|
3746
|
+
}
|
|
3747
|
+
return out;
|
|
3748
|
+
}
|
|
3749
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3750
|
+
suppressionKey(m) {
|
|
3751
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3752
|
+
}
|
|
3478
3753
|
get title() {
|
|
3479
3754
|
if (this.titleOverride) return this.titleOverride;
|
|
3480
3755
|
if (this._group?.name) return this._group.name;
|
|
@@ -3498,9 +3773,40 @@ var Chat = class {
|
|
|
3498
3773
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3499
3774
|
this.wired = true;
|
|
3500
3775
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3776
|
+
void this.loadSuppressed();
|
|
3777
|
+
void this.loadElevated();
|
|
3501
3778
|
void this.hydrateHistory();
|
|
3502
3779
|
void this.refreshMembers();
|
|
3503
3780
|
}
|
|
3781
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3782
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3783
|
+
async loadSuppressed() {
|
|
3784
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3785
|
+
this.suppressedLoaded = true;
|
|
3786
|
+
try {
|
|
3787
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3788
|
+
let changed = false;
|
|
3789
|
+
for (const k of keys) {
|
|
3790
|
+
if (!this.suppressed.has(k)) {
|
|
3791
|
+
this.suppressed.add(k);
|
|
3792
|
+
changed = true;
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
if (changed) this.emit();
|
|
3796
|
+
} catch {
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
3800
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
3801
|
+
async loadElevated() {
|
|
3802
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
3803
|
+
this.elevatedLoaded = true;
|
|
3804
|
+
try {
|
|
3805
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
3806
|
+
for (const k of keys) this.elevated.add(k);
|
|
3807
|
+
} catch {
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3504
3810
|
async hydrateHistory() {
|
|
3505
3811
|
if (this.historyLoaded || !this._group) return;
|
|
3506
3812
|
this.historyLoaded = true;
|
|
@@ -3511,13 +3817,13 @@ var Chat = class {
|
|
|
3511
3817
|
let changed = false;
|
|
3512
3818
|
for (const m of incoming) {
|
|
3513
3819
|
if (m.serverSeq <= 0) continue;
|
|
3514
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3820
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3515
3821
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3516
3822
|
text: m.text,
|
|
3517
3823
|
senderUserId: m.senderUserId ?? ""
|
|
3518
3824
|
});
|
|
3519
3825
|
}
|
|
3520
|
-
if (m.clientMsgId) {
|
|
3826
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3521
3827
|
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3522
3828
|
}
|
|
3523
3829
|
}
|
|
@@ -3527,7 +3833,12 @@ var Chat = class {
|
|
|
3527
3833
|
const key = this.internalKey(m.serverSeq);
|
|
3528
3834
|
if (this.seenKeys.has(key)) continue;
|
|
3529
3835
|
this.seenKeys.add(key);
|
|
3530
|
-
|
|
3836
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3837
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3838
|
+
}
|
|
3839
|
+
this.messageList.push(
|
|
3840
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3841
|
+
);
|
|
3531
3842
|
changed = true;
|
|
3532
3843
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3533
3844
|
}
|
|
@@ -3577,19 +3888,38 @@ var Chat = class {
|
|
|
3577
3888
|
newText: incoming.edit.newText,
|
|
3578
3889
|
epoch: incoming.epoch,
|
|
3579
3890
|
serverSeq: incoming.serverSeq,
|
|
3580
|
-
eventClientMsgId: incoming.clientMsgId
|
|
3891
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
3892
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
3893
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
3894
|
+
bodyRanges: incoming.bodyRanges
|
|
3581
3895
|
},
|
|
3582
3896
|
this.authorOfTarget
|
|
3583
3897
|
);
|
|
3584
3898
|
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3585
3899
|
return;
|
|
3586
3900
|
}
|
|
3901
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3902
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3903
|
+
this.deleteFold.ingest(
|
|
3904
|
+
{
|
|
3905
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3906
|
+
actorUserId,
|
|
3907
|
+
epoch: incoming.epoch,
|
|
3908
|
+
serverSeq: incoming.serverSeq,
|
|
3909
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3910
|
+
},
|
|
3911
|
+
this.authorOfTarget
|
|
3912
|
+
);
|
|
3913
|
+
this.emit();
|
|
3914
|
+
return;
|
|
3915
|
+
}
|
|
3587
3916
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3588
3917
|
const incomingReplyRef = incoming.replyRef;
|
|
3589
3918
|
let resolvedReplyTo = null;
|
|
3590
3919
|
if (incomingReplyRef) {
|
|
3591
3920
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3592
3921
|
}
|
|
3922
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3593
3923
|
const msg = {
|
|
3594
3924
|
id: this.publicId(incoming.serverSeq),
|
|
3595
3925
|
kind: this.kindOf(incoming),
|
|
@@ -3604,8 +3934,12 @@ var Chat = class {
|
|
|
3604
3934
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3605
3935
|
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3606
3936
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3607
|
-
edited: false
|
|
3937
|
+
edited: false,
|
|
3938
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3939
|
+
isDeleted: false,
|
|
3940
|
+
mentions
|
|
3608
3941
|
};
|
|
3942
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3609
3943
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3610
3944
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3611
3945
|
text: incoming.text,
|
|
@@ -3615,6 +3949,7 @@ var Chat = class {
|
|
|
3615
3949
|
if (incomingClientMsgId) {
|
|
3616
3950
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3617
3951
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3952
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3618
3953
|
}
|
|
3619
3954
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3620
3955
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3628,6 +3963,75 @@ var Chat = class {
|
|
|
3628
3963
|
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3629
3964
|
* so it can be passed to the pure EditFold. */
|
|
3630
3965
|
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3966
|
+
// ── Mentions (mentions T6) ──
|
|
3967
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3968
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
3969
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
3970
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
3971
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
3972
|
+
resolveMentions(text, bodyRanges) {
|
|
3973
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
3974
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
3975
|
+
if (normalized.length === 0) return [];
|
|
3976
|
+
return normalized.map((r) => ({
|
|
3977
|
+
start: r.start,
|
|
3978
|
+
length: r.length,
|
|
3979
|
+
mentionedUserId: r.mentionedUserId,
|
|
3980
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
3981
|
+
}));
|
|
3982
|
+
}
|
|
3983
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
3984
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
3985
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
3986
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
3987
|
+
resolveMentionNames(m) {
|
|
3988
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
3989
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
3990
|
+
}
|
|
3991
|
+
let changed = false;
|
|
3992
|
+
const reresolved = m.mentions.map((span) => {
|
|
3993
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
3994
|
+
if (name === span.displayName) return span;
|
|
3995
|
+
changed = true;
|
|
3996
|
+
return { ...span, displayName: name };
|
|
3997
|
+
});
|
|
3998
|
+
if (!changed) return m;
|
|
3999
|
+
return { ...m, mentions: reresolved };
|
|
4000
|
+
}
|
|
4001
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
4002
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
4003
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
4004
|
+
editMentions(targetClientMsgId, newText) {
|
|
4005
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
4006
|
+
if (!ranges) return [];
|
|
4007
|
+
return this.resolveMentions(newText, ranges);
|
|
4008
|
+
}
|
|
4009
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
4010
|
+
displayNameOf(userId) {
|
|
4011
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
4012
|
+
}
|
|
4013
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
4014
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
4015
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
4016
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
4017
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
4018
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
4019
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
4020
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
4021
|
+
const me = this.backend.selfUserId;
|
|
4022
|
+
if (envelopeType === "edit") return;
|
|
4023
|
+
if (senderUserId === me) return;
|
|
4024
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
4025
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4026
|
+
const key = `${me}|${idPart}`;
|
|
4027
|
+
if (this.elevated.has(key)) return;
|
|
4028
|
+
this.elevated.add(key);
|
|
4029
|
+
if (this._group) {
|
|
4030
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
4033
|
+
this.onMentionElevation?.(message);
|
|
4034
|
+
}
|
|
3631
4035
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3632
4036
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3633
4037
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3684,9 +4088,10 @@ var Chat = class {
|
|
|
3684
4088
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3685
4089
|
const text = editText ?? base;
|
|
3686
4090
|
const edited = foldEdited || m.edited;
|
|
3687
|
-
|
|
4091
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
4092
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3688
4093
|
changed = true;
|
|
3689
|
-
return { ...m, text, edited };
|
|
4094
|
+
return { ...m, text, edited, mentions };
|
|
3690
4095
|
});
|
|
3691
4096
|
if (changed) this.emit();
|
|
3692
4097
|
}
|
|
@@ -3703,8 +4108,9 @@ var Chat = class {
|
|
|
3703
4108
|
if (editText === null && !foldEdited) return m;
|
|
3704
4109
|
const text = editText ?? m.text;
|
|
3705
4110
|
const edited = foldEdited || m.edited;
|
|
3706
|
-
|
|
3707
|
-
|
|
4111
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
4112
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
4113
|
+
return { ...m, text, edited, mentions };
|
|
3708
4114
|
}
|
|
3709
4115
|
/** @internal — called by the backend's conv subscription. */
|
|
3710
4116
|
applyConv(event, payload) {
|
|
@@ -3762,6 +4168,18 @@ var Chat = class {
|
|
|
3762
4168
|
}
|
|
3763
4169
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3764
4170
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
4171
|
+
this.reresolveAllMentionNames();
|
|
4172
|
+
}
|
|
4173
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
4174
|
+
* change). Re-emits only if any name actually changed. */
|
|
4175
|
+
reresolveAllMentionNames() {
|
|
4176
|
+
let changed = false;
|
|
4177
|
+
this.messageList = this.messageList.map((m) => {
|
|
4178
|
+
const reresolved = this.resolveMentionNames(m);
|
|
4179
|
+
if (reresolved !== m) changed = true;
|
|
4180
|
+
return reresolved;
|
|
4181
|
+
});
|
|
4182
|
+
if (changed) this.emit();
|
|
3765
4183
|
}
|
|
3766
4184
|
seedMembersFromGroup(group) {
|
|
3767
4185
|
const seed = [
|
|
@@ -3829,11 +4247,12 @@ var Chat = class {
|
|
|
3829
4247
|
};
|
|
3830
4248
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3831
4249
|
}
|
|
3832
|
-
const
|
|
3833
|
-
this.
|
|
4250
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4251
|
+
const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
|
|
4252
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
3834
4253
|
return receipt;
|
|
3835
4254
|
}
|
|
3836
|
-
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
|
|
4255
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
3837
4256
|
if (receipt.serverSeq <= 0) return;
|
|
3838
4257
|
const key = this.internalKey(receipt.serverSeq);
|
|
3839
4258
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3856,7 +4275,12 @@ var Chat = class {
|
|
|
3856
4275
|
// the dangling-target invariant uniform across every append path).
|
|
3857
4276
|
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
3858
4277
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3859
|
-
edited: false
|
|
4278
|
+
edited: false,
|
|
4279
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4280
|
+
isDeleted: false,
|
|
4281
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4282
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4283
|
+
mentions: this.resolveMentions(text, bodyRanges)
|
|
3860
4284
|
});
|
|
3861
4285
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3862
4286
|
this.emit();
|
|
@@ -3944,15 +4368,18 @@ var Chat = class {
|
|
|
3944
4368
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3945
4369
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3946
4370
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
3947
|
-
* message self IS the author, so the author-gate passes.
|
|
3948
|
-
|
|
4371
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4372
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4373
|
+
async edit(message, newText, opts) {
|
|
3949
4374
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3950
4375
|
const group = await this.materializeIfNeeded();
|
|
3951
4376
|
const clientMsgId = mintClientMsgId();
|
|
4377
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3952
4378
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
3953
4379
|
clientMsgId,
|
|
3954
4380
|
targetClientMsgId: message.clientMsgId,
|
|
3955
|
-
newText
|
|
4381
|
+
newText,
|
|
4382
|
+
bodyRanges
|
|
3956
4383
|
});
|
|
3957
4384
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3958
4385
|
this.editFold.ingest(
|
|
@@ -3962,13 +4389,72 @@ var Chat = class {
|
|
|
3962
4389
|
newText,
|
|
3963
4390
|
epoch: receipt.epoch,
|
|
3964
4391
|
serverSeq: receipt.serverSeq,
|
|
3965
|
-
eventClientMsgId: clientMsgId
|
|
4392
|
+
eventClientMsgId: clientMsgId,
|
|
4393
|
+
bodyRanges
|
|
3966
4394
|
},
|
|
3967
4395
|
this.authorOfTarget
|
|
3968
4396
|
);
|
|
3969
4397
|
this.recomputeEdit(message.clientMsgId);
|
|
3970
4398
|
}
|
|
4399
|
+
// ── Delete ──
|
|
4400
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
4401
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
4402
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
4403
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
4404
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
4405
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
4406
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
4407
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
4408
|
+
async deleteForEveryone(message) {
|
|
4409
|
+
if (!message.clientMsgId) return;
|
|
4410
|
+
const group = await this.materializeIfNeeded();
|
|
4411
|
+
const clientMsgId = mintClientMsgId();
|
|
4412
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
4413
|
+
clientMsgId,
|
|
4414
|
+
targetClientMsgId: message.clientMsgId
|
|
4415
|
+
});
|
|
4416
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4417
|
+
this.deleteFold.ingest(
|
|
4418
|
+
{
|
|
4419
|
+
targetClientMsgId: message.clientMsgId,
|
|
4420
|
+
actorUserId: this.backend.selfUserId,
|
|
4421
|
+
epoch: receipt.epoch,
|
|
4422
|
+
serverSeq: receipt.serverSeq,
|
|
4423
|
+
eventClientMsgId: clientMsgId
|
|
4424
|
+
},
|
|
4425
|
+
this.authorOfTarget
|
|
4426
|
+
);
|
|
4427
|
+
this.emit();
|
|
4428
|
+
}
|
|
4429
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4430
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4431
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4432
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4433
|
+
async deleteForMe(message) {
|
|
4434
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4435
|
+
if (this.suppressed.has(key)) return;
|
|
4436
|
+
this.suppressed.add(key);
|
|
4437
|
+
this.emit();
|
|
4438
|
+
if (this._group) {
|
|
4439
|
+
try {
|
|
4440
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4441
|
+
} catch {
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
3971
4445
|
};
|
|
4446
|
+
function sameMentions(a, b) {
|
|
4447
|
+
if (a.length !== b.length) return false;
|
|
4448
|
+
for (let i = 0; i < a.length; i++) {
|
|
4449
|
+
const x = a[i];
|
|
4450
|
+
const y = b[i];
|
|
4451
|
+
if (!x || !y) return false;
|
|
4452
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4453
|
+
return false;
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
return true;
|
|
4457
|
+
}
|
|
3972
4458
|
function sameReactions(a, b) {
|
|
3973
4459
|
const ak = Object.keys(a);
|
|
3974
4460
|
const bk = Object.keys(b);
|
|
@@ -4148,6 +4634,7 @@ var MessageDeliverySource = class {
|
|
|
4148
4634
|
const { text, clientMsgId, replyTo } = decoded;
|
|
4149
4635
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4150
4636
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4637
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
4151
4638
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4152
4639
|
const stored = {
|
|
4153
4640
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4179,12 +4666,31 @@ var MessageDeliverySource = class {
|
|
|
4179
4666
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4180
4667
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4181
4668
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4182
|
-
// `'text'`/no-edit (backward-compat).
|
|
4669
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
4670
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
4183
4671
|
...isEdit && decoded.edit ? {
|
|
4184
4672
|
envelopeType: "edit",
|
|
4185
4673
|
edit: {
|
|
4186
4674
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4187
|
-
newText: decoded.edit.newText
|
|
4675
|
+
newText: decoded.edit.newText,
|
|
4676
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4677
|
+
}
|
|
4678
|
+
} : {},
|
|
4679
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
4680
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
4681
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
4682
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
4683
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4684
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4685
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4686
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4687
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4688
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4689
|
+
...isDelete && decoded.delete ? {
|
|
4690
|
+
envelopeType: "delete",
|
|
4691
|
+
delete: {
|
|
4692
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4693
|
+
scope: decoded.delete.scope
|
|
4188
4694
|
}
|
|
4189
4695
|
} : {}
|
|
4190
4696
|
};
|
|
@@ -4205,7 +4711,11 @@ var MessageDeliverySource = class {
|
|
|
4205
4711
|
replyRef: replyTo,
|
|
4206
4712
|
envelopeType: decoded.type ?? "text",
|
|
4207
4713
|
reaction: isReaction ? decoded.reaction : null,
|
|
4208
|
-
edit: isEdit ? decoded.edit : null
|
|
4714
|
+
edit: isEdit ? decoded.edit : null,
|
|
4715
|
+
delete: isDelete ? decoded.delete : null,
|
|
4716
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4717
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4718
|
+
bodyRanges: decoded.bodyRanges ?? null
|
|
4209
4719
|
});
|
|
4210
4720
|
return true;
|
|
4211
4721
|
}
|
|
@@ -4360,6 +4870,36 @@ var GroupCatalog = class {
|
|
|
4360
4870
|
}
|
|
4361
4871
|
};
|
|
4362
4872
|
|
|
4873
|
+
// src/messaging/mention-elevation.ts
|
|
4874
|
+
var MentionElevationStore = class {
|
|
4875
|
+
constructor(kv) {
|
|
4876
|
+
this.kv = kv;
|
|
4877
|
+
}
|
|
4878
|
+
kv;
|
|
4879
|
+
key(rfcGroupId) {
|
|
4880
|
+
return `elev:${rfcGroupId}`;
|
|
4881
|
+
}
|
|
4882
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
4883
|
+
async load(rfcGroupId) {
|
|
4884
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
4885
|
+
if (!raw) return [];
|
|
4886
|
+
try {
|
|
4887
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
4888
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4889
|
+
} catch {
|
|
4890
|
+
return [];
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
4894
|
+
async save(rfcGroupId, keys) {
|
|
4895
|
+
const sorted = [...new Set(keys)].sort();
|
|
4896
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
4897
|
+
}
|
|
4898
|
+
async wipe() {
|
|
4899
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
4900
|
+
}
|
|
4901
|
+
};
|
|
4902
|
+
|
|
4363
4903
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4364
4904
|
var palbe_mls_bg_exports = {};
|
|
4365
4905
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6027,6 +6567,36 @@ var SignatureKeyStore = class {
|
|
|
6027
6567
|
}
|
|
6028
6568
|
};
|
|
6029
6569
|
|
|
6570
|
+
// src/messaging/suppression.ts
|
|
6571
|
+
var SuppressionStore = class {
|
|
6572
|
+
constructor(kv) {
|
|
6573
|
+
this.kv = kv;
|
|
6574
|
+
}
|
|
6575
|
+
kv;
|
|
6576
|
+
key(rfcGroupId) {
|
|
6577
|
+
return `supp:${rfcGroupId}`;
|
|
6578
|
+
}
|
|
6579
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6580
|
+
async load(rfcGroupId) {
|
|
6581
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6582
|
+
if (!raw) return [];
|
|
6583
|
+
try {
|
|
6584
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6585
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6586
|
+
} catch {
|
|
6587
|
+
return [];
|
|
6588
|
+
}
|
|
6589
|
+
}
|
|
6590
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6591
|
+
async save(rfcGroupId, keys) {
|
|
6592
|
+
const sorted = [...new Set(keys)].sort();
|
|
6593
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6594
|
+
}
|
|
6595
|
+
async wipe() {
|
|
6596
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6597
|
+
}
|
|
6598
|
+
};
|
|
6599
|
+
|
|
6030
6600
|
// src/messaging/coordinator.ts
|
|
6031
6601
|
var MessagingCoordinator = class {
|
|
6032
6602
|
constructor(rt) {
|
|
@@ -6036,6 +6606,8 @@ var MessagingCoordinator = class {
|
|
|
6036
6606
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
6037
6607
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6038
6608
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6609
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6610
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6039
6611
|
this.registry.attachChatList(
|
|
6040
6612
|
(chats) => {
|
|
6041
6613
|
this.chatList = chats;
|
|
@@ -6050,6 +6622,8 @@ var MessagingCoordinator = class {
|
|
|
6050
6622
|
sigStore;
|
|
6051
6623
|
groupStore;
|
|
6052
6624
|
kpStore;
|
|
6625
|
+
suppressionStore;
|
|
6626
|
+
elevationStore;
|
|
6053
6627
|
registry = new GroupRegistry();
|
|
6054
6628
|
resolved = null;
|
|
6055
6629
|
resolvePromise = null;
|
|
@@ -6205,9 +6779,9 @@ var MessagingCoordinator = class {
|
|
|
6205
6779
|
});
|
|
6206
6780
|
return group;
|
|
6207
6781
|
}
|
|
6208
|
-
async sendText(group, text, replyTo) {
|
|
6782
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6209
6783
|
const r = await this.resolve();
|
|
6210
|
-
return r.groups.sendText(group, text, replyTo);
|
|
6784
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6211
6785
|
}
|
|
6212
6786
|
async sendReaction(group, args) {
|
|
6213
6787
|
const r = await this.resolve();
|
|
@@ -6217,6 +6791,26 @@ var MessagingCoordinator = class {
|
|
|
6217
6791
|
const r = await this.resolve();
|
|
6218
6792
|
return r.groups.sendEdit(group, args);
|
|
6219
6793
|
}
|
|
6794
|
+
async sendDelete(group, args) {
|
|
6795
|
+
const r = await this.resolve();
|
|
6796
|
+
return r.groups.sendDelete(group, args);
|
|
6797
|
+
}
|
|
6798
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6799
|
+
loadSuppressed(group) {
|
|
6800
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6801
|
+
}
|
|
6802
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6803
|
+
saveSuppressed(group, keys) {
|
|
6804
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6805
|
+
}
|
|
6806
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
6807
|
+
loadElevated(group) {
|
|
6808
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
6809
|
+
}
|
|
6810
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
6811
|
+
saveElevated(group, keys) {
|
|
6812
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6813
|
+
}
|
|
6220
6814
|
async history(group, limit, before) {
|
|
6221
6815
|
const r = await this.resolve();
|
|
6222
6816
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6314,9 +6908,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6314
6908
|
});
|
|
6315
6909
|
}
|
|
6316
6910
|
const editFold = new EditFold();
|
|
6911
|
+
const deleteFold = new DeleteFold();
|
|
6317
6912
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6318
6913
|
for (const s of rows) {
|
|
6319
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6914
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6915
|
+
continue;
|
|
6320
6916
|
const cid = s.clientMsgId ?? "";
|
|
6321
6917
|
if (!cid) continue;
|
|
6322
6918
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
@@ -6333,15 +6929,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6333
6929
|
newText: s.edit.newText,
|
|
6334
6930
|
epoch: s.epoch,
|
|
6335
6931
|
serverSeq: s.serverSeq,
|
|
6336
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
6932
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
6933
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
6934
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
6935
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6337
6936
|
},
|
|
6338
6937
|
authorOfTarget
|
|
6339
6938
|
);
|
|
6340
6939
|
}
|
|
6341
6940
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6941
|
+
for (const s of rows) {
|
|
6942
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6943
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6944
|
+
deleteFold.ingest(
|
|
6945
|
+
{
|
|
6946
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6947
|
+
actorUserId: actor,
|
|
6948
|
+
epoch: s.epoch,
|
|
6949
|
+
serverSeq: s.serverSeq,
|
|
6950
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6951
|
+
},
|
|
6952
|
+
authorOfTarget
|
|
6953
|
+
);
|
|
6954
|
+
}
|
|
6955
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
6342
6956
|
const lookup = /* @__PURE__ */ new Map();
|
|
6343
6957
|
for (const s of rows) {
|
|
6344
|
-
if (s.envelopeType === "reaction")
|
|
6958
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6959
|
+
continue;
|
|
6345
6960
|
const cid = s.clientMsgId ?? "";
|
|
6346
6961
|
if (cid && s.text !== null) {
|
|
6347
6962
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -6350,8 +6965,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6350
6965
|
}
|
|
6351
6966
|
const out = [];
|
|
6352
6967
|
for (const s of rows) {
|
|
6353
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6968
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6969
|
+
continue;
|
|
6354
6970
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6971
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6972
|
+
if (isDeleted) {
|
|
6973
|
+
out.push({
|
|
6974
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6975
|
+
kind: "text",
|
|
6976
|
+
direction: s.direction,
|
|
6977
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6978
|
+
text: DELETED_DESCRIPTOR,
|
|
6979
|
+
serverSeq: s.serverSeq,
|
|
6980
|
+
sentAt: new Date(s.at),
|
|
6981
|
+
clientMsgId,
|
|
6982
|
+
replyTo: null,
|
|
6983
|
+
reactions: {},
|
|
6984
|
+
edited: false,
|
|
6985
|
+
isDeleted: true,
|
|
6986
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6987
|
+
mentions: []
|
|
6988
|
+
});
|
|
6989
|
+
continue;
|
|
6990
|
+
}
|
|
6355
6991
|
let replyTo = null;
|
|
6356
6992
|
if (s.replyTo) {
|
|
6357
6993
|
const ref = {
|
|
@@ -6368,22 +7004,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6368
7004
|
}
|
|
6369
7005
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6370
7006
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
7007
|
+
const text = editText ?? s.text;
|
|
7008
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
7009
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6371
7010
|
out.push({
|
|
6372
7011
|
id: `${displayId}#${s.serverSeq}`,
|
|
6373
7012
|
kind: s.text != null ? "text" : "system",
|
|
6374
7013
|
direction: s.direction,
|
|
6375
7014
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6376
|
-
text
|
|
7015
|
+
text,
|
|
6377
7016
|
serverSeq: s.serverSeq,
|
|
6378
7017
|
sentAt: new Date(s.at),
|
|
6379
7018
|
clientMsgId,
|
|
6380
7019
|
replyTo,
|
|
6381
7020
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6382
|
-
edited
|
|
7021
|
+
edited,
|
|
7022
|
+
isDeleted: false,
|
|
7023
|
+
mentions
|
|
6383
7024
|
});
|
|
6384
7025
|
}
|
|
6385
7026
|
return out;
|
|
6386
7027
|
}
|
|
7028
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
7029
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
7030
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
7031
|
+
start: r.start,
|
|
7032
|
+
length: r.length,
|
|
7033
|
+
mentionedUserId: r.mentionedUserId,
|
|
7034
|
+
displayName: null
|
|
7035
|
+
}));
|
|
7036
|
+
}
|
|
6387
7037
|
|
|
6388
7038
|
// src/messaging/facade.ts
|
|
6389
7039
|
var PalbeMessaging = class {
|
|
@@ -7117,7 +7767,7 @@ function defaultSessionStorage(key) {
|
|
|
7117
7767
|
}
|
|
7118
7768
|
|
|
7119
7769
|
// src/version.ts
|
|
7120
|
-
var VERSION = "1.
|
|
7770
|
+
var VERSION = "1.5.0";
|
|
7121
7771
|
|
|
7122
7772
|
// src/runtime.ts
|
|
7123
7773
|
function buildRuntime(config) {
|