@palbase/web 1.4.0 → 1.6.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-DYNLUW0L.d.ts} +239 -7
- package/dist/{analytics-facade-DA9TGKLF.d.cts → analytics-facade-iAWinswQ.d.cts} +239 -7
- package/dist/{chunk-3EVGYJ5F.js → chunk-A5WIPBGQ.js} +893 -70
- package/dist/chunk-A5WIPBGQ.js.map +1 -0
- package/dist/index.cjs +892 -69
- 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 +892 -69
- 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 +892 -69
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +892 -69
- 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-SVs7vTOp.d.cts → pb-Blj85_cg.d.cts} +1 -1
- package/dist/{pb-CLfTLQzH.d.ts → pb-DPPpThNg.d.ts} +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
|
@@ -2413,6 +2413,47 @@ var PalbeFlags = class {
|
|
|
2413
2413
|
}
|
|
2414
2414
|
};
|
|
2415
2415
|
|
|
2416
|
+
// src/messaging/deadline-calculator.ts
|
|
2417
|
+
function remainingSeconds(args) {
|
|
2418
|
+
const ttl = args.ttlSeconds;
|
|
2419
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
2420
|
+
let elapsed;
|
|
2421
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
2422
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
2423
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
2424
|
+
} else {
|
|
2425
|
+
elapsed = wallDeltaSec;
|
|
2426
|
+
}
|
|
2427
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
2428
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
2429
|
+
}
|
|
2430
|
+
var cachedBootToken = null;
|
|
2431
|
+
var MonotonicClock = {
|
|
2432
|
+
nowMs() {
|
|
2433
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2434
|
+
},
|
|
2435
|
+
nowWallEpochMs() {
|
|
2436
|
+
return Date.now();
|
|
2437
|
+
},
|
|
2438
|
+
bootToken() {
|
|
2439
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
2440
|
+
try {
|
|
2441
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
2442
|
+
if (existing) {
|
|
2443
|
+
cachedBootToken = existing;
|
|
2444
|
+
return existing;
|
|
2445
|
+
}
|
|
2446
|
+
const fresh = crypto.randomUUID();
|
|
2447
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
2448
|
+
cachedBootToken = fresh;
|
|
2449
|
+
return fresh;
|
|
2450
|
+
} catch {
|
|
2451
|
+
cachedBootToken = crypto.randomUUID();
|
|
2452
|
+
return cachedBootToken;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2416
2457
|
// src/messaging/delete-fold.ts
|
|
2417
2458
|
var DeleteFold = class {
|
|
2418
2459
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -2425,17 +2466,24 @@ var DeleteFold = class {
|
|
|
2425
2466
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2426
2467
|
held = [];
|
|
2427
2468
|
/**
|
|
2428
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
2429
|
-
*
|
|
2469
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
2470
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
2471
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
2472
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
2473
|
+
* park in pending, never re-attempt).
|
|
2430
2474
|
*/
|
|
2431
2475
|
ingest(e, authorOfTarget) {
|
|
2432
2476
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2433
2477
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2434
2478
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2435
|
-
const
|
|
2436
|
-
if (
|
|
2479
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2480
|
+
if (res.kind === "purged") {
|
|
2437
2481
|
this.seen.add(e.eventClientMsgId);
|
|
2438
|
-
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
if (res.kind === "author") {
|
|
2485
|
+
this.seen.add(e.eventClientMsgId);
|
|
2486
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
2439
2487
|
this.tombstoned.add(e.targetClientMsgId);
|
|
2440
2488
|
} else if (e.actorUserId !== null) {
|
|
2441
2489
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -2455,12 +2503,13 @@ var DeleteFold = class {
|
|
|
2455
2503
|
* the in-order path.
|
|
2456
2504
|
*/
|
|
2457
2505
|
reevaluatePending(target, author) {
|
|
2506
|
+
const res = author;
|
|
2458
2507
|
const actor = this.pending.get(target);
|
|
2459
2508
|
if (actor !== void 0) {
|
|
2460
|
-
if (
|
|
2461
|
-
this.tombstoned.add(target);
|
|
2509
|
+
if (res.kind === "author") {
|
|
2510
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
2462
2511
|
this.pending.delete(target);
|
|
2463
|
-
} else if (
|
|
2512
|
+
} else if (res.kind === "purged") {
|
|
2464
2513
|
this.pending.delete(target);
|
|
2465
2514
|
}
|
|
2466
2515
|
}
|
|
@@ -2468,7 +2517,7 @@ var DeleteFold = class {
|
|
|
2468
2517
|
const pendingHeld = this.held;
|
|
2469
2518
|
this.held = [];
|
|
2470
2519
|
for (const e of pendingHeld) {
|
|
2471
|
-
this.ingest(e, (t) => t === target ?
|
|
2520
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
2472
2521
|
}
|
|
2473
2522
|
}
|
|
2474
2523
|
heldContains(eventClientMsgId) {
|
|
@@ -2494,15 +2543,23 @@ var EditFold = class {
|
|
|
2494
2543
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
2495
2544
|
editedTargets = /* @__PURE__ */ new Set();
|
|
2496
2545
|
/**
|
|
2497
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
2498
|
-
*
|
|
2546
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
2547
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
2548
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
2549
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
2550
|
+
* and a later author "resolution" cannot resurrect it).
|
|
2499
2551
|
*/
|
|
2500
2552
|
ingest(e, authorOfTarget) {
|
|
2501
|
-
const
|
|
2502
|
-
if (
|
|
2553
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2554
|
+
if (res.kind === "unknown") {
|
|
2503
2555
|
this.holdIfNew(e);
|
|
2504
2556
|
return;
|
|
2505
2557
|
}
|
|
2558
|
+
if (res.kind === "purged") {
|
|
2559
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
const author = res.userId;
|
|
2506
2563
|
if (e.editorUserId === null) {
|
|
2507
2564
|
this.holdIfNew(e);
|
|
2508
2565
|
return;
|
|
@@ -2521,7 +2578,8 @@ var EditFold = class {
|
|
|
2521
2578
|
orderEpoch: e.epoch,
|
|
2522
2579
|
orderSeq: e.serverSeq,
|
|
2523
2580
|
lastEventId: e.eventClientMsgId,
|
|
2524
|
-
text: e.newText
|
|
2581
|
+
text: e.newText,
|
|
2582
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2525
2583
|
});
|
|
2526
2584
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2527
2585
|
}
|
|
@@ -2542,6 +2600,15 @@ var EditFold = class {
|
|
|
2542
2600
|
isEdited(targetClientMsgId) {
|
|
2543
2601
|
return this.editedTargets.has(targetClientMsgId);
|
|
2544
2602
|
}
|
|
2603
|
+
/**
|
|
2604
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2605
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2606
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2607
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2608
|
+
*/
|
|
2609
|
+
bodyRanges(targetClientMsgId) {
|
|
2610
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2611
|
+
}
|
|
2545
2612
|
/**
|
|
2546
2613
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2547
2614
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2715,7 +2782,14 @@ function encodeEdit(args) {
|
|
|
2715
2782
|
type: "edit",
|
|
2716
2783
|
client_msg_id: args.clientMsgId,
|
|
2717
2784
|
target_client_msg_id: args.targetClientMsgId,
|
|
2718
|
-
new_text: args.newText
|
|
2785
|
+
new_text: args.newText,
|
|
2786
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2787
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2788
|
+
start: r.start,
|
|
2789
|
+
length: r.length,
|
|
2790
|
+
mentioned_user_id: r.mentionedUserId
|
|
2791
|
+
}))
|
|
2792
|
+
} : {}
|
|
2719
2793
|
})
|
|
2720
2794
|
);
|
|
2721
2795
|
}
|
|
@@ -2737,14 +2811,53 @@ function encodeEnvelope(args) {
|
|
|
2737
2811
|
type: "text",
|
|
2738
2812
|
client_msg_id: args.clientMsgId,
|
|
2739
2813
|
text: args.text,
|
|
2740
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
2814
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
2815
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2816
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2817
|
+
start: r.start,
|
|
2818
|
+
length: r.length,
|
|
2819
|
+
mentioned_user_id: r.mentionedUserId
|
|
2820
|
+
}))
|
|
2821
|
+
} : {},
|
|
2822
|
+
...args.expiry ? {
|
|
2823
|
+
expiry: {
|
|
2824
|
+
v: args.expiry.v,
|
|
2825
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
2826
|
+
start: args.expiry.start,
|
|
2827
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
2828
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
2829
|
+
}
|
|
2830
|
+
} : {}
|
|
2741
2831
|
};
|
|
2742
2832
|
return encodeUtf8(JSON.stringify(env));
|
|
2743
2833
|
}
|
|
2834
|
+
function encodeTimerSet(args) {
|
|
2835
|
+
return encodeUtf8(
|
|
2836
|
+
JSON.stringify({
|
|
2837
|
+
v: 1,
|
|
2838
|
+
type: "timer_set",
|
|
2839
|
+
client_msg_id: args.clientMsgId,
|
|
2840
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
2841
|
+
start: args.start
|
|
2842
|
+
})
|
|
2843
|
+
);
|
|
2844
|
+
}
|
|
2744
2845
|
function decodeEnvelope(bytes) {
|
|
2745
2846
|
const s = decodeUtf8(bytes);
|
|
2746
2847
|
try {
|
|
2747
2848
|
const o = JSON.parse(s);
|
|
2849
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
2850
|
+
return {
|
|
2851
|
+
type: "timer_set",
|
|
2852
|
+
text: null,
|
|
2853
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2854
|
+
replyTo: null,
|
|
2855
|
+
timer: {
|
|
2856
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
2857
|
+
start: o.start === "read" ? "read" : "send"
|
|
2858
|
+
}
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2748
2861
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2749
2862
|
return {
|
|
2750
2863
|
type: "delete",
|
|
@@ -2771,6 +2884,7 @@ function decodeEnvelope(bytes) {
|
|
|
2771
2884
|
};
|
|
2772
2885
|
}
|
|
2773
2886
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
2887
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2774
2888
|
return {
|
|
2775
2889
|
type: "edit",
|
|
2776
2890
|
text: null,
|
|
@@ -2779,15 +2893,20 @@ function decodeEnvelope(bytes) {
|
|
|
2779
2893
|
edit: {
|
|
2780
2894
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2781
2895
|
newText: o.new_text ?? ""
|
|
2782
|
-
}
|
|
2896
|
+
},
|
|
2897
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2783
2898
|
};
|
|
2784
2899
|
}
|
|
2785
2900
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
2901
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
2902
|
+
const expiry = decodeExpiry(o.expiry);
|
|
2786
2903
|
return {
|
|
2787
2904
|
type: "text",
|
|
2788
2905
|
text: o.text ?? null,
|
|
2789
2906
|
clientMsgId: o.client_msg_id ?? "",
|
|
2790
|
-
replyTo: o.reply_to ?? null
|
|
2907
|
+
replyTo: o.reply_to ?? null,
|
|
2908
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
2909
|
+
...expiry ? { expiry } : {}
|
|
2791
2910
|
};
|
|
2792
2911
|
}
|
|
2793
2912
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2799,6 +2918,27 @@ function decodeEnvelope(bytes) {
|
|
|
2799
2918
|
}
|
|
2800
2919
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2801
2920
|
}
|
|
2921
|
+
function decodeExpiry(raw) {
|
|
2922
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
2923
|
+
const o = raw;
|
|
2924
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
2925
|
+
const start = o.start === "read" ? "read" : "send";
|
|
2926
|
+
return {
|
|
2927
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
2928
|
+
ttlSeconds: o.ttl_seconds,
|
|
2929
|
+
start,
|
|
2930
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
2931
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2934
|
+
function decodeBodyRanges(raw) {
|
|
2935
|
+
if (!raw || raw.length === 0) return void 0;
|
|
2936
|
+
return raw.map((r) => ({
|
|
2937
|
+
start: r.start,
|
|
2938
|
+
length: r.length,
|
|
2939
|
+
mentionedUserId: r.mentioned_user_id
|
|
2940
|
+
}));
|
|
2941
|
+
}
|
|
2802
2942
|
function resolveReply(ref, lookup) {
|
|
2803
2943
|
const parent = lookup(ref.client_msg_id);
|
|
2804
2944
|
if (parent !== null) {
|
|
@@ -3010,9 +3150,9 @@ var GroupMessaging = class {
|
|
|
3010
3150
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3011
3151
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3012
3152
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3013
|
-
async sendText(group, text, replyTo) {
|
|
3153
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
3014
3154
|
const clientMsgId = mintClientMsgId();
|
|
3015
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3155
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
3016
3156
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3017
3157
|
const body = {
|
|
3018
3158
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3038,7 +3178,13 @@ var GroupMessaging = class {
|
|
|
3038
3178
|
previewBody: replyTo.preview?.body ?? null,
|
|
3039
3179
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3040
3180
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3041
|
-
} : null
|
|
3181
|
+
} : null,
|
|
3182
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3183
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3184
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
3185
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
3186
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
3187
|
+
...expiry ? { expiry } : {}
|
|
3042
3188
|
};
|
|
3043
3189
|
try {
|
|
3044
3190
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3046,6 +3192,51 @@ var GroupMessaging = class {
|
|
|
3046
3192
|
}
|
|
3047
3193
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
3048
3194
|
}
|
|
3195
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
3196
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
3197
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
3198
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
3199
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
3200
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
3201
|
+
async sendTimerSet(group, args) {
|
|
3202
|
+
const plaintext = encodeTimerSet({
|
|
3203
|
+
clientMsgId: args.clientMsgId,
|
|
3204
|
+
ttlSeconds: args.ttlSeconds,
|
|
3205
|
+
start: args.start
|
|
3206
|
+
});
|
|
3207
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3208
|
+
const body = {
|
|
3209
|
+
ciphertext_b64: toBase64(ct),
|
|
3210
|
+
client_idem_key: randomId()
|
|
3211
|
+
};
|
|
3212
|
+
const wire = await palbeRequest(
|
|
3213
|
+
this.rt,
|
|
3214
|
+
"POST",
|
|
3215
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3216
|
+
{ body }
|
|
3217
|
+
);
|
|
3218
|
+
const stored = {
|
|
3219
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3220
|
+
direction: "outgoing",
|
|
3221
|
+
text: null,
|
|
3222
|
+
senderDeviceId: this.selfDeviceId,
|
|
3223
|
+
epoch: wire.epoch,
|
|
3224
|
+
serverSeq: wire.server_seq,
|
|
3225
|
+
at: Date.now(),
|
|
3226
|
+
clientMsgId: args.clientMsgId,
|
|
3227
|
+
replyTo: null,
|
|
3228
|
+
envelopeType: "timer_set",
|
|
3229
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
3230
|
+
};
|
|
3231
|
+
try {
|
|
3232
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3233
|
+
} catch {
|
|
3234
|
+
}
|
|
3235
|
+
return {
|
|
3236
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3237
|
+
clientMsgId: args.clientMsgId
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3049
3240
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
3050
3241
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
3051
3242
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -3106,7 +3297,8 @@ var GroupMessaging = class {
|
|
|
3106
3297
|
const plaintext = encodeEdit({
|
|
3107
3298
|
clientMsgId: args.clientMsgId,
|
|
3108
3299
|
targetClientMsgId: args.targetClientMsgId,
|
|
3109
|
-
newText: args.newText
|
|
3300
|
+
newText: args.newText,
|
|
3301
|
+
bodyRanges: args.bodyRanges
|
|
3110
3302
|
});
|
|
3111
3303
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3112
3304
|
const body = {
|
|
@@ -3132,7 +3324,10 @@ var GroupMessaging = class {
|
|
|
3132
3324
|
envelopeType: "edit",
|
|
3133
3325
|
edit: {
|
|
3134
3326
|
targetClientMsgId: args.targetClientMsgId,
|
|
3135
|
-
newText: args.newText
|
|
3327
|
+
newText: args.newText,
|
|
3328
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3329
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3330
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3136
3331
|
}
|
|
3137
3332
|
};
|
|
3138
3333
|
try {
|
|
@@ -3254,6 +3449,41 @@ var GroupMessaging = class {
|
|
|
3254
3449
|
}
|
|
3255
3450
|
};
|
|
3256
3451
|
|
|
3452
|
+
// src/messaging/mention-ranges.ts
|
|
3453
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3454
|
+
const n = text.length;
|
|
3455
|
+
function splitsSurrogatePair(index) {
|
|
3456
|
+
if (index <= 0 || index >= n) return false;
|
|
3457
|
+
const before = text.charCodeAt(index - 1);
|
|
3458
|
+
const at = text.charCodeAt(index);
|
|
3459
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3460
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3461
|
+
return beforeIsHigh && atIsLow;
|
|
3462
|
+
}
|
|
3463
|
+
const survivors = [];
|
|
3464
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3465
|
+
const r = ranges[idx];
|
|
3466
|
+
if (r === void 0) continue;
|
|
3467
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3468
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3469
|
+
survivors.push({ idx, range: r });
|
|
3470
|
+
}
|
|
3471
|
+
survivors.sort((lhs, rhs) => {
|
|
3472
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3473
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3474
|
+
return lhs.idx - rhs.idx;
|
|
3475
|
+
});
|
|
3476
|
+
const kept = [];
|
|
3477
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3478
|
+
for (const s of survivors) {
|
|
3479
|
+
if (s.range.start >= prevEnd) {
|
|
3480
|
+
kept.push(s.range);
|
|
3481
|
+
prevEnd = s.range.start + s.range.length;
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
return kept;
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3257
3487
|
// src/messaging/reaction-fold.ts
|
|
3258
3488
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3259
3489
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3309,6 +3539,43 @@ var ReactionFold = class {
|
|
|
3309
3539
|
}
|
|
3310
3540
|
};
|
|
3311
3541
|
|
|
3542
|
+
// src/messaging/timer-fold.ts
|
|
3543
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3544
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
3545
|
+
return aSeq <= bSeq;
|
|
3546
|
+
}
|
|
3547
|
+
var TimerFold = class {
|
|
3548
|
+
cell = null;
|
|
3549
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
3550
|
+
ingest(e) {
|
|
3551
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
3552
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
3553
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
this.cell = {
|
|
3557
|
+
orderEpoch: e.epoch,
|
|
3558
|
+
orderSeq: e.serverSeq,
|
|
3559
|
+
ttlSeconds: e.ttlSeconds,
|
|
3560
|
+
start: e.start,
|
|
3561
|
+
actor: e.actorUserId
|
|
3562
|
+
};
|
|
3563
|
+
}
|
|
3564
|
+
/**
|
|
3565
|
+
* The active chat default, or null if no timer_set has applied.
|
|
3566
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
3567
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
3568
|
+
*/
|
|
3569
|
+
active() {
|
|
3570
|
+
if (this.cell === null) return null;
|
|
3571
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
3572
|
+
}
|
|
3573
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
3574
|
+
lastActor() {
|
|
3575
|
+
return this.cell?.actor ?? null;
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
|
|
3312
3579
|
// src/messaging/chat.ts
|
|
3313
3580
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3314
3581
|
var Chat = class {
|
|
@@ -3335,12 +3602,35 @@ var Chat = class {
|
|
|
3335
3602
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3336
3603
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3337
3604
|
deleteFold = new DeleteFold();
|
|
3605
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
3606
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
3607
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
3608
|
+
* no per-message expiry (disappearing T10). */
|
|
3609
|
+
timerFold = new TimerFold();
|
|
3610
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
3611
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
3612
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
3613
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
3614
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
3615
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
3616
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
3617
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
3618
|
+
* tombstone (disappearing T10). */
|
|
3619
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
3620
|
+
purgedLoaded = false;
|
|
3338
3621
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3339
3622
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3340
3623
|
suppressed = /* @__PURE__ */ new Set();
|
|
3341
3624
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3342
3625
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3343
3626
|
suppressedLoaded = false;
|
|
3627
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3628
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3629
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3630
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3631
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3632
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3633
|
+
elevatedLoaded = false;
|
|
3344
3634
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3345
3635
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3346
3636
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3352,6 +3642,14 @@ var Chat = class {
|
|
|
3352
3642
|
wired = false;
|
|
3353
3643
|
liveUnsub = null;
|
|
3354
3644
|
listeners = /* @__PURE__ */ new Set();
|
|
3645
|
+
/**
|
|
3646
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3647
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3648
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3649
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3650
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3651
|
+
*/
|
|
3652
|
+
onMentionElevation;
|
|
3355
3653
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3356
3654
|
constructor(args) {
|
|
3357
3655
|
this.backend = args.backend;
|
|
@@ -3429,7 +3727,10 @@ var Chat = class {
|
|
|
3429
3727
|
reactions: {},
|
|
3430
3728
|
replyTo: null,
|
|
3431
3729
|
edited: false,
|
|
3432
|
-
isDeleted: true
|
|
3730
|
+
isDeleted: true,
|
|
3731
|
+
mentions: [],
|
|
3732
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
3733
|
+
expiresAt: null
|
|
3433
3734
|
});
|
|
3434
3735
|
continue;
|
|
3435
3736
|
}
|
|
@@ -3465,9 +3766,22 @@ var Chat = class {
|
|
|
3465
3766
|
this.wired = true;
|
|
3466
3767
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3467
3768
|
void this.loadSuppressed();
|
|
3468
|
-
void this.
|
|
3769
|
+
void this.loadElevated();
|
|
3770
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
3469
3771
|
void this.refreshMembers();
|
|
3470
3772
|
}
|
|
3773
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
3774
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
3775
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
3776
|
+
async loadPurged() {
|
|
3777
|
+
if (this.purgedLoaded || !this._group) return;
|
|
3778
|
+
this.purgedLoaded = true;
|
|
3779
|
+
try {
|
|
3780
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
3781
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
3782
|
+
} catch {
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3471
3785
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3472
3786
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3473
3787
|
async loadSuppressed() {
|
|
@@ -3486,6 +3800,17 @@ var Chat = class {
|
|
|
3486
3800
|
} catch {
|
|
3487
3801
|
}
|
|
3488
3802
|
}
|
|
3803
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
3804
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
3805
|
+
async loadElevated() {
|
|
3806
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
3807
|
+
this.elevatedLoaded = true;
|
|
3808
|
+
try {
|
|
3809
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
3810
|
+
for (const k of keys) this.elevated.add(k);
|
|
3811
|
+
} catch {
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3489
3814
|
async hydrateHistory() {
|
|
3490
3815
|
if (this.historyLoaded || !this._group) return;
|
|
3491
3816
|
this.historyLoaded = true;
|
|
@@ -3513,11 +3838,16 @@ var Chat = class {
|
|
|
3513
3838
|
if (this.seenKeys.has(key)) continue;
|
|
3514
3839
|
this.seenKeys.add(key);
|
|
3515
3840
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3516
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
3841
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
3517
3842
|
}
|
|
3518
|
-
this.messageList.push(
|
|
3843
|
+
this.messageList.push(
|
|
3844
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
3845
|
+
);
|
|
3519
3846
|
changed = true;
|
|
3520
3847
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
3848
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
3849
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
3850
|
+
}
|
|
3521
3851
|
}
|
|
3522
3852
|
if (changed) {
|
|
3523
3853
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3532,6 +3862,7 @@ var Chat = class {
|
|
|
3532
3862
|
return;
|
|
3533
3863
|
}
|
|
3534
3864
|
if (incoming.serverSeq <= 0) return;
|
|
3865
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
3535
3866
|
const key = this.internalKey(incoming.serverSeq);
|
|
3536
3867
|
if (this.seenKeys.has(key)) return;
|
|
3537
3868
|
this.seenKeys.add(key);
|
|
@@ -3540,6 +3871,20 @@ var Chat = class {
|
|
|
3540
3871
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
3541
3872
|
}
|
|
3542
3873
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
3874
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
3875
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3876
|
+
if (actorUserId !== null) {
|
|
3877
|
+
this.timerFold.ingest({
|
|
3878
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
3879
|
+
start: incoming.timer.start,
|
|
3880
|
+
actorUserId,
|
|
3881
|
+
epoch: incoming.epoch,
|
|
3882
|
+
serverSeq: incoming.serverSeq,
|
|
3883
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
return;
|
|
3887
|
+
}
|
|
3543
3888
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
3544
3889
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3545
3890
|
if (actorUserId !== null) {
|
|
@@ -3565,7 +3910,10 @@ var Chat = class {
|
|
|
3565
3910
|
newText: incoming.edit.newText,
|
|
3566
3911
|
epoch: incoming.epoch,
|
|
3567
3912
|
serverSeq: incoming.serverSeq,
|
|
3568
|
-
eventClientMsgId: incoming.clientMsgId
|
|
3913
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
3914
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
3915
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
3916
|
+
bodyRanges: incoming.bodyRanges
|
|
3569
3917
|
},
|
|
3570
3918
|
this.authorOfTarget
|
|
3571
3919
|
);
|
|
@@ -3593,6 +3941,7 @@ var Chat = class {
|
|
|
3593
3941
|
if (incomingReplyRef) {
|
|
3594
3942
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3595
3943
|
}
|
|
3944
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3596
3945
|
const msg = {
|
|
3597
3946
|
id: this.publicId(incoming.serverSeq),
|
|
3598
3947
|
kind: this.kindOf(incoming),
|
|
@@ -3609,8 +3958,13 @@ var Chat = class {
|
|
|
3609
3958
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3610
3959
|
edited: false,
|
|
3611
3960
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3612
|
-
isDeleted: false
|
|
3961
|
+
isDeleted: false,
|
|
3962
|
+
mentions,
|
|
3963
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
3964
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
3965
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
3613
3966
|
};
|
|
3967
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3614
3968
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3615
3969
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3616
3970
|
text: incoming.text,
|
|
@@ -3620,7 +3974,10 @@ var Chat = class {
|
|
|
3620
3974
|
if (incomingClientMsgId) {
|
|
3621
3975
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3622
3976
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3623
|
-
this.deleteFold.reevaluatePending(
|
|
3977
|
+
this.deleteFold.reevaluatePending(
|
|
3978
|
+
incomingClientMsgId,
|
|
3979
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
3980
|
+
);
|
|
3624
3981
|
}
|
|
3625
3982
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3626
3983
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3629,11 +3986,200 @@ var Chat = class {
|
|
|
3629
3986
|
incoming.serverSeq
|
|
3630
3987
|
);
|
|
3631
3988
|
this.emit();
|
|
3989
|
+
void this.armPurge(
|
|
3990
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
3991
|
+
incoming.serverSeq,
|
|
3992
|
+
incomingClientMsgId
|
|
3993
|
+
);
|
|
3994
|
+
}
|
|
3995
|
+
// ── Disappearing (TTL — T10) ──
|
|
3996
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
3997
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
3998
|
+
toExpirySpec(e) {
|
|
3999
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
4000
|
+
}
|
|
4001
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
4002
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
4003
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
4004
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
4005
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
4006
|
+
defaultExpiry() {
|
|
4007
|
+
const active = this.timerFold.active();
|
|
4008
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
4009
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
4010
|
+
}
|
|
4011
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
4012
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
4013
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
4014
|
+
* UI countdown baseline. */
|
|
4015
|
+
deadlineFor(expiry) {
|
|
4016
|
+
if (!expiry) return null;
|
|
4017
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
4018
|
+
}
|
|
4019
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
4020
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
4021
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
4022
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
4023
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
4024
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
4025
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
4026
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
4027
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
4028
|
+
const group = this._group;
|
|
4029
|
+
const fresh = {
|
|
4030
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
4031
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4032
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
4033
|
+
};
|
|
4034
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
4035
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
4036
|
+
const result = remainingSeconds({
|
|
4037
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
4038
|
+
anchor: effective,
|
|
4039
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
4040
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4041
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
4042
|
+
});
|
|
4043
|
+
let purgeInSeconds;
|
|
4044
|
+
if (result.kind === "purgeNow") {
|
|
4045
|
+
purgeInSeconds = 0;
|
|
4046
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
4047
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
4048
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
4049
|
+
} else {
|
|
4050
|
+
purgeInSeconds = result.seconds;
|
|
4051
|
+
}
|
|
4052
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4053
|
+
if (prior) clearTimeout(prior);
|
|
4054
|
+
this.purgeTimers.delete(serverSeq);
|
|
4055
|
+
if (purgeInSeconds <= 0) {
|
|
4056
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
const handle = setTimeout(() => {
|
|
4060
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4061
|
+
}, purgeInSeconds * 1e3);
|
|
4062
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4063
|
+
}
|
|
4064
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
4065
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
4066
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
4067
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
4068
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
4069
|
+
if (!this._group) return;
|
|
4070
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
4071
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4072
|
+
if (prior) clearTimeout(prior);
|
|
4073
|
+
this.purgeTimers.delete(serverSeq);
|
|
4074
|
+
if (remainingMs <= 0) {
|
|
4075
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
const handle = setTimeout(() => {
|
|
4079
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4080
|
+
}, remainingMs);
|
|
4081
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4082
|
+
}
|
|
4083
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
4084
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
4085
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
4086
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
4087
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
4088
|
+
async purge(serverSeq, clientMsgId) {
|
|
4089
|
+
if (!this._group) return;
|
|
4090
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4091
|
+
if (prior) clearTimeout(prior);
|
|
4092
|
+
this.purgeTimers.delete(serverSeq);
|
|
4093
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
4094
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
4095
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
4096
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
4097
|
+
this.emit();
|
|
4098
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
4099
|
+
if (clientMsgId) {
|
|
4100
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
4104
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
4105
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
4106
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
4107
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
4108
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
4109
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
4110
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
4111
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
4112
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
4113
|
+
};
|
|
4114
|
+
// ── Mentions (mentions T6) ──
|
|
4115
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
4116
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
4117
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
4118
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
4119
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
4120
|
+
resolveMentions(text, bodyRanges) {
|
|
4121
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
4122
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
4123
|
+
if (normalized.length === 0) return [];
|
|
4124
|
+
return normalized.map((r) => ({
|
|
4125
|
+
start: r.start,
|
|
4126
|
+
length: r.length,
|
|
4127
|
+
mentionedUserId: r.mentionedUserId,
|
|
4128
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
4129
|
+
}));
|
|
4130
|
+
}
|
|
4131
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
4132
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
4133
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
4134
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
4135
|
+
resolveMentionNames(m) {
|
|
4136
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
4137
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
4138
|
+
}
|
|
4139
|
+
let changed = false;
|
|
4140
|
+
const reresolved = m.mentions.map((span) => {
|
|
4141
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
4142
|
+
if (name === span.displayName) return span;
|
|
4143
|
+
changed = true;
|
|
4144
|
+
return { ...span, displayName: name };
|
|
4145
|
+
});
|
|
4146
|
+
if (!changed) return m;
|
|
4147
|
+
return { ...m, mentions: reresolved };
|
|
4148
|
+
}
|
|
4149
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
4150
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
4151
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
4152
|
+
editMentions(targetClientMsgId, newText) {
|
|
4153
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
4154
|
+
if (!ranges) return [];
|
|
4155
|
+
return this.resolveMentions(newText, ranges);
|
|
4156
|
+
}
|
|
4157
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
4158
|
+
displayNameOf(userId) {
|
|
4159
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
4160
|
+
}
|
|
4161
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
4162
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
4163
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
4164
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
4165
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
4166
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
4167
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
4168
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
4169
|
+
const me = this.backend.selfUserId;
|
|
4170
|
+
if (envelopeType === "edit") return;
|
|
4171
|
+
if (senderUserId === me) return;
|
|
4172
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
4173
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4174
|
+
const key = `${me}|${idPart}`;
|
|
4175
|
+
if (this.elevated.has(key)) return;
|
|
4176
|
+
this.elevated.add(key);
|
|
4177
|
+
if (this._group) {
|
|
4178
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
4179
|
+
});
|
|
4180
|
+
}
|
|
4181
|
+
this.onMentionElevation?.(message);
|
|
3632
4182
|
}
|
|
3633
|
-
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3634
|
-
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3635
|
-
* so it can be passed to the pure EditFold. */
|
|
3636
|
-
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3637
4183
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3638
4184
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3639
4185
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3690,9 +4236,10 @@ var Chat = class {
|
|
|
3690
4236
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3691
4237
|
const text = editText ?? base;
|
|
3692
4238
|
const edited = foldEdited || m.edited;
|
|
3693
|
-
|
|
4239
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
4240
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3694
4241
|
changed = true;
|
|
3695
|
-
return { ...m, text, edited };
|
|
4242
|
+
return { ...m, text, edited, mentions };
|
|
3696
4243
|
});
|
|
3697
4244
|
if (changed) this.emit();
|
|
3698
4245
|
}
|
|
@@ -3709,8 +4256,9 @@ var Chat = class {
|
|
|
3709
4256
|
if (editText === null && !foldEdited) return m;
|
|
3710
4257
|
const text = editText ?? m.text;
|
|
3711
4258
|
const edited = foldEdited || m.edited;
|
|
3712
|
-
|
|
3713
|
-
|
|
4259
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
4260
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
4261
|
+
return { ...m, text, edited, mentions };
|
|
3714
4262
|
}
|
|
3715
4263
|
/** @internal — called by the backend's conv subscription. */
|
|
3716
4264
|
applyConv(event, payload) {
|
|
@@ -3768,6 +4316,18 @@ var Chat = class {
|
|
|
3768
4316
|
}
|
|
3769
4317
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3770
4318
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
4319
|
+
this.reresolveAllMentionNames();
|
|
4320
|
+
}
|
|
4321
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
4322
|
+
* change). Re-emits only if any name actually changed. */
|
|
4323
|
+
reresolveAllMentionNames() {
|
|
4324
|
+
let changed = false;
|
|
4325
|
+
this.messageList = this.messageList.map((m) => {
|
|
4326
|
+
const reresolved = this.resolveMentionNames(m);
|
|
4327
|
+
if (reresolved !== m) changed = true;
|
|
4328
|
+
return reresolved;
|
|
4329
|
+
});
|
|
4330
|
+
if (changed) this.emit();
|
|
3771
4331
|
}
|
|
3772
4332
|
seedMembersFromGroup(group) {
|
|
3773
4333
|
const seed = [
|
|
@@ -3835,11 +4395,50 @@ var Chat = class {
|
|
|
3835
4395
|
};
|
|
3836
4396
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3837
4397
|
}
|
|
3838
|
-
const
|
|
3839
|
-
|
|
4398
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4399
|
+
const start = opts?.expiresIn?.start ?? "send";
|
|
4400
|
+
const expiry = opts?.expiresIn ? {
|
|
4401
|
+
v: 1,
|
|
4402
|
+
ttlSeconds: opts.expiresIn.ttlSeconds,
|
|
4403
|
+
start,
|
|
4404
|
+
senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
4405
|
+
} : null;
|
|
4406
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
4407
|
+
group,
|
|
4408
|
+
text,
|
|
4409
|
+
replyRef,
|
|
4410
|
+
bodyRanges,
|
|
4411
|
+
expiry
|
|
4412
|
+
);
|
|
4413
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
4414
|
+
if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
|
|
3840
4415
|
return receipt;
|
|
3841
4416
|
}
|
|
3842
|
-
|
|
4417
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
4418
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
4419
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
4420
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
4421
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
4422
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
4423
|
+
async setDisappearing(opts) {
|
|
4424
|
+
const group = await this.materializeIfNeeded();
|
|
4425
|
+
const clientMsgId = mintClientMsgId();
|
|
4426
|
+
const start = opts.start ?? "send";
|
|
4427
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
4428
|
+
clientMsgId,
|
|
4429
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4430
|
+
start
|
|
4431
|
+
});
|
|
4432
|
+
this.timerFold.ingest({
|
|
4433
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4434
|
+
start,
|
|
4435
|
+
actorUserId: this.backend.selfUserId,
|
|
4436
|
+
epoch: receipt.epoch,
|
|
4437
|
+
serverSeq: receipt.serverSeq,
|
|
4438
|
+
eventClientMsgId: clientMsgId
|
|
4439
|
+
});
|
|
4440
|
+
}
|
|
4441
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
3843
4442
|
if (receipt.serverSeq <= 0) return;
|
|
3844
4443
|
const key = this.internalKey(receipt.serverSeq);
|
|
3845
4444
|
if (this.seenKeys.has(key)) return;
|
|
@@ -3864,7 +4463,13 @@ var Chat = class {
|
|
|
3864
4463
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3865
4464
|
edited: false,
|
|
3866
4465
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3867
|
-
isDeleted: false
|
|
4466
|
+
isDeleted: false,
|
|
4467
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4468
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4469
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
4470
|
+
// Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
|
|
4471
|
+
// via the messageList overlay; default null here (a plain own-send has no deadline).
|
|
4472
|
+
expiresAt: null
|
|
3868
4473
|
});
|
|
3869
4474
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3870
4475
|
this.emit();
|
|
@@ -3952,15 +4557,18 @@ var Chat = class {
|
|
|
3952
4557
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
3953
4558
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
3954
4559
|
* 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
|
-
|
|
4560
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4561
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4562
|
+
async edit(message, newText, opts) {
|
|
3957
4563
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
3958
4564
|
const group = await this.materializeIfNeeded();
|
|
3959
4565
|
const clientMsgId = mintClientMsgId();
|
|
4566
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
3960
4567
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
3961
4568
|
clientMsgId,
|
|
3962
4569
|
targetClientMsgId: message.clientMsgId,
|
|
3963
|
-
newText
|
|
4570
|
+
newText,
|
|
4571
|
+
bodyRanges
|
|
3964
4572
|
});
|
|
3965
4573
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3966
4574
|
this.editFold.ingest(
|
|
@@ -3970,7 +4578,8 @@ var Chat = class {
|
|
|
3970
4578
|
newText,
|
|
3971
4579
|
epoch: receipt.epoch,
|
|
3972
4580
|
serverSeq: receipt.serverSeq,
|
|
3973
|
-
eventClientMsgId: clientMsgId
|
|
4581
|
+
eventClientMsgId: clientMsgId,
|
|
4582
|
+
bodyRanges
|
|
3974
4583
|
},
|
|
3975
4584
|
this.authorOfTarget
|
|
3976
4585
|
);
|
|
@@ -4023,6 +4632,18 @@ var Chat = class {
|
|
|
4023
4632
|
}
|
|
4024
4633
|
}
|
|
4025
4634
|
};
|
|
4635
|
+
function sameMentions(a, b) {
|
|
4636
|
+
if (a.length !== b.length) return false;
|
|
4637
|
+
for (let i = 0; i < a.length; i++) {
|
|
4638
|
+
const x = a[i];
|
|
4639
|
+
const y = b[i];
|
|
4640
|
+
if (!x || !y) return false;
|
|
4641
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4642
|
+
return false;
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
return true;
|
|
4646
|
+
}
|
|
4026
4647
|
function sameReactions(a, b) {
|
|
4027
4648
|
const ak = Object.keys(a);
|
|
4028
4649
|
const bk = Object.keys(b);
|
|
@@ -4203,6 +4824,7 @@ var MessageDeliverySource = class {
|
|
|
4203
4824
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4204
4825
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4205
4826
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
4827
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
4206
4828
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4207
4829
|
const stored = {
|
|
4208
4830
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4234,14 +4856,21 @@ var MessageDeliverySource = class {
|
|
|
4234
4856
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4235
4857
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4236
4858
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4237
|
-
// `'text'`/no-edit (backward-compat).
|
|
4859
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
4860
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
4238
4861
|
...isEdit && decoded.edit ? {
|
|
4239
4862
|
envelopeType: "edit",
|
|
4240
4863
|
edit: {
|
|
4241
4864
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4242
|
-
newText: decoded.edit.newText
|
|
4865
|
+
newText: decoded.edit.newText,
|
|
4866
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4243
4867
|
}
|
|
4244
4868
|
} : {},
|
|
4869
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
4870
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
4871
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
4872
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
4873
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4245
4874
|
// Thread the delete discriminator + target through the persisted row so a
|
|
4246
4875
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4247
4876
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -4253,7 +4882,18 @@ var MessageDeliverySource = class {
|
|
|
4253
4882
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4254
4883
|
scope: decoded.delete.scope
|
|
4255
4884
|
}
|
|
4256
|
-
} : {}
|
|
4885
|
+
} : {},
|
|
4886
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
4887
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
4888
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
4889
|
+
...isTimerSet && decoded.timer ? {
|
|
4890
|
+
envelopeType: "timer_set",
|
|
4891
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
4892
|
+
} : {},
|
|
4893
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
4894
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
4895
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
4896
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
4257
4897
|
};
|
|
4258
4898
|
try {
|
|
4259
4899
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -4273,7 +4913,15 @@ var MessageDeliverySource = class {
|
|
|
4273
4913
|
envelopeType: decoded.type ?? "text",
|
|
4274
4914
|
reaction: isReaction ? decoded.reaction : null,
|
|
4275
4915
|
edit: isEdit ? decoded.edit : null,
|
|
4276
|
-
delete: isDelete ? decoded.delete : null
|
|
4916
|
+
delete: isDelete ? decoded.delete : null,
|
|
4917
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
4918
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
4919
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
4920
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
4921
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
4922
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
4923
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
4924
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
4277
4925
|
});
|
|
4278
4926
|
return true;
|
|
4279
4927
|
}
|
|
@@ -4352,6 +5000,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
4352
5000
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
4353
5001
|
}
|
|
4354
5002
|
|
|
5003
|
+
// src/messaging/disappearing.ts
|
|
5004
|
+
var DisappearingStore = class {
|
|
5005
|
+
constructor(kv) {
|
|
5006
|
+
this.kv = kv;
|
|
5007
|
+
}
|
|
5008
|
+
kv;
|
|
5009
|
+
key(rfc) {
|
|
5010
|
+
return `disappear:${rfc}`;
|
|
5011
|
+
}
|
|
5012
|
+
async load(rfc) {
|
|
5013
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
5014
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5015
|
+
try {
|
|
5016
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
5017
|
+
return {
|
|
5018
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
5019
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
5020
|
+
anchors: r.anchors ?? {}
|
|
5021
|
+
};
|
|
5022
|
+
} catch {
|
|
5023
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5024
|
+
}
|
|
5025
|
+
}
|
|
5026
|
+
async save(rfc, rec) {
|
|
5027
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
5028
|
+
}
|
|
5029
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
5030
|
+
async tombstonedSeqs(rfc) {
|
|
5031
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
5032
|
+
}
|
|
5033
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
5034
|
+
async purgedClientMsgIds(rfc) {
|
|
5035
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
5036
|
+
}
|
|
5037
|
+
/**
|
|
5038
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
5039
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
5040
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
5041
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
5042
|
+
*/
|
|
5043
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
5044
|
+
const rec = await this.load(rfc);
|
|
5045
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
5046
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
5047
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
5048
|
+
}
|
|
5049
|
+
await this.save(rfc, rec);
|
|
5050
|
+
}
|
|
5051
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
5052
|
+
async anchor(rfc, clientMsgId) {
|
|
5053
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
5054
|
+
}
|
|
5055
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
5056
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
5057
|
+
const rec = await this.load(rfc);
|
|
5058
|
+
if (rec.anchors[clientMsgId]) return;
|
|
5059
|
+
rec.anchors[clientMsgId] = a;
|
|
5060
|
+
await this.save(rfc, rec);
|
|
5061
|
+
}
|
|
5062
|
+
};
|
|
5063
|
+
|
|
4355
5064
|
// src/messaging/history.ts
|
|
4356
5065
|
var MessageStore = class {
|
|
4357
5066
|
constructor(kv) {
|
|
@@ -4428,6 +5137,36 @@ var GroupCatalog = class {
|
|
|
4428
5137
|
}
|
|
4429
5138
|
};
|
|
4430
5139
|
|
|
5140
|
+
// src/messaging/mention-elevation.ts
|
|
5141
|
+
var MentionElevationStore = class {
|
|
5142
|
+
constructor(kv) {
|
|
5143
|
+
this.kv = kv;
|
|
5144
|
+
}
|
|
5145
|
+
kv;
|
|
5146
|
+
key(rfcGroupId) {
|
|
5147
|
+
return `elev:${rfcGroupId}`;
|
|
5148
|
+
}
|
|
5149
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
5150
|
+
async load(rfcGroupId) {
|
|
5151
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
5152
|
+
if (!raw) return [];
|
|
5153
|
+
try {
|
|
5154
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
5155
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5156
|
+
} catch {
|
|
5157
|
+
return [];
|
|
5158
|
+
}
|
|
5159
|
+
}
|
|
5160
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
5161
|
+
async save(rfcGroupId, keys) {
|
|
5162
|
+
const sorted = [...new Set(keys)].sort();
|
|
5163
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
5164
|
+
}
|
|
5165
|
+
async wipe() {
|
|
5166
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
5167
|
+
}
|
|
5168
|
+
};
|
|
5169
|
+
|
|
4431
5170
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4432
5171
|
var palbe_mls_bg_exports = {};
|
|
4433
5172
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6135,6 +6874,8 @@ var MessagingCoordinator = class {
|
|
|
6135
6874
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6136
6875
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6137
6876
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
6877
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
6878
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
6138
6879
|
this.registry.attachChatList(
|
|
6139
6880
|
(chats) => {
|
|
6140
6881
|
this.chatList = chats;
|
|
@@ -6150,6 +6891,8 @@ var MessagingCoordinator = class {
|
|
|
6150
6891
|
groupStore;
|
|
6151
6892
|
kpStore;
|
|
6152
6893
|
suppressionStore;
|
|
6894
|
+
elevationStore;
|
|
6895
|
+
disappearingStore;
|
|
6153
6896
|
registry = new GroupRegistry();
|
|
6154
6897
|
resolved = null;
|
|
6155
6898
|
resolvePromise = null;
|
|
@@ -6305,9 +7048,9 @@ var MessagingCoordinator = class {
|
|
|
6305
7048
|
});
|
|
6306
7049
|
return group;
|
|
6307
7050
|
}
|
|
6308
|
-
async sendText(group, text, replyTo) {
|
|
7051
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6309
7052
|
const r = await this.resolve();
|
|
6310
|
-
return r.groups.sendText(group, text, replyTo);
|
|
7053
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6311
7054
|
}
|
|
6312
7055
|
async sendReaction(group, args) {
|
|
6313
7056
|
const r = await this.resolve();
|
|
@@ -6321,6 +7064,10 @@ var MessagingCoordinator = class {
|
|
|
6321
7064
|
const r = await this.resolve();
|
|
6322
7065
|
return r.groups.sendDelete(group, args);
|
|
6323
7066
|
}
|
|
7067
|
+
async sendTimerSet(group, args) {
|
|
7068
|
+
const r = await this.resolve();
|
|
7069
|
+
return r.groups.sendTimerSet(group, args);
|
|
7070
|
+
}
|
|
6324
7071
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6325
7072
|
loadSuppressed(group) {
|
|
6326
7073
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -6329,10 +7076,36 @@ var MessagingCoordinator = class {
|
|
|
6329
7076
|
saveSuppressed(group, keys) {
|
|
6330
7077
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6331
7078
|
}
|
|
7079
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
7080
|
+
loadElevated(group) {
|
|
7081
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
7082
|
+
}
|
|
7083
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
7084
|
+
saveElevated(group, keys) {
|
|
7085
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
7086
|
+
}
|
|
7087
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
7088
|
+
tombstonedSeqs(group) {
|
|
7089
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7090
|
+
}
|
|
7091
|
+
purgedClientMsgIds(group) {
|
|
7092
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7093
|
+
}
|
|
7094
|
+
anchor(group, clientMsgId) {
|
|
7095
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
7096
|
+
}
|
|
7097
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
7098
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
7099
|
+
}
|
|
7100
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
7101
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
7102
|
+
}
|
|
6332
7103
|
async history(group, limit, before) {
|
|
6333
7104
|
const r = await this.resolve();
|
|
6334
7105
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
6335
|
-
|
|
7106
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7107
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7108
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
6336
7109
|
}
|
|
6337
7110
|
async members(group) {
|
|
6338
7111
|
const r = await this.resolve();
|
|
@@ -6409,9 +7182,10 @@ var MessagingCoordinator = class {
|
|
|
6409
7182
|
return res.devices.map((d) => d.device_id);
|
|
6410
7183
|
}
|
|
6411
7184
|
};
|
|
6412
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
7185
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
7186
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
6413
7187
|
const fold = new ReactionFold();
|
|
6414
|
-
for (const s of
|
|
7188
|
+
for (const s of visible) {
|
|
6415
7189
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
6416
7190
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6417
7191
|
if (actor === null) continue;
|
|
@@ -6427,17 +7201,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6427
7201
|
}
|
|
6428
7202
|
const editFold = new EditFold();
|
|
6429
7203
|
const deleteFold = new DeleteFold();
|
|
7204
|
+
const pageTimerFold = new TimerFold();
|
|
6430
7205
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6431
|
-
for (const s of
|
|
6432
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7206
|
+
for (const s of visible) {
|
|
7207
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6433
7208
|
continue;
|
|
6434
7209
|
const cid = s.clientMsgId ?? "";
|
|
6435
7210
|
if (!cid) continue;
|
|
6436
7211
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6437
7212
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6438
7213
|
}
|
|
6439
|
-
const authorOfTarget = (cid) =>
|
|
6440
|
-
|
|
7214
|
+
const authorOfTarget = (cid) => {
|
|
7215
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
7216
|
+
const a = authorByClientMsgId.get(cid);
|
|
7217
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
7218
|
+
};
|
|
7219
|
+
for (const s of visible) {
|
|
6441
7220
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6442
7221
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6443
7222
|
editFold.ingest(
|
|
@@ -6447,13 +7226,16 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6447
7226
|
newText: s.edit.newText,
|
|
6448
7227
|
epoch: s.epoch,
|
|
6449
7228
|
serverSeq: s.serverSeq,
|
|
6450
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
7229
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
7230
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
7231
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
7232
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6451
7233
|
},
|
|
6452
7234
|
authorOfTarget
|
|
6453
7235
|
);
|
|
6454
7236
|
}
|
|
6455
7237
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6456
|
-
for (const s of
|
|
7238
|
+
for (const s of visible) {
|
|
6457
7239
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6458
7240
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6459
7241
|
deleteFold.ingest(
|
|
@@ -6467,10 +7249,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6467
7249
|
authorOfTarget
|
|
6468
7250
|
);
|
|
6469
7251
|
}
|
|
6470
|
-
for (const [cid
|
|
7252
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
6471
7253
|
const lookup = /* @__PURE__ */ new Map();
|
|
6472
|
-
for (const s of
|
|
6473
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7254
|
+
for (const s of visible) {
|
|
7255
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6474
7256
|
continue;
|
|
6475
7257
|
const cid = s.clientMsgId ?? "";
|
|
6476
7258
|
if (cid && s.text !== null) {
|
|
@@ -6479,7 +7261,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6479
7261
|
}
|
|
6480
7262
|
}
|
|
6481
7263
|
const out = [];
|
|
6482
|
-
for (const s of
|
|
7264
|
+
for (const s of visible) {
|
|
7265
|
+
if (s.envelopeType === "timer_set") {
|
|
7266
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
7267
|
+
if (actor !== null && s.timer) {
|
|
7268
|
+
pageTimerFold.ingest({
|
|
7269
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
7270
|
+
start: s.timer.start,
|
|
7271
|
+
actorUserId: actor,
|
|
7272
|
+
epoch: s.epoch,
|
|
7273
|
+
serverSeq: s.serverSeq,
|
|
7274
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
7275
|
+
});
|
|
7276
|
+
}
|
|
7277
|
+
continue;
|
|
7278
|
+
}
|
|
6483
7279
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6484
7280
|
continue;
|
|
6485
7281
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -6497,10 +7293,23 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6497
7293
|
replyTo: null,
|
|
6498
7294
|
reactions: {},
|
|
6499
7295
|
edited: false,
|
|
6500
|
-
isDeleted: true
|
|
7296
|
+
isDeleted: true,
|
|
7297
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
7298
|
+
mentions: [],
|
|
7299
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
7300
|
+
expiresAt: null
|
|
6501
7301
|
});
|
|
6502
7302
|
continue;
|
|
6503
7303
|
}
|
|
7304
|
+
let expiresAt = null;
|
|
7305
|
+
if (s.expiry) {
|
|
7306
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
7307
|
+
} else {
|
|
7308
|
+
const active = pageTimerFold.active();
|
|
7309
|
+
if (active && active.ttlSeconds !== null) {
|
|
7310
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
7311
|
+
}
|
|
7312
|
+
}
|
|
6504
7313
|
let replyTo = null;
|
|
6505
7314
|
if (s.replyTo) {
|
|
6506
7315
|
const ref = {
|
|
@@ -6517,23 +7326,37 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6517
7326
|
}
|
|
6518
7327
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6519
7328
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
7329
|
+
const text = editText ?? s.text;
|
|
7330
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
7331
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6520
7332
|
out.push({
|
|
6521
7333
|
id: `${displayId}#${s.serverSeq}`,
|
|
6522
7334
|
kind: s.text != null ? "text" : "system",
|
|
6523
7335
|
direction: s.direction,
|
|
6524
7336
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6525
|
-
text
|
|
7337
|
+
text,
|
|
6526
7338
|
serverSeq: s.serverSeq,
|
|
6527
7339
|
sentAt: new Date(s.at),
|
|
6528
7340
|
clientMsgId,
|
|
6529
7341
|
replyTo,
|
|
6530
7342
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6531
7343
|
edited,
|
|
6532
|
-
isDeleted: false
|
|
7344
|
+
isDeleted: false,
|
|
7345
|
+
mentions,
|
|
7346
|
+
expiresAt
|
|
6533
7347
|
});
|
|
6534
7348
|
}
|
|
6535
7349
|
return out;
|
|
6536
7350
|
}
|
|
7351
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
7352
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
7353
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
7354
|
+
start: r.start,
|
|
7355
|
+
length: r.length,
|
|
7356
|
+
mentionedUserId: r.mentionedUserId,
|
|
7357
|
+
displayName: null
|
|
7358
|
+
}));
|
|
7359
|
+
}
|
|
6537
7360
|
|
|
6538
7361
|
// src/messaging/facade.ts
|
|
6539
7362
|
var PalbeMessaging = class {
|
|
@@ -7267,7 +8090,7 @@ function defaultSessionStorage(key) {
|
|
|
7267
8090
|
}
|
|
7268
8091
|
|
|
7269
8092
|
// src/version.ts
|
|
7270
|
-
var VERSION = "1.
|
|
8093
|
+
var VERSION = "1.6.0";
|
|
7271
8094
|
|
|
7272
8095
|
// src/runtime.ts
|
|
7273
8096
|
function buildRuntime(config) {
|