@palbase/web 1.4.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-CbXprYrS.d.ts → analytics-facade-BQlTfsEm.d.ts} +96 -4
- package/dist/{analytics-facade-DA9TGKLF.d.cts → analytics-facade-DvsLE_qz.d.cts} +96 -4
- package/dist/{chunk-3EVGYJ5F.js → chunk-MBA2NAKS.js} +322 -37
- package/dist/chunk-MBA2NAKS.js.map +1 -0
- package/dist/index.cjs +321 -36
- 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 +321 -36
- 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 +321 -36
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +321 -36
- 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-CLfTLQzH.d.ts → pb-Cl7TeyCR.d.ts} +1 -1
- package/dist/{pb-SVs7vTOp.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 +2 -2
- package/dist/chunk-3EVGYJ5F.js.map +0 -1
package/dist/next/client.cjs
CHANGED
|
@@ -2521,7 +2521,8 @@ var EditFold = class {
|
|
|
2521
2521
|
orderEpoch: e.epoch,
|
|
2522
2522
|
orderSeq: e.serverSeq,
|
|
2523
2523
|
lastEventId: e.eventClientMsgId,
|
|
2524
|
-
text: e.newText
|
|
2524
|
+
text: e.newText,
|
|
2525
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2525
2526
|
});
|
|
2526
2527
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2527
2528
|
}
|
|
@@ -2542,6 +2543,15 @@ var EditFold = class {
|
|
|
2542
2543
|
isEdited(targetClientMsgId) {
|
|
2543
2544
|
return this.editedTargets.has(targetClientMsgId);
|
|
2544
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
|
+
}
|
|
2545
2555
|
/**
|
|
2546
2556
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2547
2557
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2715,7 +2725,14 @@ function encodeEdit(args) {
|
|
|
2715
2725
|
type: "edit",
|
|
2716
2726
|
client_msg_id: args.clientMsgId,
|
|
2717
2727
|
target_client_msg_id: args.targetClientMsgId,
|
|
2718
|
-
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
|
+
} : {}
|
|
2719
2736
|
})
|
|
2720
2737
|
);
|
|
2721
2738
|
}
|
|
@@ -2737,7 +2754,14 @@ function encodeEnvelope(args) {
|
|
|
2737
2754
|
type: "text",
|
|
2738
2755
|
client_msg_id: args.clientMsgId,
|
|
2739
2756
|
text: args.text,
|
|
2740
|
-
...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
|
+
} : {}
|
|
2741
2765
|
};
|
|
2742
2766
|
return encodeUtf8(JSON.stringify(env));
|
|
2743
2767
|
}
|
|
@@ -2771,6 +2795,7 @@ function decodeEnvelope(bytes) {
|
|
|
2771
2795
|
};
|
|
2772
2796
|
}
|
|
2773
2797
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2798
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2774
2799
|
return {
|
|
2775
2800
|
type: "edit",
|
|
2776
2801
|
text: null,
|
|
@@ -2779,15 +2804,18 @@ function decodeEnvelope(bytes) {
|
|
|
2779
2804
|
edit: {
|
|
2780
2805
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2781
2806
|
newText: o.new_text ?? ""
|
|
2782
|
-
}
|
|
2807
|
+
},
|
|
2808
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2783
2809
|
};
|
|
2784
2810
|
}
|
|
2785
2811
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2812
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2786
2813
|
return {
|
|
2787
2814
|
type: "text",
|
|
2788
2815
|
text: o.text ?? null,
|
|
2789
2816
|
clientMsgId: o.client_msg_id ?? "",
|
|
2790
|
-
replyTo: o.reply_to ?? null
|
|
2817
|
+
replyTo: o.reply_to ?? null,
|
|
2818
|
+
...textRanges ? { bodyRanges: textRanges } : {}
|
|
2791
2819
|
};
|
|
2792
2820
|
}
|
|
2793
2821
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2799,6 +2827,14 @@ function decodeEnvelope(bytes) {
|
|
|
2799
2827
|
}
|
|
2800
2828
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2801
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
|
+
}
|
|
2802
2838
|
function resolveReply(ref, lookup) {
|
|
2803
2839
|
const parent = lookup(ref.client_msg_id);
|
|
2804
2840
|
if (parent !== null) {
|
|
@@ -3010,9 +3046,9 @@ var GroupMessaging = class {
|
|
|
3010
3046
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3011
3047
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3012
3048
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3013
|
-
async sendText(group, text, replyTo) {
|
|
3049
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
3014
3050
|
const clientMsgId = mintClientMsgId();
|
|
3015
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3051
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
3016
3052
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3017
3053
|
const body = {
|
|
3018
3054
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3038,7 +3074,10 @@ var GroupMessaging = class {
|
|
|
3038
3074
|
previewBody: replyTo.preview?.body ?? null,
|
|
3039
3075
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3040
3076
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3041
|
-
} : 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 } : {}
|
|
3042
3081
|
};
|
|
3043
3082
|
try {
|
|
3044
3083
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3106,7 +3145,8 @@ var GroupMessaging = class {
|
|
|
3106
3145
|
const plaintext = encodeEdit({
|
|
3107
3146
|
clientMsgId: args.clientMsgId,
|
|
3108
3147
|
targetClientMsgId: args.targetClientMsgId,
|
|
3109
|
-
newText: args.newText
|
|
3148
|
+
newText: args.newText,
|
|
3149
|
+
bodyRanges: args.bodyRanges
|
|
3110
3150
|
});
|
|
3111
3151
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3112
3152
|
const body = {
|
|
@@ -3132,7 +3172,10 @@ var GroupMessaging = class {
|
|
|
3132
3172
|
envelopeType: "edit",
|
|
3133
3173
|
edit: {
|
|
3134
3174
|
targetClientMsgId: args.targetClientMsgId,
|
|
3135
|
-
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 } : {}
|
|
3136
3179
|
}
|
|
3137
3180
|
};
|
|
3138
3181
|
try {
|
|
@@ -3254,6 +3297,41 @@ var GroupMessaging = class {
|
|
|
3254
3297
|
}
|
|
3255
3298
|
};
|
|
3256
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
|
+
|
|
3257
3335
|
// src/messaging/reaction-fold.ts
|
|
3258
3336
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3259
3337
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3341,6 +3419,13 @@ var Chat = class {
|
|
|
3341
3419
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3342
3420
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3343
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;
|
|
3344
3429
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3345
3430
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3346
3431
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3352,6 +3437,14 @@ var Chat = class {
|
|
|
3352
3437
|
wired = false;
|
|
3353
3438
|
liveUnsub = null;
|
|
3354
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;
|
|
3355
3448
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3356
3449
|
constructor(args) {
|
|
3357
3450
|
this.backend = args.backend;
|
|
@@ -3429,7 +3522,8 @@ var Chat = class {
|
|
|
3429
3522
|
reactions: {},
|
|
3430
3523
|
replyTo: null,
|
|
3431
3524
|
edited: false,
|
|
3432
|
-
isDeleted: true
|
|
3525
|
+
isDeleted: true,
|
|
3526
|
+
mentions: []
|
|
3433
3527
|
});
|
|
3434
3528
|
continue;
|
|
3435
3529
|
}
|
|
@@ -3465,6 +3559,7 @@ var Chat = class {
|
|
|
3465
3559
|
this.wired = true;
|
|
3466
3560
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3467
3561
|
void this.loadSuppressed();
|
|
3562
|
+
void this.loadElevated();
|
|
3468
3563
|
void this.hydrateHistory();
|
|
3469
3564
|
void this.refreshMembers();
|
|
3470
3565
|
}
|
|
@@ -3486,6 +3581,17 @@ var Chat = class {
|
|
|
3486
3581
|
} catch {
|
|
3487
3582
|
}
|
|
3488
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
|
+
}
|
|
3489
3595
|
async hydrateHistory() {
|
|
3490
3596
|
if (this.historyLoaded || !this._group) return;
|
|
3491
3597
|
this.historyLoaded = true;
|
|
@@ -3515,7 +3621,9 @@ var Chat = class {
|
|
|
3515
3621
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3516
3622
|
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3517
3623
|
}
|
|
3518
|
-
this.messageList.push(
|
|
3624
|
+
this.messageList.push(
|
|
3625
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3626
|
+
);
|
|
3519
3627
|
changed = true;
|
|
3520
3628
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3521
3629
|
}
|
|
@@ -3565,7 +3673,10 @@ var Chat = class {
|
|
|
3565
3673
|
newText: incoming.edit.newText,
|
|
3566
3674
|
epoch: incoming.epoch,
|
|
3567
3675
|
serverSeq: incoming.serverSeq,
|
|
3568
|
-
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
|
|
3569
3680
|
},
|
|
3570
3681
|
this.authorOfTarget
|
|
3571
3682
|
);
|
|
@@ -3593,6 +3704,7 @@ var Chat = class {
|
|
|
3593
3704
|
if (incomingReplyRef) {
|
|
3594
3705
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3595
3706
|
}
|
|
3707
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3596
3708
|
const msg = {
|
|
3597
3709
|
id: this.publicId(incoming.serverSeq),
|
|
3598
3710
|
kind: this.kindOf(incoming),
|
|
@@ -3609,8 +3721,10 @@ var Chat = class {
|
|
|
3609
3721
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3610
3722
|
edited: false,
|
|
3611
3723
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3612
|
-
isDeleted: false
|
|
3724
|
+
isDeleted: false,
|
|
3725
|
+
mentions
|
|
3613
3726
|
};
|
|
3727
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3614
3728
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3615
3729
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3616
3730
|
text: incoming.text,
|
|
@@ -3634,6 +3748,75 @@ var Chat = class {
|
|
|
3634
3748
|
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3635
3749
|
* so it can be passed to the pure EditFold. */
|
|
3636
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
|
+
}
|
|
3637
3820
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3638
3821
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3639
3822
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3690,9 +3873,10 @@ var Chat = class {
|
|
|
3690
3873
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3691
3874
|
const text = editText ?? base;
|
|
3692
3875
|
const edited = foldEdited || m.edited;
|
|
3693
|
-
|
|
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;
|
|
3694
3878
|
changed = true;
|
|
3695
|
-
return { ...m, text, edited };
|
|
3879
|
+
return { ...m, text, edited, mentions };
|
|
3696
3880
|
});
|
|
3697
3881
|
if (changed) this.emit();
|
|
3698
3882
|
}
|
|
@@ -3709,8 +3893,9 @@ var Chat = class {
|
|
|
3709
3893
|
if (editText === null && !foldEdited) return m;
|
|
3710
3894
|
const text = editText ?? m.text;
|
|
3711
3895
|
const edited = foldEdited || m.edited;
|
|
3712
|
-
|
|
3713
|
-
|
|
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 };
|
|
3714
3899
|
}
|
|
3715
3900
|
/** @internal — called by the backend's conv subscription. */
|
|
3716
3901
|
applyConv(event, payload) {
|
|
@@ -3768,6 +3953,18 @@ var Chat = class {
|
|
|
3768
3953
|
}
|
|
3769
3954
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3770
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();
|
|
3771
3968
|
}
|
|
3772
3969
|
seedMembersFromGroup(group) {
|
|
3773
3970
|
const seed = [
|
|
@@ -3835,11 +4032,12 @@ var Chat = class {
|
|
|
3835
4032
|
};
|
|
3836
4033
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3837
4034
|
}
|
|
3838
|
-
const
|
|
3839
|
-
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);
|
|
3840
4038
|
return receipt;
|
|
3841
4039
|
}
|
|
3842
|
-
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
|
|
4040
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
3843
4041
|
if (receipt.serverSeq <= 0) return;
|
|
3844
4042
|
const key = this.internalKey(receipt.serverSeq);
|
|
3845
4043
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3864,7 +4062,10 @@ var Chat = class {
|
|
|
3864
4062
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3865
4063
|
edited: false,
|
|
3866
4064
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3867
|
-
isDeleted: false
|
|
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)
|
|
3868
4069
|
});
|
|
3869
4070
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3870
4071
|
this.emit();
|
|
@@ -3952,15 +4153,18 @@ var Chat = class {
|
|
|
3952
4153
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3953
4154
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3954
4155
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
3955
|
-
* message self IS the author, so the author-gate passes.
|
|
3956
|
-
|
|
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) {
|
|
3957
4159
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3958
4160
|
const group = await this.materializeIfNeeded();
|
|
3959
4161
|
const clientMsgId = mintClientMsgId();
|
|
4162
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3960
4163
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
3961
4164
|
clientMsgId,
|
|
3962
4165
|
targetClientMsgId: message.clientMsgId,
|
|
3963
|
-
newText
|
|
4166
|
+
newText,
|
|
4167
|
+
bodyRanges
|
|
3964
4168
|
});
|
|
3965
4169
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3966
4170
|
this.editFold.ingest(
|
|
@@ -3970,7 +4174,8 @@ var Chat = class {
|
|
|
3970
4174
|
newText,
|
|
3971
4175
|
epoch: receipt.epoch,
|
|
3972
4176
|
serverSeq: receipt.serverSeq,
|
|
3973
|
-
eventClientMsgId: clientMsgId
|
|
4177
|
+
eventClientMsgId: clientMsgId,
|
|
4178
|
+
bodyRanges
|
|
3974
4179
|
},
|
|
3975
4180
|
this.authorOfTarget
|
|
3976
4181
|
);
|
|
@@ -4023,6 +4228,18 @@ var Chat = class {
|
|
|
4023
4228
|
}
|
|
4024
4229
|
}
|
|
4025
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
|
+
}
|
|
4026
4243
|
function sameReactions(a, b) {
|
|
4027
4244
|
const ak = Object.keys(a);
|
|
4028
4245
|
const bk = Object.keys(b);
|
|
@@ -4234,14 +4451,21 @@ var MessageDeliverySource = class {
|
|
|
4234
4451
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4235
4452
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4236
4453
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4237
|
-
// `'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).
|
|
4238
4456
|
...isEdit && decoded.edit ? {
|
|
4239
4457
|
envelopeType: "edit",
|
|
4240
4458
|
edit: {
|
|
4241
4459
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4242
|
-
newText: decoded.edit.newText
|
|
4460
|
+
newText: decoded.edit.newText,
|
|
4461
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4243
4462
|
}
|
|
4244
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 } : {},
|
|
4245
4469
|
// Thread the delete discriminator + target through the persisted row so a
|
|
4246
4470
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4247
4471
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -4273,7 +4497,10 @@ var MessageDeliverySource = class {
|
|
|
4273
4497
|
envelopeType: decoded.type ?? "text",
|
|
4274
4498
|
reaction: isReaction ? decoded.reaction : null,
|
|
4275
4499
|
edit: isEdit ? decoded.edit : null,
|
|
4276
|
-
delete: isDelete ? decoded.delete : 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
|
|
4277
4504
|
});
|
|
4278
4505
|
return true;
|
|
4279
4506
|
}
|
|
@@ -4428,6 +4655,36 @@ var GroupCatalog = class {
|
|
|
4428
4655
|
}
|
|
4429
4656
|
};
|
|
4430
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
|
+
|
|
4431
4688
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4432
4689
|
var palbe_mls_bg_exports = {};
|
|
4433
4690
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6135,6 +6392,7 @@ var MessagingCoordinator = class {
|
|
|
6135
6392
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6136
6393
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6137
6394
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6395
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6138
6396
|
this.registry.attachChatList(
|
|
6139
6397
|
(chats) => {
|
|
6140
6398
|
this.chatList = chats;
|
|
@@ -6150,6 +6408,7 @@ var MessagingCoordinator = class {
|
|
|
6150
6408
|
groupStore;
|
|
6151
6409
|
kpStore;
|
|
6152
6410
|
suppressionStore;
|
|
6411
|
+
elevationStore;
|
|
6153
6412
|
registry = new GroupRegistry();
|
|
6154
6413
|
resolved = null;
|
|
6155
6414
|
resolvePromise = null;
|
|
@@ -6305,9 +6564,9 @@ var MessagingCoordinator = class {
|
|
|
6305
6564
|
});
|
|
6306
6565
|
return group;
|
|
6307
6566
|
}
|
|
6308
|
-
async sendText(group, text, replyTo) {
|
|
6567
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6309
6568
|
const r = await this.resolve();
|
|
6310
|
-
return r.groups.sendText(group, text, replyTo);
|
|
6569
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6311
6570
|
}
|
|
6312
6571
|
async sendReaction(group, args) {
|
|
6313
6572
|
const r = await this.resolve();
|
|
@@ -6329,6 +6588,14 @@ var MessagingCoordinator = class {
|
|
|
6329
6588
|
saveSuppressed(group, keys) {
|
|
6330
6589
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6331
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
|
+
}
|
|
6332
6599
|
async history(group, limit, before) {
|
|
6333
6600
|
const r = await this.resolve();
|
|
6334
6601
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6447,7 +6714,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6447
6714
|
newText: s.edit.newText,
|
|
6448
6715
|
epoch: s.epoch,
|
|
6449
6716
|
serverSeq: s.serverSeq,
|
|
6450
|
-
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
|
|
6451
6721
|
},
|
|
6452
6722
|
authorOfTarget
|
|
6453
6723
|
);
|
|
@@ -6497,7 +6767,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6497
6767
|
replyTo: null,
|
|
6498
6768
|
reactions: {},
|
|
6499
6769
|
edited: false,
|
|
6500
|
-
isDeleted: true
|
|
6770
|
+
isDeleted: true,
|
|
6771
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6772
|
+
mentions: []
|
|
6501
6773
|
});
|
|
6502
6774
|
continue;
|
|
6503
6775
|
}
|
|
@@ -6517,23 +6789,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6517
6789
|
}
|
|
6518
6790
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6519
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);
|
|
6520
6795
|
out.push({
|
|
6521
6796
|
id: `${displayId}#${s.serverSeq}`,
|
|
6522
6797
|
kind: s.text != null ? "text" : "system",
|
|
6523
6798
|
direction: s.direction,
|
|
6524
6799
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6525
|
-
text
|
|
6800
|
+
text,
|
|
6526
6801
|
serverSeq: s.serverSeq,
|
|
6527
6802
|
sentAt: new Date(s.at),
|
|
6528
6803
|
clientMsgId,
|
|
6529
6804
|
replyTo,
|
|
6530
6805
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6531
6806
|
edited,
|
|
6532
|
-
isDeleted: false
|
|
6807
|
+
isDeleted: false,
|
|
6808
|
+
mentions
|
|
6533
6809
|
});
|
|
6534
6810
|
}
|
|
6535
6811
|
return out;
|
|
6536
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
|
+
}
|
|
6537
6822
|
|
|
6538
6823
|
// src/messaging/facade.ts
|
|
6539
6824
|
var PalbeMessaging = class {
|
|
@@ -7267,7 +7552,7 @@ function defaultSessionStorage(key) {
|
|
|
7267
7552
|
}
|
|
7268
7553
|
|
|
7269
7554
|
// src/version.ts
|
|
7270
|
-
var VERSION = "1.
|
|
7555
|
+
var VERSION = "1.5.0";
|
|
7271
7556
|
|
|
7272
7557
|
// src/runtime.ts
|
|
7273
7558
|
function buildRuntime(config) {
|