@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.js
CHANGED
package/dist/next/index.cjs
CHANGED
|
@@ -2736,7 +2736,8 @@ var EditFold = class {
|
|
|
2736
2736
|
orderEpoch: e.epoch,
|
|
2737
2737
|
orderSeq: e.serverSeq,
|
|
2738
2738
|
lastEventId: e.eventClientMsgId,
|
|
2739
|
-
text: e.newText
|
|
2739
|
+
text: e.newText,
|
|
2740
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2740
2741
|
});
|
|
2741
2742
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2742
2743
|
}
|
|
@@ -2757,6 +2758,15 @@ var EditFold = class {
|
|
|
2757
2758
|
isEdited(targetClientMsgId) {
|
|
2758
2759
|
return this.editedTargets.has(targetClientMsgId);
|
|
2759
2760
|
}
|
|
2761
|
+
/**
|
|
2762
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2763
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2764
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2765
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2766
|
+
*/
|
|
2767
|
+
bodyRanges(targetClientMsgId) {
|
|
2768
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2769
|
+
}
|
|
2760
2770
|
/**
|
|
2761
2771
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2762
2772
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2930,7 +2940,14 @@ function encodeEdit(args) {
|
|
|
2930
2940
|
type: "edit",
|
|
2931
2941
|
client_msg_id: args.clientMsgId,
|
|
2932
2942
|
target_client_msg_id: args.targetClientMsgId,
|
|
2933
|
-
new_text: args.newText
|
|
2943
|
+
new_text: args.newText,
|
|
2944
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2945
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2946
|
+
start: r.start,
|
|
2947
|
+
length: r.length,
|
|
2948
|
+
mentioned_user_id: r.mentionedUserId
|
|
2949
|
+
}))
|
|
2950
|
+
} : {}
|
|
2934
2951
|
})
|
|
2935
2952
|
);
|
|
2936
2953
|
}
|
|
@@ -2952,7 +2969,14 @@ function encodeEnvelope(args) {
|
|
|
2952
2969
|
type: "text",
|
|
2953
2970
|
client_msg_id: args.clientMsgId,
|
|
2954
2971
|
text: args.text,
|
|
2955
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
2972
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
2973
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2974
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2975
|
+
start: r.start,
|
|
2976
|
+
length: r.length,
|
|
2977
|
+
mentioned_user_id: r.mentionedUserId
|
|
2978
|
+
}))
|
|
2979
|
+
} : {}
|
|
2956
2980
|
};
|
|
2957
2981
|
return encodeUtf8(JSON.stringify(env));
|
|
2958
2982
|
}
|
|
@@ -2986,6 +3010,7 @@ function decodeEnvelope(bytes) {
|
|
|
2986
3010
|
};
|
|
2987
3011
|
}
|
|
2988
3012
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
3013
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2989
3014
|
return {
|
|
2990
3015
|
type: "edit",
|
|
2991
3016
|
text: null,
|
|
@@ -2994,15 +3019,18 @@ function decodeEnvelope(bytes) {
|
|
|
2994
3019
|
edit: {
|
|
2995
3020
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2996
3021
|
newText: o.new_text ?? ""
|
|
2997
|
-
}
|
|
3022
|
+
},
|
|
3023
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2998
3024
|
};
|
|
2999
3025
|
}
|
|
3000
3026
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
3027
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
3001
3028
|
return {
|
|
3002
3029
|
type: "text",
|
|
3003
3030
|
text: o.text ?? null,
|
|
3004
3031
|
clientMsgId: o.client_msg_id ?? "",
|
|
3005
|
-
replyTo: o.reply_to ?? null
|
|
3032
|
+
replyTo: o.reply_to ?? null,
|
|
3033
|
+
...textRanges ? { bodyRanges: textRanges } : {}
|
|
3006
3034
|
};
|
|
3007
3035
|
}
|
|
3008
3036
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -3014,6 +3042,14 @@ function decodeEnvelope(bytes) {
|
|
|
3014
3042
|
}
|
|
3015
3043
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
3016
3044
|
}
|
|
3045
|
+
function decodeBodyRanges(raw) {
|
|
3046
|
+
if (!raw || raw.length === 0) return void 0;
|
|
3047
|
+
return raw.map((r) => ({
|
|
3048
|
+
start: r.start,
|
|
3049
|
+
length: r.length,
|
|
3050
|
+
mentionedUserId: r.mentioned_user_id
|
|
3051
|
+
}));
|
|
3052
|
+
}
|
|
3017
3053
|
function resolveReply(ref, lookup) {
|
|
3018
3054
|
const parent = lookup(ref.client_msg_id);
|
|
3019
3055
|
if (parent !== null) {
|
|
@@ -3225,9 +3261,9 @@ var GroupMessaging = class {
|
|
|
3225
3261
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3226
3262
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3227
3263
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3228
|
-
async sendText(group, text, replyTo) {
|
|
3264
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
3229
3265
|
const clientMsgId = mintClientMsgId();
|
|
3230
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3266
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
3231
3267
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3232
3268
|
const body = {
|
|
3233
3269
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3253,7 +3289,10 @@ var GroupMessaging = class {
|
|
|
3253
3289
|
previewBody: replyTo.preview?.body ?? null,
|
|
3254
3290
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3255
3291
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3256
|
-
} : null
|
|
3292
|
+
} : null,
|
|
3293
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3294
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3295
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
3257
3296
|
};
|
|
3258
3297
|
try {
|
|
3259
3298
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3321,7 +3360,8 @@ var GroupMessaging = class {
|
|
|
3321
3360
|
const plaintext = encodeEdit({
|
|
3322
3361
|
clientMsgId: args.clientMsgId,
|
|
3323
3362
|
targetClientMsgId: args.targetClientMsgId,
|
|
3324
|
-
newText: args.newText
|
|
3363
|
+
newText: args.newText,
|
|
3364
|
+
bodyRanges: args.bodyRanges
|
|
3325
3365
|
});
|
|
3326
3366
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3327
3367
|
const body = {
|
|
@@ -3347,7 +3387,10 @@ var GroupMessaging = class {
|
|
|
3347
3387
|
envelopeType: "edit",
|
|
3348
3388
|
edit: {
|
|
3349
3389
|
targetClientMsgId: args.targetClientMsgId,
|
|
3350
|
-
newText: args.newText
|
|
3390
|
+
newText: args.newText,
|
|
3391
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3392
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3393
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3351
3394
|
}
|
|
3352
3395
|
};
|
|
3353
3396
|
try {
|
|
@@ -3469,6 +3512,41 @@ var GroupMessaging = class {
|
|
|
3469
3512
|
}
|
|
3470
3513
|
};
|
|
3471
3514
|
|
|
3515
|
+
// src/messaging/mention-ranges.ts
|
|
3516
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3517
|
+
const n = text.length;
|
|
3518
|
+
function splitsSurrogatePair(index) {
|
|
3519
|
+
if (index <= 0 || index >= n) return false;
|
|
3520
|
+
const before = text.charCodeAt(index - 1);
|
|
3521
|
+
const at = text.charCodeAt(index);
|
|
3522
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3523
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3524
|
+
return beforeIsHigh && atIsLow;
|
|
3525
|
+
}
|
|
3526
|
+
const survivors = [];
|
|
3527
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3528
|
+
const r = ranges[idx];
|
|
3529
|
+
if (r === void 0) continue;
|
|
3530
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3531
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3532
|
+
survivors.push({ idx, range: r });
|
|
3533
|
+
}
|
|
3534
|
+
survivors.sort((lhs, rhs) => {
|
|
3535
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3536
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3537
|
+
return lhs.idx - rhs.idx;
|
|
3538
|
+
});
|
|
3539
|
+
const kept = [];
|
|
3540
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3541
|
+
for (const s of survivors) {
|
|
3542
|
+
if (s.range.start >= prevEnd) {
|
|
3543
|
+
kept.push(s.range);
|
|
3544
|
+
prevEnd = s.range.start + s.range.length;
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
return kept;
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3472
3550
|
// src/messaging/reaction-fold.ts
|
|
3473
3551
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3474
3552
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3556,6 +3634,13 @@ var Chat = class {
|
|
|
3556
3634
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3557
3635
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3558
3636
|
suppressedLoaded = false;
|
|
3637
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3638
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3639
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3640
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3641
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3642
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3643
|
+
elevatedLoaded = false;
|
|
3559
3644
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3560
3645
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3561
3646
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3567,6 +3652,14 @@ var Chat = class {
|
|
|
3567
3652
|
wired = false;
|
|
3568
3653
|
liveUnsub = null;
|
|
3569
3654
|
listeners = /* @__PURE__ */ new Set();
|
|
3655
|
+
/**
|
|
3656
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3657
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3658
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3659
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3660
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3661
|
+
*/
|
|
3662
|
+
onMentionElevation;
|
|
3570
3663
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3571
3664
|
constructor(args) {
|
|
3572
3665
|
this.backend = args.backend;
|
|
@@ -3644,7 +3737,8 @@ var Chat = class {
|
|
|
3644
3737
|
reactions: {},
|
|
3645
3738
|
replyTo: null,
|
|
3646
3739
|
edited: false,
|
|
3647
|
-
isDeleted: true
|
|
3740
|
+
isDeleted: true,
|
|
3741
|
+
mentions: []
|
|
3648
3742
|
});
|
|
3649
3743
|
continue;
|
|
3650
3744
|
}
|
|
@@ -3680,6 +3774,7 @@ var Chat = class {
|
|
|
3680
3774
|
this.wired = true;
|
|
3681
3775
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3682
3776
|
void this.loadSuppressed();
|
|
3777
|
+
void this.loadElevated();
|
|
3683
3778
|
void this.hydrateHistory();
|
|
3684
3779
|
void this.refreshMembers();
|
|
3685
3780
|
}
|
|
@@ -3701,6 +3796,17 @@ var Chat = class {
|
|
|
3701
3796
|
} catch {
|
|
3702
3797
|
}
|
|
3703
3798
|
}
|
|
3799
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
3800
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
3801
|
+
async loadElevated() {
|
|
3802
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
3803
|
+
this.elevatedLoaded = true;
|
|
3804
|
+
try {
|
|
3805
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
3806
|
+
for (const k of keys) this.elevated.add(k);
|
|
3807
|
+
} catch {
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3704
3810
|
async hydrateHistory() {
|
|
3705
3811
|
if (this.historyLoaded || !this._group) return;
|
|
3706
3812
|
this.historyLoaded = true;
|
|
@@ -3730,7 +3836,9 @@ var Chat = class {
|
|
|
3730
3836
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3731
3837
|
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3732
3838
|
}
|
|
3733
|
-
this.messageList.push(
|
|
3839
|
+
this.messageList.push(
|
|
3840
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3841
|
+
);
|
|
3734
3842
|
changed = true;
|
|
3735
3843
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3736
3844
|
}
|
|
@@ -3780,7 +3888,10 @@ var Chat = class {
|
|
|
3780
3888
|
newText: incoming.edit.newText,
|
|
3781
3889
|
epoch: incoming.epoch,
|
|
3782
3890
|
serverSeq: incoming.serverSeq,
|
|
3783
|
-
eventClientMsgId: incoming.clientMsgId
|
|
3891
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
3892
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
3893
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
3894
|
+
bodyRanges: incoming.bodyRanges
|
|
3784
3895
|
},
|
|
3785
3896
|
this.authorOfTarget
|
|
3786
3897
|
);
|
|
@@ -3808,6 +3919,7 @@ var Chat = class {
|
|
|
3808
3919
|
if (incomingReplyRef) {
|
|
3809
3920
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3810
3921
|
}
|
|
3922
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3811
3923
|
const msg = {
|
|
3812
3924
|
id: this.publicId(incoming.serverSeq),
|
|
3813
3925
|
kind: this.kindOf(incoming),
|
|
@@ -3824,8 +3936,10 @@ var Chat = class {
|
|
|
3824
3936
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3825
3937
|
edited: false,
|
|
3826
3938
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3827
|
-
isDeleted: false
|
|
3939
|
+
isDeleted: false,
|
|
3940
|
+
mentions
|
|
3828
3941
|
};
|
|
3942
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3829
3943
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3830
3944
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3831
3945
|
text: incoming.text,
|
|
@@ -3849,6 +3963,75 @@ var Chat = class {
|
|
|
3849
3963
|
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3850
3964
|
* so it can be passed to the pure EditFold. */
|
|
3851
3965
|
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3966
|
+
// ── Mentions (mentions T6) ──
|
|
3967
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
3968
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
3969
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
3970
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
3971
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
3972
|
+
resolveMentions(text, bodyRanges) {
|
|
3973
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
3974
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
3975
|
+
if (normalized.length === 0) return [];
|
|
3976
|
+
return normalized.map((r) => ({
|
|
3977
|
+
start: r.start,
|
|
3978
|
+
length: r.length,
|
|
3979
|
+
mentionedUserId: r.mentionedUserId,
|
|
3980
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
3981
|
+
}));
|
|
3982
|
+
}
|
|
3983
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
3984
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
3985
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
3986
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
3987
|
+
resolveMentionNames(m) {
|
|
3988
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
3989
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
3990
|
+
}
|
|
3991
|
+
let changed = false;
|
|
3992
|
+
const reresolved = m.mentions.map((span) => {
|
|
3993
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
3994
|
+
if (name === span.displayName) return span;
|
|
3995
|
+
changed = true;
|
|
3996
|
+
return { ...span, displayName: name };
|
|
3997
|
+
});
|
|
3998
|
+
if (!changed) return m;
|
|
3999
|
+
return { ...m, mentions: reresolved };
|
|
4000
|
+
}
|
|
4001
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
4002
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
4003
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
4004
|
+
editMentions(targetClientMsgId, newText) {
|
|
4005
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
4006
|
+
if (!ranges) return [];
|
|
4007
|
+
return this.resolveMentions(newText, ranges);
|
|
4008
|
+
}
|
|
4009
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
4010
|
+
displayNameOf(userId) {
|
|
4011
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
4012
|
+
}
|
|
4013
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
4014
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
4015
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
4016
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
4017
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
4018
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
4019
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
4020
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
4021
|
+
const me = this.backend.selfUserId;
|
|
4022
|
+
if (envelopeType === "edit") return;
|
|
4023
|
+
if (senderUserId === me) return;
|
|
4024
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
4025
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4026
|
+
const key = `${me}|${idPart}`;
|
|
4027
|
+
if (this.elevated.has(key)) return;
|
|
4028
|
+
this.elevated.add(key);
|
|
4029
|
+
if (this._group) {
|
|
4030
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
4033
|
+
this.onMentionElevation?.(message);
|
|
4034
|
+
}
|
|
3852
4035
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3853
4036
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3854
4037
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3905,9 +4088,10 @@ var Chat = class {
|
|
|
3905
4088
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3906
4089
|
const text = editText ?? base;
|
|
3907
4090
|
const edited = foldEdited || m.edited;
|
|
3908
|
-
|
|
4091
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
4092
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3909
4093
|
changed = true;
|
|
3910
|
-
return { ...m, text, edited };
|
|
4094
|
+
return { ...m, text, edited, mentions };
|
|
3911
4095
|
});
|
|
3912
4096
|
if (changed) this.emit();
|
|
3913
4097
|
}
|
|
@@ -3924,8 +4108,9 @@ var Chat = class {
|
|
|
3924
4108
|
if (editText === null && !foldEdited) return m;
|
|
3925
4109
|
const text = editText ?? m.text;
|
|
3926
4110
|
const edited = foldEdited || m.edited;
|
|
3927
|
-
|
|
3928
|
-
|
|
4111
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
4112
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
4113
|
+
return { ...m, text, edited, mentions };
|
|
3929
4114
|
}
|
|
3930
4115
|
/** @internal — called by the backend's conv subscription. */
|
|
3931
4116
|
applyConv(event, payload) {
|
|
@@ -3983,6 +4168,18 @@ var Chat = class {
|
|
|
3983
4168
|
}
|
|
3984
4169
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3985
4170
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
4171
|
+
this.reresolveAllMentionNames();
|
|
4172
|
+
}
|
|
4173
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
4174
|
+
* change). Re-emits only if any name actually changed. */
|
|
4175
|
+
reresolveAllMentionNames() {
|
|
4176
|
+
let changed = false;
|
|
4177
|
+
this.messageList = this.messageList.map((m) => {
|
|
4178
|
+
const reresolved = this.resolveMentionNames(m);
|
|
4179
|
+
if (reresolved !== m) changed = true;
|
|
4180
|
+
return reresolved;
|
|
4181
|
+
});
|
|
4182
|
+
if (changed) this.emit();
|
|
3986
4183
|
}
|
|
3987
4184
|
seedMembersFromGroup(group) {
|
|
3988
4185
|
const seed = [
|
|
@@ -4050,11 +4247,12 @@ var Chat = class {
|
|
|
4050
4247
|
};
|
|
4051
4248
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
4052
4249
|
}
|
|
4053
|
-
const
|
|
4054
|
-
this.
|
|
4250
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4251
|
+
const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
|
|
4252
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
4055
4253
|
return receipt;
|
|
4056
4254
|
}
|
|
4057
|
-
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
|
|
4255
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
4058
4256
|
if (receipt.serverSeq <= 0) return;
|
|
4059
4257
|
const key = this.internalKey(receipt.serverSeq);
|
|
4060
4258
|
if (this.seenKeys.has(key)) return;
|
|
@@ -4079,7 +4277,10 @@ var Chat = class {
|
|
|
4079
4277
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
4080
4278
|
edited: false,
|
|
4081
4279
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4082
|
-
isDeleted: false
|
|
4280
|
+
isDeleted: false,
|
|
4281
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4282
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4283
|
+
mentions: this.resolveMentions(text, bodyRanges)
|
|
4083
4284
|
});
|
|
4084
4285
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
4085
4286
|
this.emit();
|
|
@@ -4167,15 +4368,18 @@ var Chat = class {
|
|
|
4167
4368
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
4168
4369
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
4169
4370
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
4170
|
-
* message self IS the author, so the author-gate passes.
|
|
4171
|
-
|
|
4371
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4372
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4373
|
+
async edit(message, newText, opts) {
|
|
4172
4374
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
4173
4375
|
const group = await this.materializeIfNeeded();
|
|
4174
4376
|
const clientMsgId = mintClientMsgId();
|
|
4377
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4175
4378
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
4176
4379
|
clientMsgId,
|
|
4177
4380
|
targetClientMsgId: message.clientMsgId,
|
|
4178
|
-
newText
|
|
4381
|
+
newText,
|
|
4382
|
+
bodyRanges
|
|
4179
4383
|
});
|
|
4180
4384
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4181
4385
|
this.editFold.ingest(
|
|
@@ -4185,7 +4389,8 @@ var Chat = class {
|
|
|
4185
4389
|
newText,
|
|
4186
4390
|
epoch: receipt.epoch,
|
|
4187
4391
|
serverSeq: receipt.serverSeq,
|
|
4188
|
-
eventClientMsgId: clientMsgId
|
|
4392
|
+
eventClientMsgId: clientMsgId,
|
|
4393
|
+
bodyRanges
|
|
4189
4394
|
},
|
|
4190
4395
|
this.authorOfTarget
|
|
4191
4396
|
);
|
|
@@ -4238,6 +4443,18 @@ var Chat = class {
|
|
|
4238
4443
|
}
|
|
4239
4444
|
}
|
|
4240
4445
|
};
|
|
4446
|
+
function sameMentions(a, b) {
|
|
4447
|
+
if (a.length !== b.length) return false;
|
|
4448
|
+
for (let i = 0; i < a.length; i++) {
|
|
4449
|
+
const x = a[i];
|
|
4450
|
+
const y = b[i];
|
|
4451
|
+
if (!x || !y) return false;
|
|
4452
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4453
|
+
return false;
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
return true;
|
|
4457
|
+
}
|
|
4241
4458
|
function sameReactions(a, b) {
|
|
4242
4459
|
const ak = Object.keys(a);
|
|
4243
4460
|
const bk = Object.keys(b);
|
|
@@ -4449,14 +4666,21 @@ var MessageDeliverySource = class {
|
|
|
4449
4666
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4450
4667
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4451
4668
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4452
|
-
// `'text'`/no-edit (backward-compat).
|
|
4669
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
4670
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
4453
4671
|
...isEdit && decoded.edit ? {
|
|
4454
4672
|
envelopeType: "edit",
|
|
4455
4673
|
edit: {
|
|
4456
4674
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4457
|
-
newText: decoded.edit.newText
|
|
4675
|
+
newText: decoded.edit.newText,
|
|
4676
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4458
4677
|
}
|
|
4459
4678
|
} : {},
|
|
4679
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
4680
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
4681
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
4682
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
4683
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4460
4684
|
// Thread the delete discriminator + target through the persisted row so a
|
|
4461
4685
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4462
4686
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -4488,7 +4712,10 @@ var MessageDeliverySource = class {
|
|
|
4488
4712
|
envelopeType: decoded.type ?? "text",
|
|
4489
4713
|
reaction: isReaction ? decoded.reaction : null,
|
|
4490
4714
|
edit: isEdit ? decoded.edit : null,
|
|
4491
|
-
delete: isDelete ? decoded.delete : null
|
|
4715
|
+
delete: isDelete ? decoded.delete : null,
|
|
4716
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4717
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4718
|
+
bodyRanges: decoded.bodyRanges ?? null
|
|
4492
4719
|
});
|
|
4493
4720
|
return true;
|
|
4494
4721
|
}
|
|
@@ -4643,6 +4870,36 @@ var GroupCatalog = class {
|
|
|
4643
4870
|
}
|
|
4644
4871
|
};
|
|
4645
4872
|
|
|
4873
|
+
// src/messaging/mention-elevation.ts
|
|
4874
|
+
var MentionElevationStore = class {
|
|
4875
|
+
constructor(kv) {
|
|
4876
|
+
this.kv = kv;
|
|
4877
|
+
}
|
|
4878
|
+
kv;
|
|
4879
|
+
key(rfcGroupId) {
|
|
4880
|
+
return `elev:${rfcGroupId}`;
|
|
4881
|
+
}
|
|
4882
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
4883
|
+
async load(rfcGroupId) {
|
|
4884
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
4885
|
+
if (!raw) return [];
|
|
4886
|
+
try {
|
|
4887
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
4888
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4889
|
+
} catch {
|
|
4890
|
+
return [];
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
4894
|
+
async save(rfcGroupId, keys) {
|
|
4895
|
+
const sorted = [...new Set(keys)].sort();
|
|
4896
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
4897
|
+
}
|
|
4898
|
+
async wipe() {
|
|
4899
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
4900
|
+
}
|
|
4901
|
+
};
|
|
4902
|
+
|
|
4646
4903
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4647
4904
|
var palbe_mls_bg_exports = {};
|
|
4648
4905
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6350,6 +6607,7 @@ var MessagingCoordinator = class {
|
|
|
6350
6607
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6351
6608
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6352
6609
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6610
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6353
6611
|
this.registry.attachChatList(
|
|
6354
6612
|
(chats) => {
|
|
6355
6613
|
this.chatList = chats;
|
|
@@ -6365,6 +6623,7 @@ var MessagingCoordinator = class {
|
|
|
6365
6623
|
groupStore;
|
|
6366
6624
|
kpStore;
|
|
6367
6625
|
suppressionStore;
|
|
6626
|
+
elevationStore;
|
|
6368
6627
|
registry = new GroupRegistry();
|
|
6369
6628
|
resolved = null;
|
|
6370
6629
|
resolvePromise = null;
|
|
@@ -6520,9 +6779,9 @@ var MessagingCoordinator = class {
|
|
|
6520
6779
|
});
|
|
6521
6780
|
return group;
|
|
6522
6781
|
}
|
|
6523
|
-
async sendText(group, text, replyTo) {
|
|
6782
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6524
6783
|
const r = await this.resolve();
|
|
6525
|
-
return r.groups.sendText(group, text, replyTo);
|
|
6784
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6526
6785
|
}
|
|
6527
6786
|
async sendReaction(group, args) {
|
|
6528
6787
|
const r = await this.resolve();
|
|
@@ -6544,6 +6803,14 @@ var MessagingCoordinator = class {
|
|
|
6544
6803
|
saveSuppressed(group, keys) {
|
|
6545
6804
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6546
6805
|
}
|
|
6806
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
6807
|
+
loadElevated(group) {
|
|
6808
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
6809
|
+
}
|
|
6810
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
6811
|
+
saveElevated(group, keys) {
|
|
6812
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
6813
|
+
}
|
|
6547
6814
|
async history(group, limit, before) {
|
|
6548
6815
|
const r = await this.resolve();
|
|
6549
6816
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6662,7 +6929,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6662
6929
|
newText: s.edit.newText,
|
|
6663
6930
|
epoch: s.epoch,
|
|
6664
6931
|
serverSeq: s.serverSeq,
|
|
6665
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
6932
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
6933
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
6934
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
6935
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6666
6936
|
},
|
|
6667
6937
|
authorOfTarget
|
|
6668
6938
|
);
|
|
@@ -6712,7 +6982,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6712
6982
|
replyTo: null,
|
|
6713
6983
|
reactions: {},
|
|
6714
6984
|
edited: false,
|
|
6715
|
-
isDeleted: true
|
|
6985
|
+
isDeleted: true,
|
|
6986
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
6987
|
+
mentions: []
|
|
6716
6988
|
});
|
|
6717
6989
|
continue;
|
|
6718
6990
|
}
|
|
@@ -6732,23 +7004,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6732
7004
|
}
|
|
6733
7005
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6734
7006
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
7007
|
+
const text = editText ?? s.text;
|
|
7008
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
7009
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6735
7010
|
out.push({
|
|
6736
7011
|
id: `${displayId}#${s.serverSeq}`,
|
|
6737
7012
|
kind: s.text != null ? "text" : "system",
|
|
6738
7013
|
direction: s.direction,
|
|
6739
7014
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6740
|
-
text
|
|
7015
|
+
text,
|
|
6741
7016
|
serverSeq: s.serverSeq,
|
|
6742
7017
|
sentAt: new Date(s.at),
|
|
6743
7018
|
clientMsgId,
|
|
6744
7019
|
replyTo,
|
|
6745
7020
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6746
7021
|
edited,
|
|
6747
|
-
isDeleted: false
|
|
7022
|
+
isDeleted: false,
|
|
7023
|
+
mentions
|
|
6748
7024
|
});
|
|
6749
7025
|
}
|
|
6750
7026
|
return out;
|
|
6751
7027
|
}
|
|
7028
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
7029
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
7030
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
7031
|
+
start: r.start,
|
|
7032
|
+
length: r.length,
|
|
7033
|
+
mentionedUserId: r.mentionedUserId,
|
|
7034
|
+
displayName: null
|
|
7035
|
+
}));
|
|
7036
|
+
}
|
|
6752
7037
|
|
|
6753
7038
|
// src/messaging/facade.ts
|
|
6754
7039
|
var PalbeMessaging = class {
|
|
@@ -7482,7 +7767,7 @@ function defaultSessionStorage(key) {
|
|
|
7482
7767
|
}
|
|
7483
7768
|
|
|
7484
7769
|
// src/version.ts
|
|
7485
|
-
var VERSION = "1.
|
|
7770
|
+
var VERSION = "1.5.0";
|
|
7486
7771
|
|
|
7487
7772
|
// src/runtime.ts
|
|
7488
7773
|
function buildRuntime(config) {
|