@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/index.cjs
CHANGED
|
@@ -1715,7 +1715,8 @@ var EditFold = class {
|
|
|
1715
1715
|
orderEpoch: e.epoch,
|
|
1716
1716
|
orderSeq: e.serverSeq,
|
|
1717
1717
|
lastEventId: e.eventClientMsgId,
|
|
1718
|
-
text: e.newText
|
|
1718
|
+
text: e.newText,
|
|
1719
|
+
bodyRanges: e.bodyRanges ?? null
|
|
1719
1720
|
});
|
|
1720
1721
|
this.editedTargets.add(e.targetClientMsgId);
|
|
1721
1722
|
}
|
|
@@ -1736,6 +1737,15 @@ var EditFold = class {
|
|
|
1736
1737
|
isEdited(targetClientMsgId) {
|
|
1737
1738
|
return this.editedTargets.has(targetClientMsgId);
|
|
1738
1739
|
}
|
|
1740
|
+
/**
|
|
1741
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
1742
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
1743
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
1744
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
1745
|
+
*/
|
|
1746
|
+
bodyRanges(targetClientMsgId) {
|
|
1747
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
1748
|
+
}
|
|
1739
1749
|
/**
|
|
1740
1750
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
1741
1751
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -1909,7 +1919,14 @@ function encodeEdit(args) {
|
|
|
1909
1919
|
type: "edit",
|
|
1910
1920
|
client_msg_id: args.clientMsgId,
|
|
1911
1921
|
target_client_msg_id: args.targetClientMsgId,
|
|
1912
|
-
new_text: args.newText
|
|
1922
|
+
new_text: args.newText,
|
|
1923
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
1924
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
1925
|
+
start: r.start,
|
|
1926
|
+
length: r.length,
|
|
1927
|
+
mentioned_user_id: r.mentionedUserId
|
|
1928
|
+
}))
|
|
1929
|
+
} : {}
|
|
1913
1930
|
})
|
|
1914
1931
|
);
|
|
1915
1932
|
}
|
|
@@ -1931,7 +1948,14 @@ function encodeEnvelope(args) {
|
|
|
1931
1948
|
type: "text",
|
|
1932
1949
|
client_msg_id: args.clientMsgId,
|
|
1933
1950
|
text: args.text,
|
|
1934
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
1951
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
1952
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
1953
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
1954
|
+
start: r.start,
|
|
1955
|
+
length: r.length,
|
|
1956
|
+
mentioned_user_id: r.mentionedUserId
|
|
1957
|
+
}))
|
|
1958
|
+
} : {}
|
|
1935
1959
|
};
|
|
1936
1960
|
return encodeUtf8(JSON.stringify(env));
|
|
1937
1961
|
}
|
|
@@ -1965,6 +1989,7 @@ function decodeEnvelope(bytes) {
|
|
|
1965
1989
|
};
|
|
1966
1990
|
}
|
|
1967
1991
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
1992
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
1968
1993
|
return {
|
|
1969
1994
|
type: "edit",
|
|
1970
1995
|
text: null,
|
|
@@ -1973,15 +1998,18 @@ function decodeEnvelope(bytes) {
|
|
|
1973
1998
|
edit: {
|
|
1974
1999
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
1975
2000
|
newText: o.new_text ?? ""
|
|
1976
|
-
}
|
|
2001
|
+
},
|
|
2002
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
1977
2003
|
};
|
|
1978
2004
|
}
|
|
1979
2005
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2006
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
1980
2007
|
return {
|
|
1981
2008
|
type: "text",
|
|
1982
2009
|
text: o.text ?? null,
|
|
1983
2010
|
clientMsgId: o.client_msg_id ?? "",
|
|
1984
|
-
replyTo: o.reply_to ?? null
|
|
2011
|
+
replyTo: o.reply_to ?? null,
|
|
2012
|
+
...textRanges ? { bodyRanges: textRanges } : {}
|
|
1985
2013
|
};
|
|
1986
2014
|
}
|
|
1987
2015
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -1993,6 +2021,14 @@ function decodeEnvelope(bytes) {
|
|
|
1993
2021
|
}
|
|
1994
2022
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
1995
2023
|
}
|
|
2024
|
+
function decodeBodyRanges(raw) {
|
|
2025
|
+
if (!raw || raw.length === 0) return void 0;
|
|
2026
|
+
return raw.map((r) => ({
|
|
2027
|
+
start: r.start,
|
|
2028
|
+
length: r.length,
|
|
2029
|
+
mentionedUserId: r.mentioned_user_id
|
|
2030
|
+
}));
|
|
2031
|
+
}
|
|
1996
2032
|
function resolveReply(ref, lookup) {
|
|
1997
2033
|
const parent = lookup(ref.client_msg_id);
|
|
1998
2034
|
if (parent !== null) {
|
|
@@ -2204,9 +2240,9 @@ var GroupMessaging = class {
|
|
|
2204
2240
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
2205
2241
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
2206
2242
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
2207
|
-
async sendText(group, text, replyTo) {
|
|
2243
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
2208
2244
|
const clientMsgId = mintClientMsgId();
|
|
2209
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
2245
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges });
|
|
2210
2246
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2211
2247
|
const body = {
|
|
2212
2248
|
ciphertext_b64: toBase64(ct),
|
|
@@ -2232,7 +2268,10 @@ var GroupMessaging = class {
|
|
|
2232
2268
|
previewBody: replyTo.preview?.body ?? null,
|
|
2233
2269
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
2234
2270
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
2235
|
-
} : null
|
|
2271
|
+
} : null,
|
|
2272
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
2273
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
2274
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {}
|
|
2236
2275
|
};
|
|
2237
2276
|
try {
|
|
2238
2277
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -2300,7 +2339,8 @@ var GroupMessaging = class {
|
|
|
2300
2339
|
const plaintext = encodeEdit({
|
|
2301
2340
|
clientMsgId: args.clientMsgId,
|
|
2302
2341
|
targetClientMsgId: args.targetClientMsgId,
|
|
2303
|
-
newText: args.newText
|
|
2342
|
+
newText: args.newText,
|
|
2343
|
+
bodyRanges: args.bodyRanges
|
|
2304
2344
|
});
|
|
2305
2345
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
2306
2346
|
const body = {
|
|
@@ -2326,7 +2366,10 @@ var GroupMessaging = class {
|
|
|
2326
2366
|
envelopeType: "edit",
|
|
2327
2367
|
edit: {
|
|
2328
2368
|
targetClientMsgId: args.targetClientMsgId,
|
|
2329
|
-
newText: args.newText
|
|
2369
|
+
newText: args.newText,
|
|
2370
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
2371
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
2372
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
2330
2373
|
}
|
|
2331
2374
|
};
|
|
2332
2375
|
try {
|
|
@@ -2448,6 +2491,41 @@ var GroupMessaging = class {
|
|
|
2448
2491
|
}
|
|
2449
2492
|
};
|
|
2450
2493
|
|
|
2494
|
+
// src/messaging/mention-ranges.ts
|
|
2495
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
2496
|
+
const n = text.length;
|
|
2497
|
+
function splitsSurrogatePair(index) {
|
|
2498
|
+
if (index <= 0 || index >= n) return false;
|
|
2499
|
+
const before = text.charCodeAt(index - 1);
|
|
2500
|
+
const at = text.charCodeAt(index);
|
|
2501
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
2502
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
2503
|
+
return beforeIsHigh && atIsLow;
|
|
2504
|
+
}
|
|
2505
|
+
const survivors = [];
|
|
2506
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
2507
|
+
const r = ranges[idx];
|
|
2508
|
+
if (r === void 0) continue;
|
|
2509
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
2510
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
2511
|
+
survivors.push({ idx, range: r });
|
|
2512
|
+
}
|
|
2513
|
+
survivors.sort((lhs, rhs) => {
|
|
2514
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
2515
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
2516
|
+
return lhs.idx - rhs.idx;
|
|
2517
|
+
});
|
|
2518
|
+
const kept = [];
|
|
2519
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
2520
|
+
for (const s of survivors) {
|
|
2521
|
+
if (s.range.start >= prevEnd) {
|
|
2522
|
+
kept.push(s.range);
|
|
2523
|
+
prevEnd = s.range.start + s.range.length;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
return kept;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2451
2529
|
// src/messaging/reaction-fold.ts
|
|
2452
2530
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2453
2531
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -2535,6 +2613,13 @@ var Chat = class {
|
|
|
2535
2613
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
2536
2614
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
2537
2615
|
suppressedLoaded = false;
|
|
2616
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
2617
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
2618
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
2619
|
+
elevated = /* @__PURE__ */ new Set();
|
|
2620
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
2621
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
2622
|
+
elevatedLoaded = false;
|
|
2538
2623
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
2539
2624
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
2540
2625
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -2546,6 +2631,14 @@ var Chat = class {
|
|
|
2546
2631
|
wired = false;
|
|
2547
2632
|
liveUnsub = null;
|
|
2548
2633
|
listeners = /* @__PURE__ */ new Set();
|
|
2634
|
+
/**
|
|
2635
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
2636
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
2637
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
2638
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
2639
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
2640
|
+
*/
|
|
2641
|
+
onMentionElevation;
|
|
2549
2642
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
2550
2643
|
constructor(args) {
|
|
2551
2644
|
this.backend = args.backend;
|
|
@@ -2623,7 +2716,8 @@ var Chat = class {
|
|
|
2623
2716
|
reactions: {},
|
|
2624
2717
|
replyTo: null,
|
|
2625
2718
|
edited: false,
|
|
2626
|
-
isDeleted: true
|
|
2719
|
+
isDeleted: true,
|
|
2720
|
+
mentions: []
|
|
2627
2721
|
});
|
|
2628
2722
|
continue;
|
|
2629
2723
|
}
|
|
@@ -2659,6 +2753,7 @@ var Chat = class {
|
|
|
2659
2753
|
this.wired = true;
|
|
2660
2754
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
2661
2755
|
void this.loadSuppressed();
|
|
2756
|
+
void this.loadElevated();
|
|
2662
2757
|
void this.hydrateHistory();
|
|
2663
2758
|
void this.refreshMembers();
|
|
2664
2759
|
}
|
|
@@ -2680,6 +2775,17 @@ var Chat = class {
|
|
|
2680
2775
|
} catch {
|
|
2681
2776
|
}
|
|
2682
2777
|
}
|
|
2778
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
2779
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
2780
|
+
async loadElevated() {
|
|
2781
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
2782
|
+
this.elevatedLoaded = true;
|
|
2783
|
+
try {
|
|
2784
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
2785
|
+
for (const k of keys) this.elevated.add(k);
|
|
2786
|
+
} catch {
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2683
2789
|
async hydrateHistory() {
|
|
2684
2790
|
if (this.historyLoaded || !this._group) return;
|
|
2685
2791
|
this.historyLoaded = true;
|
|
@@ -2709,7 +2815,9 @@ var Chat = class {
|
|
|
2709
2815
|
if (m.clientMsgId && !m.isDeleted) {
|
|
2710
2816
|
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
2711
2817
|
}
|
|
2712
|
-
this.messageList.push(
|
|
2818
|
+
this.messageList.push(
|
|
2819
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
2820
|
+
);
|
|
2713
2821
|
changed = true;
|
|
2714
2822
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
2715
2823
|
}
|
|
@@ -2759,7 +2867,10 @@ var Chat = class {
|
|
|
2759
2867
|
newText: incoming.edit.newText,
|
|
2760
2868
|
epoch: incoming.epoch,
|
|
2761
2869
|
serverSeq: incoming.serverSeq,
|
|
2762
|
-
eventClientMsgId: incoming.clientMsgId
|
|
2870
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
2871
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
2872
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
2873
|
+
bodyRanges: incoming.bodyRanges
|
|
2763
2874
|
},
|
|
2764
2875
|
this.authorOfTarget
|
|
2765
2876
|
);
|
|
@@ -2787,6 +2898,7 @@ var Chat = class {
|
|
|
2787
2898
|
if (incomingReplyRef) {
|
|
2788
2899
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
2789
2900
|
}
|
|
2901
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
2790
2902
|
const msg = {
|
|
2791
2903
|
id: this.publicId(incoming.serverSeq),
|
|
2792
2904
|
kind: this.kindOf(incoming),
|
|
@@ -2803,8 +2915,10 @@ var Chat = class {
|
|
|
2803
2915
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
2804
2916
|
edited: false,
|
|
2805
2917
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
2806
|
-
isDeleted: false
|
|
2918
|
+
isDeleted: false,
|
|
2919
|
+
mentions
|
|
2807
2920
|
};
|
|
2921
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
2808
2922
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
2809
2923
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
2810
2924
|
text: incoming.text,
|
|
@@ -2828,6 +2942,75 @@ var Chat = class {
|
|
|
2828
2942
|
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
2829
2943
|
* so it can be passed to the pure EditFold. */
|
|
2830
2944
|
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
2945
|
+
// ── Mentions (mentions T6) ──
|
|
2946
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
2947
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
2948
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
2949
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
2950
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
2951
|
+
resolveMentions(text, bodyRanges) {
|
|
2952
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
2953
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
2954
|
+
if (normalized.length === 0) return [];
|
|
2955
|
+
return normalized.map((r) => ({
|
|
2956
|
+
start: r.start,
|
|
2957
|
+
length: r.length,
|
|
2958
|
+
mentionedUserId: r.mentionedUserId,
|
|
2959
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
2960
|
+
}));
|
|
2961
|
+
}
|
|
2962
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
2963
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
2964
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
2965
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
2966
|
+
resolveMentionNames(m) {
|
|
2967
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
2968
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
2969
|
+
}
|
|
2970
|
+
let changed = false;
|
|
2971
|
+
const reresolved = m.mentions.map((span) => {
|
|
2972
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
2973
|
+
if (name === span.displayName) return span;
|
|
2974
|
+
changed = true;
|
|
2975
|
+
return { ...span, displayName: name };
|
|
2976
|
+
});
|
|
2977
|
+
if (!changed) return m;
|
|
2978
|
+
return { ...m, mentions: reresolved };
|
|
2979
|
+
}
|
|
2980
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
2981
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
2982
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
2983
|
+
editMentions(targetClientMsgId, newText) {
|
|
2984
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
2985
|
+
if (!ranges) return [];
|
|
2986
|
+
return this.resolveMentions(newText, ranges);
|
|
2987
|
+
}
|
|
2988
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
2989
|
+
displayNameOf(userId) {
|
|
2990
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
2991
|
+
}
|
|
2992
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
2993
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
2994
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
2995
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
2996
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
2997
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
2998
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
2999
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
3000
|
+
const me = this.backend.selfUserId;
|
|
3001
|
+
if (envelopeType === "edit") return;
|
|
3002
|
+
if (senderUserId === me) return;
|
|
3003
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
3004
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
3005
|
+
const key = `${me}|${idPart}`;
|
|
3006
|
+
if (this.elevated.has(key)) return;
|
|
3007
|
+
this.elevated.add(key);
|
|
3008
|
+
if (this._group) {
|
|
3009
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
this.onMentionElevation?.(message);
|
|
3013
|
+
}
|
|
2831
3014
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
2832
3015
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
2833
3016
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -2884,9 +3067,10 @@ var Chat = class {
|
|
|
2884
3067
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
2885
3068
|
const text = editText ?? base;
|
|
2886
3069
|
const edited = foldEdited || m.edited;
|
|
2887
|
-
|
|
3070
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
3071
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
2888
3072
|
changed = true;
|
|
2889
|
-
return { ...m, text, edited };
|
|
3073
|
+
return { ...m, text, edited, mentions };
|
|
2890
3074
|
});
|
|
2891
3075
|
if (changed) this.emit();
|
|
2892
3076
|
}
|
|
@@ -2903,8 +3087,9 @@ var Chat = class {
|
|
|
2903
3087
|
if (editText === null && !foldEdited) return m;
|
|
2904
3088
|
const text = editText ?? m.text;
|
|
2905
3089
|
const edited = foldEdited || m.edited;
|
|
2906
|
-
|
|
2907
|
-
|
|
3090
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
3091
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3092
|
+
return { ...m, text, edited, mentions };
|
|
2908
3093
|
}
|
|
2909
3094
|
/** @internal — called by the backend's conv subscription. */
|
|
2910
3095
|
applyConv(event, payload) {
|
|
@@ -2962,6 +3147,18 @@ var Chat = class {
|
|
|
2962
3147
|
}
|
|
2963
3148
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
2964
3149
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
3150
|
+
this.reresolveAllMentionNames();
|
|
3151
|
+
}
|
|
3152
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
3153
|
+
* change). Re-emits only if any name actually changed. */
|
|
3154
|
+
reresolveAllMentionNames() {
|
|
3155
|
+
let changed = false;
|
|
3156
|
+
this.messageList = this.messageList.map((m) => {
|
|
3157
|
+
const reresolved = this.resolveMentionNames(m);
|
|
3158
|
+
if (reresolved !== m) changed = true;
|
|
3159
|
+
return reresolved;
|
|
3160
|
+
});
|
|
3161
|
+
if (changed) this.emit();
|
|
2965
3162
|
}
|
|
2966
3163
|
seedMembersFromGroup(group) {
|
|
2967
3164
|
const seed = [
|
|
@@ -3029,11 +3226,12 @@ var Chat = class {
|
|
|
3029
3226
|
};
|
|
3030
3227
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3031
3228
|
}
|
|
3032
|
-
const
|
|
3033
|
-
this.
|
|
3229
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3230
|
+
const { receipt, clientMsgId } = await this.backend.sendText(group, text, replyRef, bodyRanges);
|
|
3231
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
3034
3232
|
return receipt;
|
|
3035
3233
|
}
|
|
3036
|
-
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo) {
|
|
3234
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
3037
3235
|
if (receipt.serverSeq <= 0) return;
|
|
3038
3236
|
const key = this.internalKey(receipt.serverSeq);
|
|
3039
3237
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3058,7 +3256,10 @@ var Chat = class {
|
|
|
3058
3256
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3059
3257
|
edited: false,
|
|
3060
3258
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3061
|
-
isDeleted: false
|
|
3259
|
+
isDeleted: false,
|
|
3260
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
3261
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
3262
|
+
mentions: this.resolveMentions(text, bodyRanges)
|
|
3062
3263
|
});
|
|
3063
3264
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3064
3265
|
this.emit();
|
|
@@ -3146,15 +3347,18 @@ var Chat = class {
|
|
|
3146
3347
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3147
3348
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3148
3349
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
3149
|
-
* message self IS the author, so the author-gate passes.
|
|
3150
|
-
|
|
3350
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
3351
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
3352
|
+
async edit(message, newText, opts) {
|
|
3151
3353
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3152
3354
|
const group = await this.materializeIfNeeded();
|
|
3153
3355
|
const clientMsgId = mintClientMsgId();
|
|
3356
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3154
3357
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
3155
3358
|
clientMsgId,
|
|
3156
3359
|
targetClientMsgId: message.clientMsgId,
|
|
3157
|
-
newText
|
|
3360
|
+
newText,
|
|
3361
|
+
bodyRanges
|
|
3158
3362
|
});
|
|
3159
3363
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3160
3364
|
this.editFold.ingest(
|
|
@@ -3164,7 +3368,8 @@ var Chat = class {
|
|
|
3164
3368
|
newText,
|
|
3165
3369
|
epoch: receipt.epoch,
|
|
3166
3370
|
serverSeq: receipt.serverSeq,
|
|
3167
|
-
eventClientMsgId: clientMsgId
|
|
3371
|
+
eventClientMsgId: clientMsgId,
|
|
3372
|
+
bodyRanges
|
|
3168
3373
|
},
|
|
3169
3374
|
this.authorOfTarget
|
|
3170
3375
|
);
|
|
@@ -3217,6 +3422,18 @@ var Chat = class {
|
|
|
3217
3422
|
}
|
|
3218
3423
|
}
|
|
3219
3424
|
};
|
|
3425
|
+
function sameMentions(a, b) {
|
|
3426
|
+
if (a.length !== b.length) return false;
|
|
3427
|
+
for (let i = 0; i < a.length; i++) {
|
|
3428
|
+
const x = a[i];
|
|
3429
|
+
const y = b[i];
|
|
3430
|
+
if (!x || !y) return false;
|
|
3431
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
3432
|
+
return false;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
return true;
|
|
3436
|
+
}
|
|
3220
3437
|
function sameReactions(a, b) {
|
|
3221
3438
|
const ak = Object.keys(a);
|
|
3222
3439
|
const bk = Object.keys(b);
|
|
@@ -3428,14 +3645,21 @@ var MessageDeliverySource = class {
|
|
|
3428
3645
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
3429
3646
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
3430
3647
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
3431
|
-
// `'text'`/no-edit (backward-compat).
|
|
3648
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
3649
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
3432
3650
|
...isEdit && decoded.edit ? {
|
|
3433
3651
|
envelopeType: "edit",
|
|
3434
3652
|
edit: {
|
|
3435
3653
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
3436
|
-
newText: decoded.edit.newText
|
|
3654
|
+
newText: decoded.edit.newText,
|
|
3655
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
3437
3656
|
}
|
|
3438
3657
|
} : {},
|
|
3658
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
3659
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
3660
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
3661
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
3662
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
3439
3663
|
// Thread the delete discriminator + target through the persisted row so a
|
|
3440
3664
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
3441
3665
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -3467,7 +3691,10 @@ var MessageDeliverySource = class {
|
|
|
3467
3691
|
envelopeType: decoded.type ?? "text",
|
|
3468
3692
|
reaction: isReaction ? decoded.reaction : null,
|
|
3469
3693
|
edit: isEdit ? decoded.edit : null,
|
|
3470
|
-
delete: isDelete ? decoded.delete : null
|
|
3694
|
+
delete: isDelete ? decoded.delete : null,
|
|
3695
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
3696
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
3697
|
+
bodyRanges: decoded.bodyRanges ?? null
|
|
3471
3698
|
});
|
|
3472
3699
|
return true;
|
|
3473
3700
|
}
|
|
@@ -3622,6 +3849,36 @@ var GroupCatalog = class {
|
|
|
3622
3849
|
}
|
|
3623
3850
|
};
|
|
3624
3851
|
|
|
3852
|
+
// src/messaging/mention-elevation.ts
|
|
3853
|
+
var MentionElevationStore = class {
|
|
3854
|
+
constructor(kv) {
|
|
3855
|
+
this.kv = kv;
|
|
3856
|
+
}
|
|
3857
|
+
kv;
|
|
3858
|
+
key(rfcGroupId) {
|
|
3859
|
+
return `elev:${rfcGroupId}`;
|
|
3860
|
+
}
|
|
3861
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
3862
|
+
async load(rfcGroupId) {
|
|
3863
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
3864
|
+
if (!raw) return [];
|
|
3865
|
+
try {
|
|
3866
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
3867
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
3868
|
+
} catch {
|
|
3869
|
+
return [];
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
3873
|
+
async save(rfcGroupId, keys) {
|
|
3874
|
+
const sorted = [...new Set(keys)].sort();
|
|
3875
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
3876
|
+
}
|
|
3877
|
+
async wipe() {
|
|
3878
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
3879
|
+
}
|
|
3880
|
+
};
|
|
3881
|
+
|
|
3625
3882
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
3626
3883
|
var palbe_mls_bg_exports = {};
|
|
3627
3884
|
__export(palbe_mls_bg_exports, {
|
|
@@ -5329,6 +5586,7 @@ var MessagingCoordinator = class {
|
|
|
5329
5586
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5330
5587
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
5331
5588
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5589
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
5332
5590
|
this.registry.attachChatList(
|
|
5333
5591
|
(chats) => {
|
|
5334
5592
|
this.chatList = chats;
|
|
@@ -5344,6 +5602,7 @@ var MessagingCoordinator = class {
|
|
|
5344
5602
|
groupStore;
|
|
5345
5603
|
kpStore;
|
|
5346
5604
|
suppressionStore;
|
|
5605
|
+
elevationStore;
|
|
5347
5606
|
registry = new GroupRegistry();
|
|
5348
5607
|
resolved = null;
|
|
5349
5608
|
resolvePromise = null;
|
|
@@ -5499,9 +5758,9 @@ var MessagingCoordinator = class {
|
|
|
5499
5758
|
});
|
|
5500
5759
|
return group;
|
|
5501
5760
|
}
|
|
5502
|
-
async sendText(group, text, replyTo) {
|
|
5761
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
5503
5762
|
const r = await this.resolve();
|
|
5504
|
-
return r.groups.sendText(group, text, replyTo);
|
|
5763
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
5505
5764
|
}
|
|
5506
5765
|
async sendReaction(group, args) {
|
|
5507
5766
|
const r = await this.resolve();
|
|
@@ -5523,6 +5782,14 @@ var MessagingCoordinator = class {
|
|
|
5523
5782
|
saveSuppressed(group, keys) {
|
|
5524
5783
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
5525
5784
|
}
|
|
5785
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
5786
|
+
loadElevated(group) {
|
|
5787
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
5788
|
+
}
|
|
5789
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
5790
|
+
saveElevated(group, keys) {
|
|
5791
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
5792
|
+
}
|
|
5526
5793
|
async history(group, limit, before) {
|
|
5527
5794
|
const r = await this.resolve();
|
|
5528
5795
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -5641,7 +5908,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5641
5908
|
newText: s.edit.newText,
|
|
5642
5909
|
epoch: s.epoch,
|
|
5643
5910
|
serverSeq: s.serverSeq,
|
|
5644
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
5911
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
5912
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
5913
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
5914
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
5645
5915
|
},
|
|
5646
5916
|
authorOfTarget
|
|
5647
5917
|
);
|
|
@@ -5691,7 +5961,9 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5691
5961
|
replyTo: null,
|
|
5692
5962
|
reactions: {},
|
|
5693
5963
|
edited: false,
|
|
5694
|
-
isDeleted: true
|
|
5964
|
+
isDeleted: true,
|
|
5965
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
5966
|
+
mentions: []
|
|
5695
5967
|
});
|
|
5696
5968
|
continue;
|
|
5697
5969
|
}
|
|
@@ -5711,23 +5983,36 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
5711
5983
|
}
|
|
5712
5984
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
5713
5985
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
5986
|
+
const text = editText ?? s.text;
|
|
5987
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
5988
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
5714
5989
|
out.push({
|
|
5715
5990
|
id: `${displayId}#${s.serverSeq}`,
|
|
5716
5991
|
kind: s.text != null ? "text" : "system",
|
|
5717
5992
|
direction: s.direction,
|
|
5718
5993
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
5719
|
-
text
|
|
5994
|
+
text,
|
|
5720
5995
|
serverSeq: s.serverSeq,
|
|
5721
5996
|
sentAt: new Date(s.at),
|
|
5722
5997
|
clientMsgId,
|
|
5723
5998
|
replyTo,
|
|
5724
5999
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
5725
6000
|
edited,
|
|
5726
|
-
isDeleted: false
|
|
6001
|
+
isDeleted: false,
|
|
6002
|
+
mentions
|
|
5727
6003
|
});
|
|
5728
6004
|
}
|
|
5729
6005
|
return out;
|
|
5730
6006
|
}
|
|
6007
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
6008
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
6009
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
6010
|
+
start: r.start,
|
|
6011
|
+
length: r.length,
|
|
6012
|
+
mentionedUserId: r.mentionedUserId,
|
|
6013
|
+
displayName: null
|
|
6014
|
+
}));
|
|
6015
|
+
}
|
|
5731
6016
|
|
|
5732
6017
|
// src/messaging/facade.ts
|
|
5733
6018
|
var PalbeMessaging = class {
|
|
@@ -6580,7 +6865,7 @@ function localStorageSessionStorage(key = DEFAULT_KEY) {
|
|
|
6580
6865
|
}
|
|
6581
6866
|
|
|
6582
6867
|
// src/version.ts
|
|
6583
|
-
var VERSION = "1.
|
|
6868
|
+
var VERSION = "1.5.0";
|
|
6584
6869
|
|
|
6585
6870
|
// src/internal.ts
|
|
6586
6871
|
function getRuntime() {
|