@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/index.cjs
CHANGED
|
@@ -2628,6 +2628,47 @@ var PalbeFlags = class {
|
|
|
2628
2628
|
}
|
|
2629
2629
|
};
|
|
2630
2630
|
|
|
2631
|
+
// src/messaging/deadline-calculator.ts
|
|
2632
|
+
function remainingSeconds(args) {
|
|
2633
|
+
const ttl = args.ttlSeconds;
|
|
2634
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
2635
|
+
let elapsed;
|
|
2636
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
2637
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
2638
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
2639
|
+
} else {
|
|
2640
|
+
elapsed = wallDeltaSec;
|
|
2641
|
+
}
|
|
2642
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
2643
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
2644
|
+
}
|
|
2645
|
+
var cachedBootToken = null;
|
|
2646
|
+
var MonotonicClock = {
|
|
2647
|
+
nowMs() {
|
|
2648
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2649
|
+
},
|
|
2650
|
+
nowWallEpochMs() {
|
|
2651
|
+
return Date.now();
|
|
2652
|
+
},
|
|
2653
|
+
bootToken() {
|
|
2654
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
2655
|
+
try {
|
|
2656
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
2657
|
+
if (existing) {
|
|
2658
|
+
cachedBootToken = existing;
|
|
2659
|
+
return existing;
|
|
2660
|
+
}
|
|
2661
|
+
const fresh = crypto.randomUUID();
|
|
2662
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
2663
|
+
cachedBootToken = fresh;
|
|
2664
|
+
return fresh;
|
|
2665
|
+
} catch {
|
|
2666
|
+
cachedBootToken = crypto.randomUUID();
|
|
2667
|
+
return cachedBootToken;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
|
|
2631
2672
|
// src/messaging/delete-fold.ts
|
|
2632
2673
|
var DeleteFold = class {
|
|
2633
2674
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -2640,17 +2681,24 @@ var DeleteFold = class {
|
|
|
2640
2681
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2641
2682
|
held = [];
|
|
2642
2683
|
/**
|
|
2643
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
2644
|
-
*
|
|
2684
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
2685
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
2686
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
2687
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
2688
|
+
* park in pending, never re-attempt).
|
|
2645
2689
|
*/
|
|
2646
2690
|
ingest(e, authorOfTarget) {
|
|
2647
2691
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2648
2692
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2649
2693
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2650
|
-
const
|
|
2651
|
-
if (
|
|
2694
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2695
|
+
if (res.kind === "purged") {
|
|
2652
2696
|
this.seen.add(e.eventClientMsgId);
|
|
2653
|
-
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
if (res.kind === "author") {
|
|
2700
|
+
this.seen.add(e.eventClientMsgId);
|
|
2701
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
2654
2702
|
this.tombstoned.add(e.targetClientMsgId);
|
|
2655
2703
|
} else if (e.actorUserId !== null) {
|
|
2656
2704
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -2670,12 +2718,13 @@ var DeleteFold = class {
|
|
|
2670
2718
|
* the in-order path.
|
|
2671
2719
|
*/
|
|
2672
2720
|
reevaluatePending(target, author) {
|
|
2721
|
+
const res = author;
|
|
2673
2722
|
const actor = this.pending.get(target);
|
|
2674
2723
|
if (actor !== void 0) {
|
|
2675
|
-
if (
|
|
2676
|
-
this.tombstoned.add(target);
|
|
2724
|
+
if (res.kind === "author") {
|
|
2725
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
2677
2726
|
this.pending.delete(target);
|
|
2678
|
-
} else if (
|
|
2727
|
+
} else if (res.kind === "purged") {
|
|
2679
2728
|
this.pending.delete(target);
|
|
2680
2729
|
}
|
|
2681
2730
|
}
|
|
@@ -2683,7 +2732,7 @@ var DeleteFold = class {
|
|
|
2683
2732
|
const pendingHeld = this.held;
|
|
2684
2733
|
this.held = [];
|
|
2685
2734
|
for (const e of pendingHeld) {
|
|
2686
|
-
this.ingest(e, (t) => t === target ?
|
|
2735
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
2687
2736
|
}
|
|
2688
2737
|
}
|
|
2689
2738
|
heldContains(eventClientMsgId) {
|
|
@@ -2709,15 +2758,23 @@ var EditFold = class {
|
|
|
2709
2758
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
2710
2759
|
editedTargets = /* @__PURE__ */ new Set();
|
|
2711
2760
|
/**
|
|
2712
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
2713
|
-
*
|
|
2761
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
2762
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
2763
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
2764
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
2765
|
+
* and a later author "resolution" cannot resurrect it).
|
|
2714
2766
|
*/
|
|
2715
2767
|
ingest(e, authorOfTarget) {
|
|
2716
|
-
const
|
|
2717
|
-
if (
|
|
2768
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2769
|
+
if (res.kind === "unknown") {
|
|
2718
2770
|
this.holdIfNew(e);
|
|
2719
2771
|
return;
|
|
2720
2772
|
}
|
|
2773
|
+
if (res.kind === "purged") {
|
|
2774
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
const author = res.userId;
|
|
2721
2778
|
if (e.editorUserId === null) {
|
|
2722
2779
|
this.holdIfNew(e);
|
|
2723
2780
|
return;
|
|
@@ -2736,7 +2793,8 @@ var EditFold = class {
|
|
|
2736
2793
|
orderEpoch: e.epoch,
|
|
2737
2794
|
orderSeq: e.serverSeq,
|
|
2738
2795
|
lastEventId: e.eventClientMsgId,
|
|
2739
|
-
text: e.newText
|
|
2796
|
+
text: e.newText,
|
|
2797
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2740
2798
|
});
|
|
2741
2799
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2742
2800
|
}
|
|
@@ -2757,6 +2815,15 @@ var EditFold = class {
|
|
|
2757
2815
|
isEdited(targetClientMsgId) {
|
|
2758
2816
|
return this.editedTargets.has(targetClientMsgId);
|
|
2759
2817
|
}
|
|
2818
|
+
/**
|
|
2819
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2820
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2821
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2822
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2823
|
+
*/
|
|
2824
|
+
bodyRanges(targetClientMsgId) {
|
|
2825
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2826
|
+
}
|
|
2760
2827
|
/**
|
|
2761
2828
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2762
2829
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2930,7 +2997,14 @@ function encodeEdit(args) {
|
|
|
2930
2997
|
type: "edit",
|
|
2931
2998
|
client_msg_id: args.clientMsgId,
|
|
2932
2999
|
target_client_msg_id: args.targetClientMsgId,
|
|
2933
|
-
new_text: args.newText
|
|
3000
|
+
new_text: args.newText,
|
|
3001
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
3002
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
3003
|
+
start: r.start,
|
|
3004
|
+
length: r.length,
|
|
3005
|
+
mentioned_user_id: r.mentionedUserId
|
|
3006
|
+
}))
|
|
3007
|
+
} : {}
|
|
2934
3008
|
})
|
|
2935
3009
|
);
|
|
2936
3010
|
}
|
|
@@ -2952,14 +3026,53 @@ function encodeEnvelope(args) {
|
|
|
2952
3026
|
type: "text",
|
|
2953
3027
|
client_msg_id: args.clientMsgId,
|
|
2954
3028
|
text: args.text,
|
|
2955
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
3029
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
3030
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
3031
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
3032
|
+
start: r.start,
|
|
3033
|
+
length: r.length,
|
|
3034
|
+
mentioned_user_id: r.mentionedUserId
|
|
3035
|
+
}))
|
|
3036
|
+
} : {},
|
|
3037
|
+
...args.expiry ? {
|
|
3038
|
+
expiry: {
|
|
3039
|
+
v: args.expiry.v,
|
|
3040
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
3041
|
+
start: args.expiry.start,
|
|
3042
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
3043
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
3044
|
+
}
|
|
3045
|
+
} : {}
|
|
2956
3046
|
};
|
|
2957
3047
|
return encodeUtf8(JSON.stringify(env));
|
|
2958
3048
|
}
|
|
3049
|
+
function encodeTimerSet(args) {
|
|
3050
|
+
return encodeUtf8(
|
|
3051
|
+
JSON.stringify({
|
|
3052
|
+
v: 1,
|
|
3053
|
+
type: "timer_set",
|
|
3054
|
+
client_msg_id: args.clientMsgId,
|
|
3055
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
3056
|
+
start: args.start
|
|
3057
|
+
})
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
2959
3060
|
function decodeEnvelope(bytes) {
|
|
2960
3061
|
const s = decodeUtf8(bytes);
|
|
2961
3062
|
try {
|
|
2962
3063
|
const o = JSON.parse(s);
|
|
3064
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
3065
|
+
return {
|
|
3066
|
+
type: "timer_set",
|
|
3067
|
+
text: null,
|
|
3068
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
3069
|
+
replyTo: null,
|
|
3070
|
+
timer: {
|
|
3071
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
3072
|
+
start: o.start === "read" ? "read" : "send"
|
|
3073
|
+
}
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
2963
3076
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2964
3077
|
return {
|
|
2965
3078
|
type: "delete",
|
|
@@ -2986,6 +3099,7 @@ function decodeEnvelope(bytes) {
|
|
|
2986
3099
|
};
|
|
2987
3100
|
}
|
|
2988
3101
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
3102
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2989
3103
|
return {
|
|
2990
3104
|
type: "edit",
|
|
2991
3105
|
text: null,
|
|
@@ -2994,15 +3108,20 @@ function decodeEnvelope(bytes) {
|
|
|
2994
3108
|
edit: {
|
|
2995
3109
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2996
3110
|
newText: o.new_text ?? ""
|
|
2997
|
-
}
|
|
3111
|
+
},
|
|
3112
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2998
3113
|
};
|
|
2999
3114
|
}
|
|
3000
3115
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
3116
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
3117
|
+
const expiry = decodeExpiry(o.expiry);
|
|
3001
3118
|
return {
|
|
3002
3119
|
type: "text",
|
|
3003
3120
|
text: o.text ?? null,
|
|
3004
3121
|
clientMsgId: o.client_msg_id ?? "",
|
|
3005
|
-
replyTo: o.reply_to ?? null
|
|
3122
|
+
replyTo: o.reply_to ?? null,
|
|
3123
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
3124
|
+
...expiry ? { expiry } : {}
|
|
3006
3125
|
};
|
|
3007
3126
|
}
|
|
3008
3127
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -3014,6 +3133,27 @@ function decodeEnvelope(bytes) {
|
|
|
3014
3133
|
}
|
|
3015
3134
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
3016
3135
|
}
|
|
3136
|
+
function decodeExpiry(raw) {
|
|
3137
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
3138
|
+
const o = raw;
|
|
3139
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
3140
|
+
const start = o.start === "read" ? "read" : "send";
|
|
3141
|
+
return {
|
|
3142
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
3143
|
+
ttlSeconds: o.ttl_seconds,
|
|
3144
|
+
start,
|
|
3145
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
3146
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
3147
|
+
};
|
|
3148
|
+
}
|
|
3149
|
+
function decodeBodyRanges(raw) {
|
|
3150
|
+
if (!raw || raw.length === 0) return void 0;
|
|
3151
|
+
return raw.map((r) => ({
|
|
3152
|
+
start: r.start,
|
|
3153
|
+
length: r.length,
|
|
3154
|
+
mentionedUserId: r.mentioned_user_id
|
|
3155
|
+
}));
|
|
3156
|
+
}
|
|
3017
3157
|
function resolveReply(ref, lookup) {
|
|
3018
3158
|
const parent = lookup(ref.client_msg_id);
|
|
3019
3159
|
if (parent !== null) {
|
|
@@ -3225,9 +3365,9 @@ var GroupMessaging = class {
|
|
|
3225
3365
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3226
3366
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3227
3367
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3228
|
-
async sendText(group, text, replyTo) {
|
|
3368
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
3229
3369
|
const clientMsgId = mintClientMsgId();
|
|
3230
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3370
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
3231
3371
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3232
3372
|
const body = {
|
|
3233
3373
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3253,7 +3393,13 @@ var GroupMessaging = class {
|
|
|
3253
3393
|
previewBody: replyTo.preview?.body ?? null,
|
|
3254
3394
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3255
3395
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3256
|
-
} : null
|
|
3396
|
+
} : null,
|
|
3397
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3398
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3399
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
3400
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
3401
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
3402
|
+
...expiry ? { expiry } : {}
|
|
3257
3403
|
};
|
|
3258
3404
|
try {
|
|
3259
3405
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3261,6 +3407,51 @@ var GroupMessaging = class {
|
|
|
3261
3407
|
}
|
|
3262
3408
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
3263
3409
|
}
|
|
3410
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
3411
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
3412
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
3413
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
3414
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
3415
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
3416
|
+
async sendTimerSet(group, args) {
|
|
3417
|
+
const plaintext = encodeTimerSet({
|
|
3418
|
+
clientMsgId: args.clientMsgId,
|
|
3419
|
+
ttlSeconds: args.ttlSeconds,
|
|
3420
|
+
start: args.start
|
|
3421
|
+
});
|
|
3422
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3423
|
+
const body = {
|
|
3424
|
+
ciphertext_b64: toBase64(ct),
|
|
3425
|
+
client_idem_key: randomId()
|
|
3426
|
+
};
|
|
3427
|
+
const wire = await palbeRequest(
|
|
3428
|
+
this.rt,
|
|
3429
|
+
"POST",
|
|
3430
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3431
|
+
{ body }
|
|
3432
|
+
);
|
|
3433
|
+
const stored = {
|
|
3434
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3435
|
+
direction: "outgoing",
|
|
3436
|
+
text: null,
|
|
3437
|
+
senderDeviceId: this.selfDeviceId,
|
|
3438
|
+
epoch: wire.epoch,
|
|
3439
|
+
serverSeq: wire.server_seq,
|
|
3440
|
+
at: Date.now(),
|
|
3441
|
+
clientMsgId: args.clientMsgId,
|
|
3442
|
+
replyTo: null,
|
|
3443
|
+
envelopeType: "timer_set",
|
|
3444
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
3445
|
+
};
|
|
3446
|
+
try {
|
|
3447
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3448
|
+
} catch {
|
|
3449
|
+
}
|
|
3450
|
+
return {
|
|
3451
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3452
|
+
clientMsgId: args.clientMsgId
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3264
3455
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
3265
3456
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
3266
3457
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -3321,7 +3512,8 @@ var GroupMessaging = class {
|
|
|
3321
3512
|
const plaintext = encodeEdit({
|
|
3322
3513
|
clientMsgId: args.clientMsgId,
|
|
3323
3514
|
targetClientMsgId: args.targetClientMsgId,
|
|
3324
|
-
newText: args.newText
|
|
3515
|
+
newText: args.newText,
|
|
3516
|
+
bodyRanges: args.bodyRanges
|
|
3325
3517
|
});
|
|
3326
3518
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3327
3519
|
const body = {
|
|
@@ -3347,7 +3539,10 @@ var GroupMessaging = class {
|
|
|
3347
3539
|
envelopeType: "edit",
|
|
3348
3540
|
edit: {
|
|
3349
3541
|
targetClientMsgId: args.targetClientMsgId,
|
|
3350
|
-
newText: args.newText
|
|
3542
|
+
newText: args.newText,
|
|
3543
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3544
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3545
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3351
3546
|
}
|
|
3352
3547
|
};
|
|
3353
3548
|
try {
|
|
@@ -3469,6 +3664,41 @@ var GroupMessaging = class {
|
|
|
3469
3664
|
}
|
|
3470
3665
|
};
|
|
3471
3666
|
|
|
3667
|
+
// src/messaging/mention-ranges.ts
|
|
3668
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3669
|
+
const n = text.length;
|
|
3670
|
+
function splitsSurrogatePair(index) {
|
|
3671
|
+
if (index <= 0 || index >= n) return false;
|
|
3672
|
+
const before = text.charCodeAt(index - 1);
|
|
3673
|
+
const at = text.charCodeAt(index);
|
|
3674
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3675
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3676
|
+
return beforeIsHigh && atIsLow;
|
|
3677
|
+
}
|
|
3678
|
+
const survivors = [];
|
|
3679
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3680
|
+
const r = ranges[idx];
|
|
3681
|
+
if (r === void 0) continue;
|
|
3682
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3683
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3684
|
+
survivors.push({ idx, range: r });
|
|
3685
|
+
}
|
|
3686
|
+
survivors.sort((lhs, rhs) => {
|
|
3687
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3688
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3689
|
+
return lhs.idx - rhs.idx;
|
|
3690
|
+
});
|
|
3691
|
+
const kept = [];
|
|
3692
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3693
|
+
for (const s of survivors) {
|
|
3694
|
+
if (s.range.start >= prevEnd) {
|
|
3695
|
+
kept.push(s.range);
|
|
3696
|
+
prevEnd = s.range.start + s.range.length;
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
return kept;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3472
3702
|
// src/messaging/reaction-fold.ts
|
|
3473
3703
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3474
3704
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3524,6 +3754,43 @@ var ReactionFold = class {
|
|
|
3524
3754
|
}
|
|
3525
3755
|
};
|
|
3526
3756
|
|
|
3757
|
+
// src/messaging/timer-fold.ts
|
|
3758
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3759
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
3760
|
+
return aSeq <= bSeq;
|
|
3761
|
+
}
|
|
3762
|
+
var TimerFold = class {
|
|
3763
|
+
cell = null;
|
|
3764
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
3765
|
+
ingest(e) {
|
|
3766
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
3767
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
3768
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
3769
|
+
return;
|
|
3770
|
+
}
|
|
3771
|
+
this.cell = {
|
|
3772
|
+
orderEpoch: e.epoch,
|
|
3773
|
+
orderSeq: e.serverSeq,
|
|
3774
|
+
ttlSeconds: e.ttlSeconds,
|
|
3775
|
+
start: e.start,
|
|
3776
|
+
actor: e.actorUserId
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3779
|
+
/**
|
|
3780
|
+
* The active chat default, or null if no timer_set has applied.
|
|
3781
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
3782
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
3783
|
+
*/
|
|
3784
|
+
active() {
|
|
3785
|
+
if (this.cell === null) return null;
|
|
3786
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
3787
|
+
}
|
|
3788
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
3789
|
+
lastActor() {
|
|
3790
|
+
return this.cell?.actor ?? null;
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
|
|
3527
3794
|
// src/messaging/chat.ts
|
|
3528
3795
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3529
3796
|
var Chat = class {
|
|
@@ -3550,12 +3817,35 @@ var Chat = class {
|
|
|
3550
3817
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3551
3818
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3552
3819
|
deleteFold = new DeleteFold();
|
|
3820
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
3821
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
3822
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
3823
|
+
* no per-message expiry (disappearing T10). */
|
|
3824
|
+
timerFold = new TimerFold();
|
|
3825
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
3826
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
3827
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
3828
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
3829
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
3830
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
3831
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
3832
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
3833
|
+
* tombstone (disappearing T10). */
|
|
3834
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
3835
|
+
purgedLoaded = false;
|
|
3553
3836
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3554
3837
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3555
3838
|
suppressed = /* @__PURE__ */ new Set();
|
|
3556
3839
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3557
3840
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3558
3841
|
suppressedLoaded = false;
|
|
3842
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3843
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3844
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3845
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3846
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3847
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3848
|
+
elevatedLoaded = false;
|
|
3559
3849
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3560
3850
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3561
3851
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3567,6 +3857,14 @@ var Chat = class {
|
|
|
3567
3857
|
wired = false;
|
|
3568
3858
|
liveUnsub = null;
|
|
3569
3859
|
listeners = /* @__PURE__ */ new Set();
|
|
3860
|
+
/**
|
|
3861
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3862
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3863
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3864
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3865
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3866
|
+
*/
|
|
3867
|
+
onMentionElevation;
|
|
3570
3868
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3571
3869
|
constructor(args) {
|
|
3572
3870
|
this.backend = args.backend;
|
|
@@ -3644,7 +3942,10 @@ var Chat = class {
|
|
|
3644
3942
|
reactions: {},
|
|
3645
3943
|
replyTo: null,
|
|
3646
3944
|
edited: false,
|
|
3647
|
-
isDeleted: true
|
|
3945
|
+
isDeleted: true,
|
|
3946
|
+
mentions: [],
|
|
3947
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
3948
|
+
expiresAt: null
|
|
3648
3949
|
});
|
|
3649
3950
|
continue;
|
|
3650
3951
|
}
|
|
@@ -3680,9 +3981,22 @@ var Chat = class {
|
|
|
3680
3981
|
this.wired = true;
|
|
3681
3982
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3682
3983
|
void this.loadSuppressed();
|
|
3683
|
-
void this.
|
|
3984
|
+
void this.loadElevated();
|
|
3985
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
3684
3986
|
void this.refreshMembers();
|
|
3685
3987
|
}
|
|
3988
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
3989
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
3990
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
3991
|
+
async loadPurged() {
|
|
3992
|
+
if (this.purgedLoaded || !this._group) return;
|
|
3993
|
+
this.purgedLoaded = true;
|
|
3994
|
+
try {
|
|
3995
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
3996
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
3997
|
+
} catch {
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
3686
4000
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3687
4001
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3688
4002
|
async loadSuppressed() {
|
|
@@ -3701,6 +4015,17 @@ var Chat = class {
|
|
|
3701
4015
|
} catch {
|
|
3702
4016
|
}
|
|
3703
4017
|
}
|
|
4018
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
4019
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
4020
|
+
async loadElevated() {
|
|
4021
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
4022
|
+
this.elevatedLoaded = true;
|
|
4023
|
+
try {
|
|
4024
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
4025
|
+
for (const k of keys) this.elevated.add(k);
|
|
4026
|
+
} catch {
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
3704
4029
|
async hydrateHistory() {
|
|
3705
4030
|
if (this.historyLoaded || !this._group) return;
|
|
3706
4031
|
this.historyLoaded = true;
|
|
@@ -3728,11 +4053,16 @@ var Chat = class {
|
|
|
3728
4053
|
if (this.seenKeys.has(key)) continue;
|
|
3729
4054
|
this.seenKeys.add(key);
|
|
3730
4055
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3731
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
4056
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
3732
4057
|
}
|
|
3733
|
-
this.messageList.push(
|
|
4058
|
+
this.messageList.push(
|
|
4059
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
4060
|
+
);
|
|
3734
4061
|
changed = true;
|
|
3735
4062
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
4063
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
4064
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
4065
|
+
}
|
|
3736
4066
|
}
|
|
3737
4067
|
if (changed) {
|
|
3738
4068
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3747,6 +4077,7 @@ var Chat = class {
|
|
|
3747
4077
|
return;
|
|
3748
4078
|
}
|
|
3749
4079
|
if (incoming.serverSeq <= 0) return;
|
|
4080
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
3750
4081
|
const key = this.internalKey(incoming.serverSeq);
|
|
3751
4082
|
if (this.seenKeys.has(key)) return;
|
|
3752
4083
|
this.seenKeys.add(key);
|
|
@@ -3755,6 +4086,20 @@ var Chat = class {
|
|
|
3755
4086
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
3756
4087
|
}
|
|
3757
4088
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
4089
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
4090
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
4091
|
+
if (actorUserId !== null) {
|
|
4092
|
+
this.timerFold.ingest({
|
|
4093
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
4094
|
+
start: incoming.timer.start,
|
|
4095
|
+
actorUserId,
|
|
4096
|
+
epoch: incoming.epoch,
|
|
4097
|
+
serverSeq: incoming.serverSeq,
|
|
4098
|
+
eventClientMsgId: incoming.clientMsgId
|
|
4099
|
+
});
|
|
4100
|
+
}
|
|
4101
|
+
return;
|
|
4102
|
+
}
|
|
3758
4103
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
3759
4104
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3760
4105
|
if (actorUserId !== null) {
|
|
@@ -3780,7 +4125,10 @@ var Chat = class {
|
|
|
3780
4125
|
newText: incoming.edit.newText,
|
|
3781
4126
|
epoch: incoming.epoch,
|
|
3782
4127
|
serverSeq: incoming.serverSeq,
|
|
3783
|
-
eventClientMsgId: incoming.clientMsgId
|
|
4128
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
4129
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
4130
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
4131
|
+
bodyRanges: incoming.bodyRanges
|
|
3784
4132
|
},
|
|
3785
4133
|
this.authorOfTarget
|
|
3786
4134
|
);
|
|
@@ -3808,6 +4156,7 @@ var Chat = class {
|
|
|
3808
4156
|
if (incomingReplyRef) {
|
|
3809
4157
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3810
4158
|
}
|
|
4159
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3811
4160
|
const msg = {
|
|
3812
4161
|
id: this.publicId(incoming.serverSeq),
|
|
3813
4162
|
kind: this.kindOf(incoming),
|
|
@@ -3824,8 +4173,13 @@ var Chat = class {
|
|
|
3824
4173
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3825
4174
|
edited: false,
|
|
3826
4175
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3827
|
-
isDeleted: false
|
|
4176
|
+
isDeleted: false,
|
|
4177
|
+
mentions,
|
|
4178
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
4179
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
4180
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
3828
4181
|
};
|
|
4182
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3829
4183
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3830
4184
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3831
4185
|
text: incoming.text,
|
|
@@ -3835,7 +4189,10 @@ var Chat = class {
|
|
|
3835
4189
|
if (incomingClientMsgId) {
|
|
3836
4190
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3837
4191
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3838
|
-
this.deleteFold.reevaluatePending(
|
|
4192
|
+
this.deleteFold.reevaluatePending(
|
|
4193
|
+
incomingClientMsgId,
|
|
4194
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
4195
|
+
);
|
|
3839
4196
|
}
|
|
3840
4197
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3841
4198
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3844,11 +4201,200 @@ var Chat = class {
|
|
|
3844
4201
|
incoming.serverSeq
|
|
3845
4202
|
);
|
|
3846
4203
|
this.emit();
|
|
4204
|
+
void this.armPurge(
|
|
4205
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
4206
|
+
incoming.serverSeq,
|
|
4207
|
+
incomingClientMsgId
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
// ── Disappearing (TTL — T10) ──
|
|
4211
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
4212
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
4213
|
+
toExpirySpec(e) {
|
|
4214
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
4215
|
+
}
|
|
4216
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
4217
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
4218
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
4219
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
4220
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
4221
|
+
defaultExpiry() {
|
|
4222
|
+
const active = this.timerFold.active();
|
|
4223
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
4224
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
4225
|
+
}
|
|
4226
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
4227
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
4228
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
4229
|
+
* UI countdown baseline. */
|
|
4230
|
+
deadlineFor(expiry) {
|
|
4231
|
+
if (!expiry) return null;
|
|
4232
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
4233
|
+
}
|
|
4234
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
4235
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
4236
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
4237
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
4238
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
4239
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
4240
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
4241
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
4242
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
4243
|
+
const group = this._group;
|
|
4244
|
+
const fresh = {
|
|
4245
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
4246
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4247
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
4248
|
+
};
|
|
4249
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
4250
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
4251
|
+
const result = remainingSeconds({
|
|
4252
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
4253
|
+
anchor: effective,
|
|
4254
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
4255
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4256
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
4257
|
+
});
|
|
4258
|
+
let purgeInSeconds;
|
|
4259
|
+
if (result.kind === "purgeNow") {
|
|
4260
|
+
purgeInSeconds = 0;
|
|
4261
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
4262
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
4263
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
4264
|
+
} else {
|
|
4265
|
+
purgeInSeconds = result.seconds;
|
|
4266
|
+
}
|
|
4267
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4268
|
+
if (prior) clearTimeout(prior);
|
|
4269
|
+
this.purgeTimers.delete(serverSeq);
|
|
4270
|
+
if (purgeInSeconds <= 0) {
|
|
4271
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4272
|
+
return;
|
|
4273
|
+
}
|
|
4274
|
+
const handle = setTimeout(() => {
|
|
4275
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4276
|
+
}, purgeInSeconds * 1e3);
|
|
4277
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4278
|
+
}
|
|
4279
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
4280
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
4281
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
4282
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
4283
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
4284
|
+
if (!this._group) return;
|
|
4285
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
4286
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4287
|
+
if (prior) clearTimeout(prior);
|
|
4288
|
+
this.purgeTimers.delete(serverSeq);
|
|
4289
|
+
if (remainingMs <= 0) {
|
|
4290
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4291
|
+
return;
|
|
4292
|
+
}
|
|
4293
|
+
const handle = setTimeout(() => {
|
|
4294
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4295
|
+
}, remainingMs);
|
|
4296
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4297
|
+
}
|
|
4298
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
4299
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
4300
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
4301
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
4302
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
4303
|
+
async purge(serverSeq, clientMsgId) {
|
|
4304
|
+
if (!this._group) return;
|
|
4305
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4306
|
+
if (prior) clearTimeout(prior);
|
|
4307
|
+
this.purgeTimers.delete(serverSeq);
|
|
4308
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
4309
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
4310
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
4311
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
4312
|
+
this.emit();
|
|
4313
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
4314
|
+
if (clientMsgId) {
|
|
4315
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
4319
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
4320
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
4321
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
4322
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
4323
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
4324
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
4325
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
4326
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
4327
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
4328
|
+
};
|
|
4329
|
+
// ── Mentions (mentions T6) ──
|
|
4330
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
4331
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
4332
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
4333
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
4334
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
4335
|
+
resolveMentions(text, bodyRanges) {
|
|
4336
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
4337
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
4338
|
+
if (normalized.length === 0) return [];
|
|
4339
|
+
return normalized.map((r) => ({
|
|
4340
|
+
start: r.start,
|
|
4341
|
+
length: r.length,
|
|
4342
|
+
mentionedUserId: r.mentionedUserId,
|
|
4343
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
4344
|
+
}));
|
|
4345
|
+
}
|
|
4346
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
4347
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
4348
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
4349
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
4350
|
+
resolveMentionNames(m) {
|
|
4351
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
4352
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
4353
|
+
}
|
|
4354
|
+
let changed = false;
|
|
4355
|
+
const reresolved = m.mentions.map((span) => {
|
|
4356
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
4357
|
+
if (name === span.displayName) return span;
|
|
4358
|
+
changed = true;
|
|
4359
|
+
return { ...span, displayName: name };
|
|
4360
|
+
});
|
|
4361
|
+
if (!changed) return m;
|
|
4362
|
+
return { ...m, mentions: reresolved };
|
|
4363
|
+
}
|
|
4364
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
4365
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
4366
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
4367
|
+
editMentions(targetClientMsgId, newText) {
|
|
4368
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
4369
|
+
if (!ranges) return [];
|
|
4370
|
+
return this.resolveMentions(newText, ranges);
|
|
4371
|
+
}
|
|
4372
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
4373
|
+
displayNameOf(userId) {
|
|
4374
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
4375
|
+
}
|
|
4376
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
4377
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
4378
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
4379
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
4380
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
4381
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
4382
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
4383
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
4384
|
+
const me = this.backend.selfUserId;
|
|
4385
|
+
if (envelopeType === "edit") return;
|
|
4386
|
+
if (senderUserId === me) return;
|
|
4387
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
4388
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4389
|
+
const key = `${me}|${idPart}`;
|
|
4390
|
+
if (this.elevated.has(key)) return;
|
|
4391
|
+
this.elevated.add(key);
|
|
4392
|
+
if (this._group) {
|
|
4393
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
4394
|
+
});
|
|
4395
|
+
}
|
|
4396
|
+
this.onMentionElevation?.(message);
|
|
3847
4397
|
}
|
|
3848
|
-
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3849
|
-
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3850
|
-
* so it can be passed to the pure EditFold. */
|
|
3851
|
-
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3852
4398
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3853
4399
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3854
4400
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3905,9 +4451,10 @@ var Chat = class {
|
|
|
3905
4451
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3906
4452
|
const text = editText ?? base;
|
|
3907
4453
|
const edited = foldEdited || m.edited;
|
|
3908
|
-
|
|
4454
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
4455
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3909
4456
|
changed = true;
|
|
3910
|
-
return { ...m, text, edited };
|
|
4457
|
+
return { ...m, text, edited, mentions };
|
|
3911
4458
|
});
|
|
3912
4459
|
if (changed) this.emit();
|
|
3913
4460
|
}
|
|
@@ -3924,8 +4471,9 @@ var Chat = class {
|
|
|
3924
4471
|
if (editText === null && !foldEdited) return m;
|
|
3925
4472
|
const text = editText ?? m.text;
|
|
3926
4473
|
const edited = foldEdited || m.edited;
|
|
3927
|
-
|
|
3928
|
-
|
|
4474
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
4475
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
4476
|
+
return { ...m, text, edited, mentions };
|
|
3929
4477
|
}
|
|
3930
4478
|
/** @internal — called by the backend's conv subscription. */
|
|
3931
4479
|
applyConv(event, payload) {
|
|
@@ -3983,6 +4531,18 @@ var Chat = class {
|
|
|
3983
4531
|
}
|
|
3984
4532
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3985
4533
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
4534
|
+
this.reresolveAllMentionNames();
|
|
4535
|
+
}
|
|
4536
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
4537
|
+
* change). Re-emits only if any name actually changed. */
|
|
4538
|
+
reresolveAllMentionNames() {
|
|
4539
|
+
let changed = false;
|
|
4540
|
+
this.messageList = this.messageList.map((m) => {
|
|
4541
|
+
const reresolved = this.resolveMentionNames(m);
|
|
4542
|
+
if (reresolved !== m) changed = true;
|
|
4543
|
+
return reresolved;
|
|
4544
|
+
});
|
|
4545
|
+
if (changed) this.emit();
|
|
3986
4546
|
}
|
|
3987
4547
|
seedMembersFromGroup(group) {
|
|
3988
4548
|
const seed = [
|
|
@@ -4050,11 +4610,50 @@ var Chat = class {
|
|
|
4050
4610
|
};
|
|
4051
4611
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
4052
4612
|
}
|
|
4053
|
-
const
|
|
4054
|
-
|
|
4613
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4614
|
+
const start = opts?.expiresIn?.start ?? "send";
|
|
4615
|
+
const expiry = opts?.expiresIn ? {
|
|
4616
|
+
v: 1,
|
|
4617
|
+
ttlSeconds: opts.expiresIn.ttlSeconds,
|
|
4618
|
+
start,
|
|
4619
|
+
senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
4620
|
+
} : null;
|
|
4621
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
4622
|
+
group,
|
|
4623
|
+
text,
|
|
4624
|
+
replyRef,
|
|
4625
|
+
bodyRanges,
|
|
4626
|
+
expiry
|
|
4627
|
+
);
|
|
4628
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
4629
|
+
if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
|
|
4055
4630
|
return receipt;
|
|
4056
4631
|
}
|
|
4057
|
-
|
|
4632
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
4633
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
4634
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
4635
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
4636
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
4637
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
4638
|
+
async setDisappearing(opts) {
|
|
4639
|
+
const group = await this.materializeIfNeeded();
|
|
4640
|
+
const clientMsgId = mintClientMsgId();
|
|
4641
|
+
const start = opts.start ?? "send";
|
|
4642
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
4643
|
+
clientMsgId,
|
|
4644
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4645
|
+
start
|
|
4646
|
+
});
|
|
4647
|
+
this.timerFold.ingest({
|
|
4648
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4649
|
+
start,
|
|
4650
|
+
actorUserId: this.backend.selfUserId,
|
|
4651
|
+
epoch: receipt.epoch,
|
|
4652
|
+
serverSeq: receipt.serverSeq,
|
|
4653
|
+
eventClientMsgId: clientMsgId
|
|
4654
|
+
});
|
|
4655
|
+
}
|
|
4656
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
4058
4657
|
if (receipt.serverSeq <= 0) return;
|
|
4059
4658
|
const key = this.internalKey(receipt.serverSeq);
|
|
4060
4659
|
if (this.seenKeys.has(key)) return;
|
|
@@ -4079,7 +4678,13 @@ var Chat = class {
|
|
|
4079
4678
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
4080
4679
|
edited: false,
|
|
4081
4680
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4082
|
-
isDeleted: false
|
|
4681
|
+
isDeleted: false,
|
|
4682
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4683
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4684
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
4685
|
+
// Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
|
|
4686
|
+
// via the messageList overlay; default null here (a plain own-send has no deadline).
|
|
4687
|
+
expiresAt: null
|
|
4083
4688
|
});
|
|
4084
4689
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
4085
4690
|
this.emit();
|
|
@@ -4167,15 +4772,18 @@ var Chat = class {
|
|
|
4167
4772
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
4168
4773
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
4169
4774
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
4170
|
-
* message self IS the author, so the author-gate passes.
|
|
4171
|
-
|
|
4775
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4776
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4777
|
+
async edit(message, newText, opts) {
|
|
4172
4778
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
4173
4779
|
const group = await this.materializeIfNeeded();
|
|
4174
4780
|
const clientMsgId = mintClientMsgId();
|
|
4781
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4175
4782
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
4176
4783
|
clientMsgId,
|
|
4177
4784
|
targetClientMsgId: message.clientMsgId,
|
|
4178
|
-
newText
|
|
4785
|
+
newText,
|
|
4786
|
+
bodyRanges
|
|
4179
4787
|
});
|
|
4180
4788
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4181
4789
|
this.editFold.ingest(
|
|
@@ -4185,7 +4793,8 @@ var Chat = class {
|
|
|
4185
4793
|
newText,
|
|
4186
4794
|
epoch: receipt.epoch,
|
|
4187
4795
|
serverSeq: receipt.serverSeq,
|
|
4188
|
-
eventClientMsgId: clientMsgId
|
|
4796
|
+
eventClientMsgId: clientMsgId,
|
|
4797
|
+
bodyRanges
|
|
4189
4798
|
},
|
|
4190
4799
|
this.authorOfTarget
|
|
4191
4800
|
);
|
|
@@ -4238,6 +4847,18 @@ var Chat = class {
|
|
|
4238
4847
|
}
|
|
4239
4848
|
}
|
|
4240
4849
|
};
|
|
4850
|
+
function sameMentions(a, b) {
|
|
4851
|
+
if (a.length !== b.length) return false;
|
|
4852
|
+
for (let i = 0; i < a.length; i++) {
|
|
4853
|
+
const x = a[i];
|
|
4854
|
+
const y = b[i];
|
|
4855
|
+
if (!x || !y) return false;
|
|
4856
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4857
|
+
return false;
|
|
4858
|
+
}
|
|
4859
|
+
}
|
|
4860
|
+
return true;
|
|
4861
|
+
}
|
|
4241
4862
|
function sameReactions(a, b) {
|
|
4242
4863
|
const ak = Object.keys(a);
|
|
4243
4864
|
const bk = Object.keys(b);
|
|
@@ -4418,6 +5039,7 @@ var MessageDeliverySource = class {
|
|
|
4418
5039
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4419
5040
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4420
5041
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
5042
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
4421
5043
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4422
5044
|
const stored = {
|
|
4423
5045
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4449,14 +5071,21 @@ var MessageDeliverySource = class {
|
|
|
4449
5071
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4450
5072
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4451
5073
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4452
|
-
// `'text'`/no-edit (backward-compat).
|
|
5074
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
5075
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
4453
5076
|
...isEdit && decoded.edit ? {
|
|
4454
5077
|
envelopeType: "edit",
|
|
4455
5078
|
edit: {
|
|
4456
5079
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4457
|
-
newText: decoded.edit.newText
|
|
5080
|
+
newText: decoded.edit.newText,
|
|
5081
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4458
5082
|
}
|
|
4459
5083
|
} : {},
|
|
5084
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
5085
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
5086
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
5087
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
5088
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4460
5089
|
// Thread the delete discriminator + target through the persisted row so a
|
|
4461
5090
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4462
5091
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -4468,7 +5097,18 @@ var MessageDeliverySource = class {
|
|
|
4468
5097
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4469
5098
|
scope: decoded.delete.scope
|
|
4470
5099
|
}
|
|
4471
|
-
} : {}
|
|
5100
|
+
} : {},
|
|
5101
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
5102
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
5103
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
5104
|
+
...isTimerSet && decoded.timer ? {
|
|
5105
|
+
envelopeType: "timer_set",
|
|
5106
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
5107
|
+
} : {},
|
|
5108
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
5109
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
5110
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
5111
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
4472
5112
|
};
|
|
4473
5113
|
try {
|
|
4474
5114
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -4488,7 +5128,15 @@ var MessageDeliverySource = class {
|
|
|
4488
5128
|
envelopeType: decoded.type ?? "text",
|
|
4489
5129
|
reaction: isReaction ? decoded.reaction : null,
|
|
4490
5130
|
edit: isEdit ? decoded.edit : null,
|
|
4491
|
-
delete: isDelete ? decoded.delete : null
|
|
5131
|
+
delete: isDelete ? decoded.delete : null,
|
|
5132
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
5133
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
5134
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
5135
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
5136
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
5137
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
5138
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
5139
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
4492
5140
|
});
|
|
4493
5141
|
return true;
|
|
4494
5142
|
}
|
|
@@ -4567,6 +5215,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
4567
5215
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
4568
5216
|
}
|
|
4569
5217
|
|
|
5218
|
+
// src/messaging/disappearing.ts
|
|
5219
|
+
var DisappearingStore = class {
|
|
5220
|
+
constructor(kv) {
|
|
5221
|
+
this.kv = kv;
|
|
5222
|
+
}
|
|
5223
|
+
kv;
|
|
5224
|
+
key(rfc) {
|
|
5225
|
+
return `disappear:${rfc}`;
|
|
5226
|
+
}
|
|
5227
|
+
async load(rfc) {
|
|
5228
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
5229
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5230
|
+
try {
|
|
5231
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
5232
|
+
return {
|
|
5233
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
5234
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
5235
|
+
anchors: r.anchors ?? {}
|
|
5236
|
+
};
|
|
5237
|
+
} catch {
|
|
5238
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
async save(rfc, rec) {
|
|
5242
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
5243
|
+
}
|
|
5244
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
5245
|
+
async tombstonedSeqs(rfc) {
|
|
5246
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
5247
|
+
}
|
|
5248
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
5249
|
+
async purgedClientMsgIds(rfc) {
|
|
5250
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
5251
|
+
}
|
|
5252
|
+
/**
|
|
5253
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
5254
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
5255
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
5256
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
5257
|
+
*/
|
|
5258
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
5259
|
+
const rec = await this.load(rfc);
|
|
5260
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
5261
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
5262
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
5263
|
+
}
|
|
5264
|
+
await this.save(rfc, rec);
|
|
5265
|
+
}
|
|
5266
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
5267
|
+
async anchor(rfc, clientMsgId) {
|
|
5268
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
5269
|
+
}
|
|
5270
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
5271
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
5272
|
+
const rec = await this.load(rfc);
|
|
5273
|
+
if (rec.anchors[clientMsgId]) return;
|
|
5274
|
+
rec.anchors[clientMsgId] = a;
|
|
5275
|
+
await this.save(rfc, rec);
|
|
5276
|
+
}
|
|
5277
|
+
};
|
|
5278
|
+
|
|
4570
5279
|
// src/messaging/history.ts
|
|
4571
5280
|
var MessageStore = class {
|
|
4572
5281
|
constructor(kv) {
|
|
@@ -4643,6 +5352,36 @@ var GroupCatalog = class {
|
|
|
4643
5352
|
}
|
|
4644
5353
|
};
|
|
4645
5354
|
|
|
5355
|
+
// src/messaging/mention-elevation.ts
|
|
5356
|
+
var MentionElevationStore = class {
|
|
5357
|
+
constructor(kv) {
|
|
5358
|
+
this.kv = kv;
|
|
5359
|
+
}
|
|
5360
|
+
kv;
|
|
5361
|
+
key(rfcGroupId) {
|
|
5362
|
+
return `elev:${rfcGroupId}`;
|
|
5363
|
+
}
|
|
5364
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
5365
|
+
async load(rfcGroupId) {
|
|
5366
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
5367
|
+
if (!raw) return [];
|
|
5368
|
+
try {
|
|
5369
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
5370
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5371
|
+
} catch {
|
|
5372
|
+
return [];
|
|
5373
|
+
}
|
|
5374
|
+
}
|
|
5375
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
5376
|
+
async save(rfcGroupId, keys) {
|
|
5377
|
+
const sorted = [...new Set(keys)].sort();
|
|
5378
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
5379
|
+
}
|
|
5380
|
+
async wipe() {
|
|
5381
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
5382
|
+
}
|
|
5383
|
+
};
|
|
5384
|
+
|
|
4646
5385
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4647
5386
|
var palbe_mls_bg_exports = {};
|
|
4648
5387
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6350,6 +7089,8 @@ var MessagingCoordinator = class {
|
|
|
6350
7089
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6351
7090
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6352
7091
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
7092
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
7093
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
6353
7094
|
this.registry.attachChatList(
|
|
6354
7095
|
(chats) => {
|
|
6355
7096
|
this.chatList = chats;
|
|
@@ -6365,6 +7106,8 @@ var MessagingCoordinator = class {
|
|
|
6365
7106
|
groupStore;
|
|
6366
7107
|
kpStore;
|
|
6367
7108
|
suppressionStore;
|
|
7109
|
+
elevationStore;
|
|
7110
|
+
disappearingStore;
|
|
6368
7111
|
registry = new GroupRegistry();
|
|
6369
7112
|
resolved = null;
|
|
6370
7113
|
resolvePromise = null;
|
|
@@ -6520,9 +7263,9 @@ var MessagingCoordinator = class {
|
|
|
6520
7263
|
});
|
|
6521
7264
|
return group;
|
|
6522
7265
|
}
|
|
6523
|
-
async sendText(group, text, replyTo) {
|
|
7266
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6524
7267
|
const r = await this.resolve();
|
|
6525
|
-
return r.groups.sendText(group, text, replyTo);
|
|
7268
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6526
7269
|
}
|
|
6527
7270
|
async sendReaction(group, args) {
|
|
6528
7271
|
const r = await this.resolve();
|
|
@@ -6536,6 +7279,10 @@ var MessagingCoordinator = class {
|
|
|
6536
7279
|
const r = await this.resolve();
|
|
6537
7280
|
return r.groups.sendDelete(group, args);
|
|
6538
7281
|
}
|
|
7282
|
+
async sendTimerSet(group, args) {
|
|
7283
|
+
const r = await this.resolve();
|
|
7284
|
+
return r.groups.sendTimerSet(group, args);
|
|
7285
|
+
}
|
|
6539
7286
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6540
7287
|
loadSuppressed(group) {
|
|
6541
7288
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -6544,10 +7291,36 @@ var MessagingCoordinator = class {
|
|
|
6544
7291
|
saveSuppressed(group, keys) {
|
|
6545
7292
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6546
7293
|
}
|
|
7294
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
7295
|
+
loadElevated(group) {
|
|
7296
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
7297
|
+
}
|
|
7298
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
7299
|
+
saveElevated(group, keys) {
|
|
7300
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
7301
|
+
}
|
|
7302
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
7303
|
+
tombstonedSeqs(group) {
|
|
7304
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7305
|
+
}
|
|
7306
|
+
purgedClientMsgIds(group) {
|
|
7307
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7308
|
+
}
|
|
7309
|
+
anchor(group, clientMsgId) {
|
|
7310
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
7311
|
+
}
|
|
7312
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
7313
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
7314
|
+
}
|
|
7315
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
7316
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
7317
|
+
}
|
|
6547
7318
|
async history(group, limit, before) {
|
|
6548
7319
|
const r = await this.resolve();
|
|
6549
7320
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
6550
|
-
|
|
7321
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7322
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7323
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
6551
7324
|
}
|
|
6552
7325
|
async members(group) {
|
|
6553
7326
|
const r = await this.resolve();
|
|
@@ -6624,9 +7397,10 @@ var MessagingCoordinator = class {
|
|
|
6624
7397
|
return res.devices.map((d) => d.device_id);
|
|
6625
7398
|
}
|
|
6626
7399
|
};
|
|
6627
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
7400
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
7401
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
6628
7402
|
const fold = new ReactionFold();
|
|
6629
|
-
for (const s of
|
|
7403
|
+
for (const s of visible) {
|
|
6630
7404
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
6631
7405
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6632
7406
|
if (actor === null) continue;
|
|
@@ -6642,17 +7416,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6642
7416
|
}
|
|
6643
7417
|
const editFold = new EditFold();
|
|
6644
7418
|
const deleteFold = new DeleteFold();
|
|
7419
|
+
const pageTimerFold = new TimerFold();
|
|
6645
7420
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6646
|
-
for (const s of
|
|
6647
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7421
|
+
for (const s of visible) {
|
|
7422
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6648
7423
|
continue;
|
|
6649
7424
|
const cid = s.clientMsgId ?? "";
|
|
6650
7425
|
if (!cid) continue;
|
|
6651
7426
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6652
7427
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6653
7428
|
}
|
|
6654
|
-
const authorOfTarget = (cid) =>
|
|
6655
|
-
|
|
7429
|
+
const authorOfTarget = (cid) => {
|
|
7430
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
7431
|
+
const a = authorByClientMsgId.get(cid);
|
|
7432
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
7433
|
+
};
|
|
7434
|
+
for (const s of visible) {
|
|
6656
7435
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6657
7436
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6658
7437
|
editFold.ingest(
|
|
@@ -6662,13 +7441,16 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6662
7441
|
newText: s.edit.newText,
|
|
6663
7442
|
epoch: s.epoch,
|
|
6664
7443
|
serverSeq: s.serverSeq,
|
|
6665
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
7444
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
7445
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
7446
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
7447
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6666
7448
|
},
|
|
6667
7449
|
authorOfTarget
|
|
6668
7450
|
);
|
|
6669
7451
|
}
|
|
6670
7452
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6671
|
-
for (const s of
|
|
7453
|
+
for (const s of visible) {
|
|
6672
7454
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6673
7455
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6674
7456
|
deleteFold.ingest(
|
|
@@ -6682,10 +7464,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6682
7464
|
authorOfTarget
|
|
6683
7465
|
);
|
|
6684
7466
|
}
|
|
6685
|
-
for (const [cid
|
|
7467
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
6686
7468
|
const lookup = /* @__PURE__ */ new Map();
|
|
6687
|
-
for (const s of
|
|
6688
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7469
|
+
for (const s of visible) {
|
|
7470
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6689
7471
|
continue;
|
|
6690
7472
|
const cid = s.clientMsgId ?? "";
|
|
6691
7473
|
if (cid && s.text !== null) {
|
|
@@ -6694,7 +7476,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6694
7476
|
}
|
|
6695
7477
|
}
|
|
6696
7478
|
const out = [];
|
|
6697
|
-
for (const s of
|
|
7479
|
+
for (const s of visible) {
|
|
7480
|
+
if (s.envelopeType === "timer_set") {
|
|
7481
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
7482
|
+
if (actor !== null && s.timer) {
|
|
7483
|
+
pageTimerFold.ingest({
|
|
7484
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
7485
|
+
start: s.timer.start,
|
|
7486
|
+
actorUserId: actor,
|
|
7487
|
+
epoch: s.epoch,
|
|
7488
|
+
serverSeq: s.serverSeq,
|
|
7489
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
7490
|
+
});
|
|
7491
|
+
}
|
|
7492
|
+
continue;
|
|
7493
|
+
}
|
|
6698
7494
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6699
7495
|
continue;
|
|
6700
7496
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -6712,10 +7508,23 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6712
7508
|
replyTo: null,
|
|
6713
7509
|
reactions: {},
|
|
6714
7510
|
edited: false,
|
|
6715
|
-
isDeleted: true
|
|
7511
|
+
isDeleted: true,
|
|
7512
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
7513
|
+
mentions: [],
|
|
7514
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
7515
|
+
expiresAt: null
|
|
6716
7516
|
});
|
|
6717
7517
|
continue;
|
|
6718
7518
|
}
|
|
7519
|
+
let expiresAt = null;
|
|
7520
|
+
if (s.expiry) {
|
|
7521
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
7522
|
+
} else {
|
|
7523
|
+
const active = pageTimerFold.active();
|
|
7524
|
+
if (active && active.ttlSeconds !== null) {
|
|
7525
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
7526
|
+
}
|
|
7527
|
+
}
|
|
6719
7528
|
let replyTo = null;
|
|
6720
7529
|
if (s.replyTo) {
|
|
6721
7530
|
const ref = {
|
|
@@ -6732,23 +7541,37 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6732
7541
|
}
|
|
6733
7542
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6734
7543
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
7544
|
+
const text = editText ?? s.text;
|
|
7545
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
7546
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6735
7547
|
out.push({
|
|
6736
7548
|
id: `${displayId}#${s.serverSeq}`,
|
|
6737
7549
|
kind: s.text != null ? "text" : "system",
|
|
6738
7550
|
direction: s.direction,
|
|
6739
7551
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6740
|
-
text
|
|
7552
|
+
text,
|
|
6741
7553
|
serverSeq: s.serverSeq,
|
|
6742
7554
|
sentAt: new Date(s.at),
|
|
6743
7555
|
clientMsgId,
|
|
6744
7556
|
replyTo,
|
|
6745
7557
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6746
7558
|
edited,
|
|
6747
|
-
isDeleted: false
|
|
7559
|
+
isDeleted: false,
|
|
7560
|
+
mentions,
|
|
7561
|
+
expiresAt
|
|
6748
7562
|
});
|
|
6749
7563
|
}
|
|
6750
7564
|
return out;
|
|
6751
7565
|
}
|
|
7566
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
7567
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
7568
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
7569
|
+
start: r.start,
|
|
7570
|
+
length: r.length,
|
|
7571
|
+
mentionedUserId: r.mentionedUserId,
|
|
7572
|
+
displayName: null
|
|
7573
|
+
}));
|
|
7574
|
+
}
|
|
6752
7575
|
|
|
6753
7576
|
// src/messaging/facade.ts
|
|
6754
7577
|
var PalbeMessaging = class {
|
|
@@ -7482,7 +8305,7 @@ function defaultSessionStorage(key) {
|
|
|
7482
8305
|
}
|
|
7483
8306
|
|
|
7484
8307
|
// src/version.ts
|
|
7485
|
-
var VERSION = "1.
|
|
8308
|
+
var VERSION = "1.6.0";
|
|
7486
8309
|
|
|
7487
8310
|
// src/runtime.ts
|
|
7488
8311
|
function buildRuntime(config) {
|