@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/client.cjs
CHANGED
|
@@ -2413,6 +2413,69 @@ var PalbeFlags = class {
|
|
|
2413
2413
|
}
|
|
2414
2414
|
};
|
|
2415
2415
|
|
|
2416
|
+
// src/messaging/delete-fold.ts
|
|
2417
|
+
var DeleteFold = class {
|
|
2418
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2419
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2420
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2421
|
+
pending = /* @__PURE__ */ new Map();
|
|
2422
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2423
|
+
seen = /* @__PURE__ */ new Set();
|
|
2424
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2425
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2426
|
+
held = [];
|
|
2427
|
+
/**
|
|
2428
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2429
|
+
* userId (null = target absent locally → defer).
|
|
2430
|
+
*/
|
|
2431
|
+
ingest(e, authorOfTarget) {
|
|
2432
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2433
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2434
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2435
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2436
|
+
if (author !== null) {
|
|
2437
|
+
this.seen.add(e.eventClientMsgId);
|
|
2438
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2439
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2440
|
+
} else if (e.actorUserId !== null) {
|
|
2441
|
+
this.seen.add(e.eventClientMsgId);
|
|
2442
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2443
|
+
} else {
|
|
2444
|
+
this.held.push(e);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2448
|
+
isTombstoned(targetClientMsgId) {
|
|
2449
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2453
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2454
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2455
|
+
* the in-order path.
|
|
2456
|
+
*/
|
|
2457
|
+
reevaluatePending(target, author) {
|
|
2458
|
+
const actor = this.pending.get(target);
|
|
2459
|
+
if (actor !== void 0) {
|
|
2460
|
+
if (author !== null && actor === author) {
|
|
2461
|
+
this.tombstoned.add(target);
|
|
2462
|
+
this.pending.delete(target);
|
|
2463
|
+
} else if (author !== null) {
|
|
2464
|
+
this.pending.delete(target);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
if (this.held.length === 0) return;
|
|
2468
|
+
const pendingHeld = this.held;
|
|
2469
|
+
this.held = [];
|
|
2470
|
+
for (const e of pendingHeld) {
|
|
2471
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
heldContains(eventClientMsgId) {
|
|
2475
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2476
|
+
}
|
|
2477
|
+
};
|
|
2478
|
+
|
|
2416
2479
|
// src/messaging/edit-fold.ts
|
|
2417
2480
|
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2418
2481
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -2458,7 +2521,8 @@ var EditFold = class {
|
|
|
2458
2521
|
orderEpoch: e.epoch,
|
|
2459
2522
|
orderSeq: e.serverSeq,
|
|
2460
2523
|
lastEventId: e.eventClientMsgId,
|
|
2461
|
-
text: e.newText
|
|
2524
|
+
text: e.newText,
|
|
2525
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2462
2526
|
});
|
|
2463
2527
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2464
2528
|
}
|
|
@@ -2479,6 +2543,15 @@ var EditFold = class {
|
|
|
2479
2543
|
isEdited(targetClientMsgId) {
|
|
2480
2544
|
return this.editedTargets.has(targetClientMsgId);
|
|
2481
2545
|
}
|
|
2546
|
+
/**
|
|
2547
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2548
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2549
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2550
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2551
|
+
*/
|
|
2552
|
+
bodyRanges(targetClientMsgId) {
|
|
2553
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2554
|
+
}
|
|
2482
2555
|
/**
|
|
2483
2556
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2484
2557
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2634,6 +2707,17 @@ async function listDevices(rt, userId) {
|
|
|
2634
2707
|
}
|
|
2635
2708
|
|
|
2636
2709
|
// src/messaging/group-messaging.ts
|
|
2710
|
+
function encodeDelete(args) {
|
|
2711
|
+
return encodeUtf8(
|
|
2712
|
+
JSON.stringify({
|
|
2713
|
+
v: 1,
|
|
2714
|
+
type: "delete",
|
|
2715
|
+
client_msg_id: args.clientMsgId,
|
|
2716
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2717
|
+
scope: "everyone"
|
|
2718
|
+
})
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2637
2721
|
function encodeEdit(args) {
|
|
2638
2722
|
return encodeUtf8(
|
|
2639
2723
|
JSON.stringify({
|
|
@@ -2641,7 +2725,14 @@ function encodeEdit(args) {
|
|
|
2641
2725
|
type: "edit",
|
|
2642
2726
|
client_msg_id: args.clientMsgId,
|
|
2643
2727
|
target_client_msg_id: args.targetClientMsgId,
|
|
2644
|
-
new_text: args.newText
|
|
2728
|
+
new_text: args.newText,
|
|
2729
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2730
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2731
|
+
start: r.start,
|
|
2732
|
+
length: r.length,
|
|
2733
|
+
mentioned_user_id: r.mentionedUserId
|
|
2734
|
+
}))
|
|
2735
|
+
} : {}
|
|
2645
2736
|
})
|
|
2646
2737
|
);
|
|
2647
2738
|
}
|
|
@@ -2663,7 +2754,14 @@ function encodeEnvelope(args) {
|
|
|
2663
2754
|
type: "text",
|
|
2664
2755
|
client_msg_id: args.clientMsgId,
|
|
2665
2756
|
text: args.text,
|
|
2666
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
2757
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
2758
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2759
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2760
|
+
start: r.start,
|
|
2761
|
+
length: r.length,
|
|
2762
|
+
mentioned_user_id: r.mentionedUserId
|
|
2763
|
+
}))
|
|
2764
|
+
} : {}
|
|
2667
2765
|
};
|
|
2668
2766
|
return encodeUtf8(JSON.stringify(env));
|
|
2669
2767
|
}
|
|
@@ -2671,6 +2769,18 @@ function decodeEnvelope(bytes) {
|
|
|
2671
2769
|
const s = decodeUtf8(bytes);
|
|
2672
2770
|
try {
|
|
2673
2771
|
const o = JSON.parse(s);
|
|
2772
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2773
|
+
return {
|
|
2774
|
+
type: "delete",
|
|
2775
|
+
text: null,
|
|
2776
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2777
|
+
replyTo: null,
|
|
2778
|
+
delete: {
|
|
2779
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2780
|
+
scope: o.scope ?? "everyone"
|
|
2781
|
+
}
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2674
2784
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2675
2785
|
return {
|
|
2676
2786
|
type: "reaction",
|
|
@@ -2685,6 +2795,7 @@ function decodeEnvelope(bytes) {
|
|
|
2685
2795
|
};
|
|
2686
2796
|
}
|
|
2687
2797
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2798
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2688
2799
|
return {
|
|
2689
2800
|
type: "edit",
|
|
2690
2801
|
text: null,
|
|
@@ -2693,15 +2804,18 @@ function decodeEnvelope(bytes) {
|
|
|
2693
2804
|
edit: {
|
|
2694
2805
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2695
2806
|
newText: o.new_text ?? ""
|
|
2696
|
-
}
|
|
2807
|
+
},
|
|
2808
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2697
2809
|
};
|
|
2698
2810
|
}
|
|
2699
2811
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2812
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2700
2813
|
return {
|
|
2701
2814
|
type: "text",
|
|
2702
2815
|
text: o.text ?? null,
|
|
2703
2816
|
clientMsgId: o.client_msg_id ?? "",
|
|
2704
|
-
replyTo: o.reply_to ?? null
|
|
2817
|
+
replyTo: o.reply_to ?? null,
|
|
2818
|
+
...textRanges ? { bodyRanges: textRanges } : {}
|
|
2705
2819
|
};
|
|
2706
2820
|
}
|
|
2707
2821
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2713,6 +2827,14 @@ function decodeEnvelope(bytes) {
|
|
|
2713
2827
|
}
|
|
2714
2828
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2715
2829
|
}
|
|
2830
|
+
function decodeBodyRanges(raw) {
|
|
2831
|
+
if (!raw || raw.length === 0) return void 0;
|
|
2832
|
+
return raw.map((r) => ({
|
|
2833
|
+
start: r.start,
|
|
2834
|
+
length: r.length,
|
|
2835
|
+
mentionedUserId: r.mentioned_user_id
|
|
2836
|
+
}));
|
|
2837
|
+
}
|
|
2716
2838
|
function resolveReply(ref, lookup) {
|
|
2717
2839
|
const parent = lookup(ref.client_msg_id);
|
|
2718
2840
|
if (parent !== null) {
|
|
@@ -2924,9 +3046,9 @@ var GroupMessaging = class {
|
|
|
2924
3046
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
2925
3047
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
2926
3048
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
2927
|
-
async sendText(group, text, replyTo) {
|
|
3049
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
2928
3050
|
const clientMsgId = mintClientMsgId();
|
|
2929
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3051
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
2930
3052
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2931
3053
|
const body = {
|
|
2932
3054
|
ciphertext_b64: toBase64(ct),
|
|
@@ -2952,7 +3074,10 @@ var GroupMessaging = class {
|
|
|
2952
3074
|
previewBody: replyTo.preview?.body ?? null,
|
|
2953
3075
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
2954
3076
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
2955
|
-
} : null
|
|
3077
|
+
} : null,
|
|
3078
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3079
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3080
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
2956
3081
|
};
|
|
2957
3082
|
try {
|
|
2958
3083
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3020,7 +3145,8 @@ var GroupMessaging = class {
|
|
|
3020
3145
|
const plaintext = encodeEdit({
|
|
3021
3146
|
clientMsgId: args.clientMsgId,
|
|
3022
3147
|
targetClientMsgId: args.targetClientMsgId,
|
|
3023
|
-
newText: args.newText
|
|
3148
|
+
newText: args.newText,
|
|
3149
|
+
bodyRanges: args.bodyRanges
|
|
3024
3150
|
});
|
|
3025
3151
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3026
3152
|
const body = {
|
|
@@ -3046,7 +3172,59 @@ var GroupMessaging = class {
|
|
|
3046
3172
|
envelopeType: "edit",
|
|
3047
3173
|
edit: {
|
|
3048
3174
|
targetClientMsgId: args.targetClientMsgId,
|
|
3049
|
-
newText: args.newText
|
|
3175
|
+
newText: args.newText,
|
|
3176
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3177
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3178
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3179
|
+
}
|
|
3180
|
+
};
|
|
3181
|
+
try {
|
|
3182
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3183
|
+
} catch {
|
|
3184
|
+
}
|
|
3185
|
+
return {
|
|
3186
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3187
|
+
clientMsgId: args.clientMsgId
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3191
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3192
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3193
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3194
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3195
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3196
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3197
|
+
* rebases (epoch-bound like any application message). */
|
|
3198
|
+
async sendDelete(group, args) {
|
|
3199
|
+
const plaintext = encodeDelete({
|
|
3200
|
+
clientMsgId: args.clientMsgId,
|
|
3201
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3202
|
+
});
|
|
3203
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3204
|
+
const body = {
|
|
3205
|
+
ciphertext_b64: toBase64(ct),
|
|
3206
|
+
client_idem_key: randomId()
|
|
3207
|
+
};
|
|
3208
|
+
const wire = await palbeRequest(
|
|
3209
|
+
this.rt,
|
|
3210
|
+
"POST",
|
|
3211
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3212
|
+
{ body }
|
|
3213
|
+
);
|
|
3214
|
+
const stored = {
|
|
3215
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3216
|
+
direction: "outgoing",
|
|
3217
|
+
text: null,
|
|
3218
|
+
senderDeviceId: this.selfDeviceId,
|
|
3219
|
+
epoch: wire.epoch,
|
|
3220
|
+
serverSeq: wire.server_seq,
|
|
3221
|
+
at: Date.now(),
|
|
3222
|
+
clientMsgId: args.clientMsgId,
|
|
3223
|
+
replyTo: null,
|
|
3224
|
+
envelopeType: "delete",
|
|
3225
|
+
delete: {
|
|
3226
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3227
|
+
scope: "everyone"
|
|
3050
3228
|
}
|
|
3051
3229
|
};
|
|
3052
3230
|
try {
|
|
@@ -3119,6 +3297,41 @@ var GroupMessaging = class {
|
|
|
3119
3297
|
}
|
|
3120
3298
|
};
|
|
3121
3299
|
|
|
3300
|
+
// src/messaging/mention-ranges.ts
|
|
3301
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3302
|
+
const n = text.length;
|
|
3303
|
+
function splitsSurrogatePair(index) {
|
|
3304
|
+
if (index <= 0 || index >= n) return false;
|
|
3305
|
+
const before = text.charCodeAt(index - 1);
|
|
3306
|
+
const at = text.charCodeAt(index);
|
|
3307
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3308
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3309
|
+
return beforeIsHigh && atIsLow;
|
|
3310
|
+
}
|
|
3311
|
+
const survivors = [];
|
|
3312
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3313
|
+
const r = ranges[idx];
|
|
3314
|
+
if (r === void 0) continue;
|
|
3315
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3316
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3317
|
+
survivors.push({ idx, range: r });
|
|
3318
|
+
}
|
|
3319
|
+
survivors.sort((lhs, rhs) => {
|
|
3320
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3321
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3322
|
+
return lhs.idx - rhs.idx;
|
|
3323
|
+
});
|
|
3324
|
+
const kept = [];
|
|
3325
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3326
|
+
for (const s of survivors) {
|
|
3327
|
+
if (s.range.start >= prevEnd) {
|
|
3328
|
+
kept.push(s.range);
|
|
3329
|
+
prevEnd = s.range.start + s.range.length;
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
return kept;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3122
3335
|
// src/messaging/reaction-fold.ts
|
|
3123
3336
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3124
3337
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3175,6 +3388,7 @@ var ReactionFold = class {
|
|
|
3175
3388
|
};
|
|
3176
3389
|
|
|
3177
3390
|
// src/messaging/chat.ts
|
|
3391
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3178
3392
|
var Chat = class {
|
|
3179
3393
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3180
3394
|
id;
|
|
@@ -3196,6 +3410,22 @@ var Chat = class {
|
|
|
3196
3410
|
reactionFold = new ReactionFold();
|
|
3197
3411
|
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3198
3412
|
editFold = new EditFold();
|
|
3413
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3414
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3415
|
+
deleteFold = new DeleteFold();
|
|
3416
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3417
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3418
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3419
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3420
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3421
|
+
suppressedLoaded = false;
|
|
3422
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3423
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3424
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3425
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3426
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3427
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3428
|
+
elevatedLoaded = false;
|
|
3199
3429
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3200
3430
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3201
3431
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3207,6 +3437,14 @@ var Chat = class {
|
|
|
3207
3437
|
wired = false;
|
|
3208
3438
|
liveUnsub = null;
|
|
3209
3439
|
listeners = /* @__PURE__ */ new Set();
|
|
3440
|
+
/**
|
|
3441
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3442
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3443
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3444
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3445
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3446
|
+
*/
|
|
3447
|
+
onMentionElevation;
|
|
3210
3448
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3211
3449
|
constructor(args) {
|
|
3212
3450
|
this.backend = args.backend;
|
|
@@ -3244,7 +3482,7 @@ var Chat = class {
|
|
|
3244
3482
|
return this.kind === "direct";
|
|
3245
3483
|
}
|
|
3246
3484
|
get messages() {
|
|
3247
|
-
return this.
|
|
3485
|
+
return this.surfaced();
|
|
3248
3486
|
}
|
|
3249
3487
|
get members() {
|
|
3250
3488
|
return this.memberCache;
|
|
@@ -3253,13 +3491,50 @@ var Chat = class {
|
|
|
3253
3491
|
return this.typingList;
|
|
3254
3492
|
}
|
|
3255
3493
|
get lastMessage() {
|
|
3256
|
-
return this.
|
|
3494
|
+
return this.surfaced().at(-1) ?? null;
|
|
3257
3495
|
}
|
|
3258
3496
|
get unreadCount() {
|
|
3259
|
-
return this.
|
|
3260
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3497
|
+
return this.surfaced().filter(
|
|
3498
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3261
3499
|
).length;
|
|
3262
3500
|
}
|
|
3501
|
+
/**
|
|
3502
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3503
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3504
|
+
* folded edit text + reactions + reply):
|
|
3505
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3506
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3507
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3508
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3509
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3510
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3511
|
+
*/
|
|
3512
|
+
surfaced() {
|
|
3513
|
+
const out = [];
|
|
3514
|
+
for (const m of this.messageList) {
|
|
3515
|
+
const key = this.suppressionKey(m);
|
|
3516
|
+
if (this.suppressed.has(key)) continue;
|
|
3517
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3518
|
+
if (tombstoned) {
|
|
3519
|
+
out.push({
|
|
3520
|
+
...m,
|
|
3521
|
+
text: DELETED_DESCRIPTOR,
|
|
3522
|
+
reactions: {},
|
|
3523
|
+
replyTo: null,
|
|
3524
|
+
edited: false,
|
|
3525
|
+
isDeleted: true,
|
|
3526
|
+
mentions: []
|
|
3527
|
+
});
|
|
3528
|
+
continue;
|
|
3529
|
+
}
|
|
3530
|
+
out.push(m);
|
|
3531
|
+
}
|
|
3532
|
+
return out;
|
|
3533
|
+
}
|
|
3534
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3535
|
+
suppressionKey(m) {
|
|
3536
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3537
|
+
}
|
|
3263
3538
|
get title() {
|
|
3264
3539
|
if (this.titleOverride) return this.titleOverride;
|
|
3265
3540
|
if (this._group?.name) return this._group.name;
|
|
@@ -3283,9 +3558,40 @@ var Chat = class {
|
|
|
3283
3558
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3284
3559
|
this.wired = true;
|
|
3285
3560
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3561
|
+
void this.loadSuppressed();
|
|
3562
|
+
void this.loadElevated();
|
|
3286
3563
|
void this.hydrateHistory();
|
|
3287
3564
|
void this.refreshMembers();
|
|
3288
3565
|
}
|
|
3566
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3567
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3568
|
+
async loadSuppressed() {
|
|
3569
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3570
|
+
this.suppressedLoaded = true;
|
|
3571
|
+
try {
|
|
3572
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3573
|
+
let changed = false;
|
|
3574
|
+
for (const k of keys) {
|
|
3575
|
+
if (!this.suppressed.has(k)) {
|
|
3576
|
+
this.suppressed.add(k);
|
|
3577
|
+
changed = true;
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
if (changed) this.emit();
|
|
3581
|
+
} catch {
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
3585
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
3586
|
+
async loadElevated() {
|
|
3587
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
3588
|
+
this.elevatedLoaded = true;
|
|
3589
|
+
try {
|
|
3590
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
3591
|
+
for (const k of keys) this.elevated.add(k);
|
|
3592
|
+
} catch {
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3289
3595
|
async hydrateHistory() {
|
|
3290
3596
|
if (this.historyLoaded || !this._group) return;
|
|
3291
3597
|
this.historyLoaded = true;
|
|
@@ -3296,13 +3602,13 @@ var Chat = class {
|
|
|
3296
3602
|
let changed = false;
|
|
3297
3603
|
for (const m of incoming) {
|
|
3298
3604
|
if (m.serverSeq <= 0) continue;
|
|
3299
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3605
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3300
3606
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3301
3607
|
text: m.text,
|
|
3302
3608
|
senderUserId: m.senderUserId ?? ""
|
|
3303
3609
|
});
|
|
3304
3610
|
}
|
|
3305
|
-
if (m.clientMsgId) {
|
|
3611
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3306
3612
|
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3307
3613
|
}
|
|
3308
3614
|
}
|
|
@@ -3312,7 +3618,12 @@ var Chat = class {
|
|
|
3312
3618
|
const key = this.internalKey(m.serverSeq);
|
|
3313
3619
|
if (this.seenKeys.has(key)) continue;
|
|
3314
3620
|
this.seenKeys.add(key);
|
|
3315
|
-
|
|
3621
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3622
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3623
|
+
}
|
|
3624
|
+
this.messageList.push(
|
|
3625
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3626
|
+
);
|
|
3316
3627
|
changed = true;
|
|
3317
3628
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3318
3629
|
}
|
|
@@ -3362,19 +3673,38 @@ var Chat = class {
|
|
|
3362
3673
|
newText: incoming.edit.newText,
|
|
3363
3674
|
epoch: incoming.epoch,
|
|
3364
3675
|
serverSeq: incoming.serverSeq,
|
|
3365
|
-
eventClientMsgId: incoming.clientMsgId
|
|
3676
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
3677
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
3678
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
3679
|
+
bodyRanges: incoming.bodyRanges
|
|
3366
3680
|
},
|
|
3367
3681
|
this.authorOfTarget
|
|
3368
3682
|
);
|
|
3369
3683
|
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3370
3684
|
return;
|
|
3371
3685
|
}
|
|
3686
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3687
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3688
|
+
this.deleteFold.ingest(
|
|
3689
|
+
{
|
|
3690
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3691
|
+
actorUserId,
|
|
3692
|
+
epoch: incoming.epoch,
|
|
3693
|
+
serverSeq: incoming.serverSeq,
|
|
3694
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3695
|
+
},
|
|
3696
|
+
this.authorOfTarget
|
|
3697
|
+
);
|
|
3698
|
+
this.emit();
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3372
3701
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3373
3702
|
const incomingReplyRef = incoming.replyRef;
|
|
3374
3703
|
let resolvedReplyTo = null;
|
|
3375
3704
|
if (incomingReplyRef) {
|
|
3376
3705
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3377
3706
|
}
|
|
3707
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3378
3708
|
const msg = {
|
|
3379
3709
|
id: this.publicId(incoming.serverSeq),
|
|
3380
3710
|
kind: this.kindOf(incoming),
|
|
@@ -3389,8 +3719,12 @@ var Chat = class {
|
|
|
3389
3719
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3390
3720
|
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3391
3721
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3392
|
-
edited: false
|
|
3722
|
+
edited: false,
|
|
3723
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3724
|
+
isDeleted: false,
|
|
3725
|
+
mentions
|
|
3393
3726
|
};
|
|
3727
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3394
3728
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3395
3729
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3396
3730
|
text: incoming.text,
|
|
@@ -3400,6 +3734,7 @@ var Chat = class {
|
|
|
3400
3734
|
if (incomingClientMsgId) {
|
|
3401
3735
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3402
3736
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3737
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3403
3738
|
}
|
|
3404
3739
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3405
3740
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3413,6 +3748,75 @@ var Chat = class {
|
|
|
3413
3748
|
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3414
3749
|
* so it can be passed to the pure EditFold. */
|
|
3415
3750
|
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3751
|
+
// ── Mentions (mentions T6) ──
|
|
3752
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3753
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
3754
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
3755
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
3756
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
3757
|
+
resolveMentions(text, bodyRanges) {
|
|
3758
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
3759
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
3760
|
+
if (normalized.length === 0) return [];
|
|
3761
|
+
return normalized.map((r) => ({
|
|
3762
|
+
start: r.start,
|
|
3763
|
+
length: r.length,
|
|
3764
|
+
mentionedUserId: r.mentionedUserId,
|
|
3765
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
3766
|
+
}));
|
|
3767
|
+
}
|
|
3768
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
3769
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
3770
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
3771
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
3772
|
+
resolveMentionNames(m) {
|
|
3773
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
3774
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
3775
|
+
}
|
|
3776
|
+
let changed = false;
|
|
3777
|
+
const reresolved = m.mentions.map((span) => {
|
|
3778
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
3779
|
+
if (name === span.displayName) return span;
|
|
3780
|
+
changed = true;
|
|
3781
|
+
return { ...span, displayName: name };
|
|
3782
|
+
});
|
|
3783
|
+
if (!changed) return m;
|
|
3784
|
+
return { ...m, mentions: reresolved };
|
|
3785
|
+
}
|
|
3786
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
3787
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
3788
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
3789
|
+
editMentions(targetClientMsgId, newText) {
|
|
3790
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
3791
|
+
if (!ranges) return [];
|
|
3792
|
+
return this.resolveMentions(newText, ranges);
|
|
3793
|
+
}
|
|
3794
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
3795
|
+
displayNameOf(userId) {
|
|
3796
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
3797
|
+
}
|
|
3798
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
3799
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
3800
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
3801
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
3802
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
3803
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
3804
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
3805
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
3806
|
+
const me = this.backend.selfUserId;
|
|
3807
|
+
if (envelopeType === "edit") return;
|
|
3808
|
+
if (senderUserId === me) return;
|
|
3809
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
3810
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
3811
|
+
const key = `${me}|${idPart}`;
|
|
3812
|
+
if (this.elevated.has(key)) return;
|
|
3813
|
+
this.elevated.add(key);
|
|
3814
|
+
if (this._group) {
|
|
3815
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3818
|
+
this.onMentionElevation?.(message);
|
|
3819
|
+
}
|
|
3416
3820
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3417
3821
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3418
3822
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3469,9 +3873,10 @@ var Chat = class {
|
|
|
3469
3873
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3470
3874
|
const text = editText ?? base;
|
|
3471
3875
|
const edited = foldEdited || m.edited;
|
|
3472
|
-
|
|
3876
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
3877
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3473
3878
|
changed = true;
|
|
3474
|
-
return { ...m, text, edited };
|
|
3879
|
+
return { ...m, text, edited, mentions };
|
|
3475
3880
|
});
|
|
3476
3881
|
if (changed) this.emit();
|
|
3477
3882
|
}
|
|
@@ -3488,8 +3893,9 @@ var Chat = class {
|
|
|
3488
3893
|
if (editText === null && !foldEdited) return m;
|
|
3489
3894
|
const text = editText ?? m.text;
|
|
3490
3895
|
const edited = foldEdited || m.edited;
|
|
3491
|
-
|
|
3492
|
-
|
|
3896
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
3897
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3898
|
+
return { ...m, text, edited, mentions };
|
|
3493
3899
|
}
|
|
3494
3900
|
/** @internal — called by the backend's conv subscription. */
|
|
3495
3901
|
applyConv(event, payload) {
|
|
@@ -3547,6 +3953,18 @@ var Chat = class {
|
|
|
3547
3953
|
}
|
|
3548
3954
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3549
3955
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3956
|
+
this.reresolveAllMentionNames();
|
|
3957
|
+
}
|
|
3958
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
3959
|
+
* change). Re-emits only if any name actually changed. */
|
|
3960
|
+
reresolveAllMentionNames() {
|
|
3961
|
+
let changed = false;
|
|
3962
|
+
this.messageList = this.messageList.map((m) => {
|
|
3963
|
+
const reresolved = this.resolveMentionNames(m);
|
|
3964
|
+
if (reresolved !== m) changed = true;
|
|
3965
|
+
return reresolved;
|
|
3966
|
+
});
|
|
3967
|
+
if (changed) this.emit();
|
|
3550
3968
|
}
|
|
3551
3969
|
seedMembersFromGroup(group) {
|
|
3552
3970
|
const seed = [
|
|
@@ -3614,11 +4032,12 @@ var Chat = class {
|
|
|
3614
4032
|
};
|
|
3615
4033
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3616
4034
|
}
|
|
3617
|
-
const
|
|
3618
|
-
this.
|
|
4035
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4036
|
+
const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
|
|
4037
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
3619
4038
|
return receipt;
|
|
3620
4039
|
}
|
|
3621
|
-
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
|
|
4040
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
3622
4041
|
if (receipt.serverSeq <= 0) return;
|
|
3623
4042
|
const key = this.internalKey(receipt.serverSeq);
|
|
3624
4043
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3641,7 +4060,12 @@ var Chat = class {
|
|
|
3641
4060
|
// the dangling-target invariant uniform across every append path).
|
|
3642
4061
|
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
3643
4062
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3644
|
-
edited: false
|
|
4063
|
+
edited: false,
|
|
4064
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4065
|
+
isDeleted: false,
|
|
4066
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4067
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4068
|
+
mentions: this.resolveMentions(text, bodyRanges)
|
|
3645
4069
|
});
|
|
3646
4070
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3647
4071
|
this.emit();
|
|
@@ -3729,15 +4153,18 @@ var Chat = class {
|
|
|
3729
4153
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3730
4154
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3731
4155
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
3732
|
-
* message self IS the author, so the author-gate passes.
|
|
3733
|
-
|
|
4156
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4157
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4158
|
+
async edit(message, newText, opts) {
|
|
3734
4159
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3735
4160
|
const group = await this.materializeIfNeeded();
|
|
3736
4161
|
const clientMsgId = mintClientMsgId();
|
|
4162
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3737
4163
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
3738
4164
|
clientMsgId,
|
|
3739
4165
|
targetClientMsgId: message.clientMsgId,
|
|
3740
|
-
newText
|
|
4166
|
+
newText,
|
|
4167
|
+
bodyRanges
|
|
3741
4168
|
});
|
|
3742
4169
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3743
4170
|
this.editFold.ingest(
|
|
@@ -3747,13 +4174,72 @@ var Chat = class {
|
|
|
3747
4174
|
newText,
|
|
3748
4175
|
epoch: receipt.epoch,
|
|
3749
4176
|
serverSeq: receipt.serverSeq,
|
|
3750
|
-
eventClientMsgId: clientMsgId
|
|
4177
|
+
eventClientMsgId: clientMsgId,
|
|
4178
|
+
bodyRanges
|
|
3751
4179
|
},
|
|
3752
4180
|
this.authorOfTarget
|
|
3753
4181
|
);
|
|
3754
4182
|
this.recomputeEdit(message.clientMsgId);
|
|
3755
4183
|
}
|
|
4184
|
+
// ── Delete ──
|
|
4185
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
4186
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
4187
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
4188
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
4189
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
4190
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
4191
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
4192
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
4193
|
+
async deleteForEveryone(message) {
|
|
4194
|
+
if (!message.clientMsgId) return;
|
|
4195
|
+
const group = await this.materializeIfNeeded();
|
|
4196
|
+
const clientMsgId = mintClientMsgId();
|
|
4197
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
4198
|
+
clientMsgId,
|
|
4199
|
+
targetClientMsgId: message.clientMsgId
|
|
4200
|
+
});
|
|
4201
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4202
|
+
this.deleteFold.ingest(
|
|
4203
|
+
{
|
|
4204
|
+
targetClientMsgId: message.clientMsgId,
|
|
4205
|
+
actorUserId: this.backend.selfUserId,
|
|
4206
|
+
epoch: receipt.epoch,
|
|
4207
|
+
serverSeq: receipt.serverSeq,
|
|
4208
|
+
eventClientMsgId: clientMsgId
|
|
4209
|
+
},
|
|
4210
|
+
this.authorOfTarget
|
|
4211
|
+
);
|
|
4212
|
+
this.emit();
|
|
4213
|
+
}
|
|
4214
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4215
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4216
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4217
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4218
|
+
async deleteForMe(message) {
|
|
4219
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4220
|
+
if (this.suppressed.has(key)) return;
|
|
4221
|
+
this.suppressed.add(key);
|
|
4222
|
+
this.emit();
|
|
4223
|
+
if (this._group) {
|
|
4224
|
+
try {
|
|
4225
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4226
|
+
} catch {
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
3756
4230
|
};
|
|
4231
|
+
function sameMentions(a, b) {
|
|
4232
|
+
if (a.length !== b.length) return false;
|
|
4233
|
+
for (let i = 0; i < a.length; i++) {
|
|
4234
|
+
const x = a[i];
|
|
4235
|
+
const y = b[i];
|
|
4236
|
+
if (!x || !y) return false;
|
|
4237
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4238
|
+
return false;
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
return true;
|
|
4242
|
+
}
|
|
3757
4243
|
function sameReactions(a, b) {
|
|
3758
4244
|
const ak = Object.keys(a);
|
|
3759
4245
|
const bk = Object.keys(b);
|
|
@@ -3933,6 +4419,7 @@ var MessageDeliverySource = class {
|
|
|
3933
4419
|
const { text, clientMsgId, replyTo } = decoded;
|
|
3934
4420
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
3935
4421
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4422
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
3936
4423
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3937
4424
|
const stored = {
|
|
3938
4425
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3964,12 +4451,31 @@ var MessageDeliverySource = class {
|
|
|
3964
4451
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
3965
4452
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
3966
4453
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
3967
|
-
// `'text'`/no-edit (backward-compat).
|
|
4454
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
4455
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
3968
4456
|
...isEdit && decoded.edit ? {
|
|
3969
4457
|
envelopeType: "edit",
|
|
3970
4458
|
edit: {
|
|
3971
4459
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
3972
|
-
newText: decoded.edit.newText
|
|
4460
|
+
newText: decoded.edit.newText,
|
|
4461
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4462
|
+
}
|
|
4463
|
+
} : {},
|
|
4464
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
4465
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
4466
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
4467
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
4468
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4469
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4470
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4471
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4472
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4473
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4474
|
+
...isDelete && decoded.delete ? {
|
|
4475
|
+
envelopeType: "delete",
|
|
4476
|
+
delete: {
|
|
4477
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4478
|
+
scope: decoded.delete.scope
|
|
3973
4479
|
}
|
|
3974
4480
|
} : {}
|
|
3975
4481
|
};
|
|
@@ -3990,7 +4496,11 @@ var MessageDeliverySource = class {
|
|
|
3990
4496
|
replyRef: replyTo,
|
|
3991
4497
|
envelopeType: decoded.type ?? "text",
|
|
3992
4498
|
reaction: isReaction ? decoded.reaction : null,
|
|
3993
|
-
edit: isEdit ? decoded.edit : null
|
|
4499
|
+
edit: isEdit ? decoded.edit : null,
|
|
4500
|
+
delete: isDelete ? decoded.delete : null,
|
|
4501
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4502
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4503
|
+
bodyRanges: decoded.bodyRanges ?? null
|
|
3994
4504
|
});
|
|
3995
4505
|
return true;
|
|
3996
4506
|
}
|
|
@@ -4145,6 +4655,36 @@ var GroupCatalog = class {
|
|
|
4145
4655
|
}
|
|
4146
4656
|
};
|
|
4147
4657
|
|
|
4658
|
+
// src/messaging/mention-elevation.ts
|
|
4659
|
+
var MentionElevationStore = class {
|
|
4660
|
+
constructor(kv) {
|
|
4661
|
+
this.kv = kv;
|
|
4662
|
+
}
|
|
4663
|
+
kv;
|
|
4664
|
+
key(rfcGroupId) {
|
|
4665
|
+
return `elev:${rfcGroupId}`;
|
|
4666
|
+
}
|
|
4667
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
4668
|
+
async load(rfcGroupId) {
|
|
4669
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
4670
|
+
if (!raw) return [];
|
|
4671
|
+
try {
|
|
4672
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
4673
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4674
|
+
} catch {
|
|
4675
|
+
return [];
|
|
4676
|
+
}
|
|
4677
|
+
}
|
|
4678
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
4679
|
+
async save(rfcGroupId, keys) {
|
|
4680
|
+
const sorted = [...new Set(keys)].sort();
|
|
4681
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
4682
|
+
}
|
|
4683
|
+
async wipe() {
|
|
4684
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
4685
|
+
}
|
|
4686
|
+
};
|
|
4687
|
+
|
|
4148
4688
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4149
4689
|
var palbe_mls_bg_exports = {};
|
|
4150
4690
|
__export(palbe_mls_bg_exports, {
|
|
@@ -5812,6 +6352,36 @@ var SignatureKeyStore = class {
|
|
|
5812
6352
|
}
|
|
5813
6353
|
};
|
|
5814
6354
|
|
|
6355
|
+
// src/messaging/suppression.ts
|
|
6356
|
+
var SuppressionStore = class {
|
|
6357
|
+
constructor(kv) {
|
|
6358
|
+
this.kv = kv;
|
|
6359
|
+
}
|
|
6360
|
+
kv;
|
|
6361
|
+
key(rfcGroupId) {
|
|
6362
|
+
return `supp:${rfcGroupId}`;
|
|
6363
|
+
}
|
|
6364
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6365
|
+
async load(rfcGroupId) {
|
|
6366
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6367
|
+
if (!raw) return [];
|
|
6368
|
+
try {
|
|
6369
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6370
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6371
|
+
} catch {
|
|
6372
|
+
return [];
|
|
6373
|
+
}
|
|
6374
|
+
}
|
|
6375
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6376
|
+
async save(rfcGroupId, keys) {
|
|
6377
|
+
const sorted = [...new Set(keys)].sort();
|
|
6378
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6379
|
+
}
|
|
6380
|
+
async wipe() {
|
|
6381
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6382
|
+
}
|
|
6383
|
+
};
|
|
6384
|
+
|
|
5815
6385
|
// src/messaging/coordinator.ts
|
|
5816
6386
|
var MessagingCoordinator = class {
|
|
5817
6387
|
constructor(rt) {
|
|
@@ -5821,6 +6391,8 @@ var MessagingCoordinator = class {
|
|
|
5821
6391
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
5822
6392
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5823
6393
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6394
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6395
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
5824
6396
|
this.registry.attachChatList(
|
|
5825
6397
|
(chats) => {
|
|
5826
6398
|
this.chatList = chats;
|
|
@@ -5835,6 +6407,8 @@ var MessagingCoordinator = class {
|
|
|
5835
6407
|
sigStore;
|
|
5836
6408
|
groupStore;
|
|
5837
6409
|
kpStore;
|
|
6410
|
+
suppressionStore;
|
|
6411
|
+
elevationStore;
|
|
5838
6412
|
registry = new GroupRegistry();
|
|
5839
6413
|
resolved = null;
|
|
5840
6414
|
resolvePromise = null;
|
|
@@ -5990,9 +6564,9 @@ var MessagingCoordinator = class {
|
|
|
5990
6564
|
});
|
|
5991
6565
|
return group;
|
|
5992
6566
|
}
|
|
5993
|
-
async sendText(group, text, replyTo) {
|
|
6567
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
5994
6568
|
const r = await this.resolve();
|
|
5995
|
-
return r.groups.sendText(group, text, replyTo);
|
|
6569
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
5996
6570
|
}
|
|
5997
6571
|
async sendReaction(group, args) {
|
|
5998
6572
|
const r = await this.resolve();
|
|
@@ -6002,6 +6576,26 @@ var MessagingCoordinator = class {
|
|
|
6002
6576
|
const r = await this.resolve();
|
|
6003
6577
|
return r.groups.sendEdit(group, args);
|
|
6004
6578
|
}
|
|
6579
|
+
async sendDelete(group, args) {
|
|
6580
|
+
const r = await this.resolve();
|
|
6581
|
+
return r.groups.sendDelete(group, args);
|
|
6582
|
+
}
|
|
6583
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6584
|
+
loadSuppressed(group) {
|
|
6585
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6586
|
+
}
|
|
6587
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6588
|
+
saveSuppressed(group, keys) {
|
|
6589
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6590
|
+
}
|
|
6591
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
6592
|
+
loadElevated(group) {
|
|
6593
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
6594
|
+
}
|
|
6595
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
6596
|
+
saveElevated(group, keys) {
|
|
6597
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6598
|
+
}
|
|
6005
6599
|
async history(group, limit, before) {
|
|
6006
6600
|
const r = await this.resolve();
|
|
6007
6601
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6099,9 +6693,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6099
6693
|
});
|
|
6100
6694
|
}
|
|
6101
6695
|
const editFold = new EditFold();
|
|
6696
|
+
const deleteFold = new DeleteFold();
|
|
6102
6697
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6103
6698
|
for (const s of rows) {
|
|
6104
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6699
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6700
|
+
continue;
|
|
6105
6701
|
const cid = s.clientMsgId ?? "";
|
|
6106
6702
|
if (!cid) continue;
|
|
6107
6703
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
@@ -6118,15 +6714,34 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6118
6714
|
newText: s.edit.newText,
|
|
6119
6715
|
epoch: s.epoch,
|
|
6120
6716
|
serverSeq: s.serverSeq,
|
|
6121
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
6717
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
6718
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
6719
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
6720
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6122
6721
|
},
|
|
6123
6722
|
authorOfTarget
|
|
6124
6723
|
);
|
|
6125
6724
|
}
|
|
6126
6725
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6726
|
+
for (const s of rows) {
|
|
6727
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6728
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6729
|
+
deleteFold.ingest(
|
|
6730
|
+
{
|
|
6731
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6732
|
+
actorUserId: actor,
|
|
6733
|
+
epoch: s.epoch,
|
|
6734
|
+
serverSeq: s.serverSeq,
|
|
6735
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6736
|
+
},
|
|
6737
|
+
authorOfTarget
|
|
6738
|
+
);
|
|
6739
|
+
}
|
|
6740
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
6127
6741
|
const lookup = /* @__PURE__ */ new Map();
|
|
6128
6742
|
for (const s of rows) {
|
|
6129
|
-
if (s.envelopeType === "reaction")
|
|
6743
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6744
|
+
continue;
|
|
6130
6745
|
const cid = s.clientMsgId ?? "";
|
|
6131
6746
|
if (cid && s.text !== null) {
|
|
6132
6747
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -6135,8 +6750,29 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6135
6750
|
}
|
|
6136
6751
|
const out = [];
|
|
6137
6752
|
for (const s of rows) {
|
|
6138
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6753
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6754
|
+
continue;
|
|
6139
6755
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6756
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6757
|
+
if (isDeleted) {
|
|
6758
|
+
out.push({
|
|
6759
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6760
|
+
kind: "text",
|
|
6761
|
+
direction: s.direction,
|
|
6762
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6763
|
+
text: DELETED_DESCRIPTOR,
|
|
6764
|
+
serverSeq: s.serverSeq,
|
|
6765
|
+
sentAt: new Date(s.at),
|
|
6766
|
+
clientMsgId,
|
|
6767
|
+
replyTo: null,
|
|
6768
|
+
reactions: {},
|
|
6769
|
+
edited: false,
|
|
6770
|
+
isDeleted: true,
|
|
6771
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6772
|
+
mentions: []
|
|
6773
|
+
});
|
|
6774
|
+
continue;
|
|
6775
|
+
}
|
|
6140
6776
|
let replyTo = null;
|
|
6141
6777
|
if (s.replyTo) {
|
|
6142
6778
|
const ref = {
|
|
@@ -6153,22 +6789,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6153
6789
|
}
|
|
6154
6790
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6155
6791
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
6792
|
+
const text = editText ?? s.text;
|
|
6793
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
6794
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6156
6795
|
out.push({
|
|
6157
6796
|
id: `${displayId}#${s.serverSeq}`,
|
|
6158
6797
|
kind: s.text != null ? "text" : "system",
|
|
6159
6798
|
direction: s.direction,
|
|
6160
6799
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6161
|
-
text
|
|
6800
|
+
text,
|
|
6162
6801
|
serverSeq: s.serverSeq,
|
|
6163
6802
|
sentAt: new Date(s.at),
|
|
6164
6803
|
clientMsgId,
|
|
6165
6804
|
replyTo,
|
|
6166
6805
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6167
|
-
edited
|
|
6806
|
+
edited,
|
|
6807
|
+
isDeleted: false,
|
|
6808
|
+
mentions
|
|
6168
6809
|
});
|
|
6169
6810
|
}
|
|
6170
6811
|
return out;
|
|
6171
6812
|
}
|
|
6813
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
6814
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
6815
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
6816
|
+
start: r.start,
|
|
6817
|
+
length: r.length,
|
|
6818
|
+
mentionedUserId: r.mentionedUserId,
|
|
6819
|
+
displayName: null
|
|
6820
|
+
}));
|
|
6821
|
+
}
|
|
6172
6822
|
|
|
6173
6823
|
// src/messaging/facade.ts
|
|
6174
6824
|
var PalbeMessaging = class {
|
|
@@ -6902,7 +7552,7 @@ function defaultSessionStorage(key) {
|
|
|
6902
7552
|
}
|
|
6903
7553
|
|
|
6904
7554
|
// src/version.ts
|
|
6905
|
-
var VERSION = "1.
|
|
7555
|
+
var VERSION = "1.5.0";
|
|
6906
7556
|
|
|
6907
7557
|
// src/runtime.ts
|
|
6908
7558
|
function buildRuntime(config) {
|