@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/internal.cjs
CHANGED
|
@@ -2612,6 +2612,47 @@ var PalbeFlags = class {
|
|
|
2612
2612
|
}
|
|
2613
2613
|
};
|
|
2614
2614
|
|
|
2615
|
+
// src/messaging/deadline-calculator.ts
|
|
2616
|
+
function remainingSeconds(args) {
|
|
2617
|
+
const ttl = args.ttlSeconds;
|
|
2618
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
2619
|
+
let elapsed;
|
|
2620
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
2621
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
2622
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
2623
|
+
} else {
|
|
2624
|
+
elapsed = wallDeltaSec;
|
|
2625
|
+
}
|
|
2626
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
2627
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
2628
|
+
}
|
|
2629
|
+
var cachedBootToken = null;
|
|
2630
|
+
var MonotonicClock = {
|
|
2631
|
+
nowMs() {
|
|
2632
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2633
|
+
},
|
|
2634
|
+
nowWallEpochMs() {
|
|
2635
|
+
return Date.now();
|
|
2636
|
+
},
|
|
2637
|
+
bootToken() {
|
|
2638
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
2639
|
+
try {
|
|
2640
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
2641
|
+
if (existing) {
|
|
2642
|
+
cachedBootToken = existing;
|
|
2643
|
+
return existing;
|
|
2644
|
+
}
|
|
2645
|
+
const fresh = crypto.randomUUID();
|
|
2646
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
2647
|
+
cachedBootToken = fresh;
|
|
2648
|
+
return fresh;
|
|
2649
|
+
} catch {
|
|
2650
|
+
cachedBootToken = crypto.randomUUID();
|
|
2651
|
+
return cachedBootToken;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
|
|
2615
2656
|
// src/messaging/delete-fold.ts
|
|
2616
2657
|
var DeleteFold = class {
|
|
2617
2658
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -2624,17 +2665,24 @@ var DeleteFold = class {
|
|
|
2624
2665
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2625
2666
|
held = [];
|
|
2626
2667
|
/**
|
|
2627
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
2628
|
-
*
|
|
2668
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
2669
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
2670
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
2671
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
2672
|
+
* park in pending, never re-attempt).
|
|
2629
2673
|
*/
|
|
2630
2674
|
ingest(e, authorOfTarget) {
|
|
2631
2675
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2632
2676
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2633
2677
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2634
|
-
const
|
|
2635
|
-
if (
|
|
2678
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2679
|
+
if (res.kind === "purged") {
|
|
2636
2680
|
this.seen.add(e.eventClientMsgId);
|
|
2637
|
-
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
if (res.kind === "author") {
|
|
2684
|
+
this.seen.add(e.eventClientMsgId);
|
|
2685
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
2638
2686
|
this.tombstoned.add(e.targetClientMsgId);
|
|
2639
2687
|
} else if (e.actorUserId !== null) {
|
|
2640
2688
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -2654,12 +2702,13 @@ var DeleteFold = class {
|
|
|
2654
2702
|
* the in-order path.
|
|
2655
2703
|
*/
|
|
2656
2704
|
reevaluatePending(target, author) {
|
|
2705
|
+
const res = author;
|
|
2657
2706
|
const actor = this.pending.get(target);
|
|
2658
2707
|
if (actor !== void 0) {
|
|
2659
|
-
if (
|
|
2660
|
-
this.tombstoned.add(target);
|
|
2708
|
+
if (res.kind === "author") {
|
|
2709
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
2661
2710
|
this.pending.delete(target);
|
|
2662
|
-
} else if (
|
|
2711
|
+
} else if (res.kind === "purged") {
|
|
2663
2712
|
this.pending.delete(target);
|
|
2664
2713
|
}
|
|
2665
2714
|
}
|
|
@@ -2667,7 +2716,7 @@ var DeleteFold = class {
|
|
|
2667
2716
|
const pendingHeld = this.held;
|
|
2668
2717
|
this.held = [];
|
|
2669
2718
|
for (const e of pendingHeld) {
|
|
2670
|
-
this.ingest(e, (t) => t === target ?
|
|
2719
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
2671
2720
|
}
|
|
2672
2721
|
}
|
|
2673
2722
|
heldContains(eventClientMsgId) {
|
|
@@ -2693,15 +2742,23 @@ var EditFold = class {
|
|
|
2693
2742
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
2694
2743
|
editedTargets = /* @__PURE__ */ new Set();
|
|
2695
2744
|
/**
|
|
2696
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
2697
|
-
*
|
|
2745
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
2746
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
2747
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
2748
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
2749
|
+
* and a later author "resolution" cannot resurrect it).
|
|
2698
2750
|
*/
|
|
2699
2751
|
ingest(e, authorOfTarget) {
|
|
2700
|
-
const
|
|
2701
|
-
if (
|
|
2752
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2753
|
+
if (res.kind === "unknown") {
|
|
2702
2754
|
this.holdIfNew(e);
|
|
2703
2755
|
return;
|
|
2704
2756
|
}
|
|
2757
|
+
if (res.kind === "purged") {
|
|
2758
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const author = res.userId;
|
|
2705
2762
|
if (e.editorUserId === null) {
|
|
2706
2763
|
this.holdIfNew(e);
|
|
2707
2764
|
return;
|
|
@@ -2720,7 +2777,8 @@ var EditFold = class {
|
|
|
2720
2777
|
orderEpoch: e.epoch,
|
|
2721
2778
|
orderSeq: e.serverSeq,
|
|
2722
2779
|
lastEventId: e.eventClientMsgId,
|
|
2723
|
-
text: e.newText
|
|
2780
|
+
text: e.newText,
|
|
2781
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2724
2782
|
});
|
|
2725
2783
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2726
2784
|
}
|
|
@@ -2741,6 +2799,15 @@ var EditFold = class {
|
|
|
2741
2799
|
isEdited(targetClientMsgId) {
|
|
2742
2800
|
return this.editedTargets.has(targetClientMsgId);
|
|
2743
2801
|
}
|
|
2802
|
+
/**
|
|
2803
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2804
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2805
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2806
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2807
|
+
*/
|
|
2808
|
+
bodyRanges(targetClientMsgId) {
|
|
2809
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2810
|
+
}
|
|
2744
2811
|
/**
|
|
2745
2812
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2746
2813
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2914,7 +2981,14 @@ function encodeEdit(args) {
|
|
|
2914
2981
|
type: "edit",
|
|
2915
2982
|
client_msg_id: args.clientMsgId,
|
|
2916
2983
|
target_client_msg_id: args.targetClientMsgId,
|
|
2917
|
-
new_text: args.newText
|
|
2984
|
+
new_text: args.newText,
|
|
2985
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2986
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2987
|
+
start: r.start,
|
|
2988
|
+
length: r.length,
|
|
2989
|
+
mentioned_user_id: r.mentionedUserId
|
|
2990
|
+
}))
|
|
2991
|
+
} : {}
|
|
2918
2992
|
})
|
|
2919
2993
|
);
|
|
2920
2994
|
}
|
|
@@ -2936,14 +3010,53 @@ function encodeEnvelope(args) {
|
|
|
2936
3010
|
type: "text",
|
|
2937
3011
|
client_msg_id: args.clientMsgId,
|
|
2938
3012
|
text: args.text,
|
|
2939
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
3013
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
3014
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
3015
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
3016
|
+
start: r.start,
|
|
3017
|
+
length: r.length,
|
|
3018
|
+
mentioned_user_id: r.mentionedUserId
|
|
3019
|
+
}))
|
|
3020
|
+
} : {},
|
|
3021
|
+
...args.expiry ? {
|
|
3022
|
+
expiry: {
|
|
3023
|
+
v: args.expiry.v,
|
|
3024
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
3025
|
+
start: args.expiry.start,
|
|
3026
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
3027
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
3028
|
+
}
|
|
3029
|
+
} : {}
|
|
2940
3030
|
};
|
|
2941
3031
|
return encodeUtf8(JSON.stringify(env));
|
|
2942
3032
|
}
|
|
3033
|
+
function encodeTimerSet(args) {
|
|
3034
|
+
return encodeUtf8(
|
|
3035
|
+
JSON.stringify({
|
|
3036
|
+
v: 1,
|
|
3037
|
+
type: "timer_set",
|
|
3038
|
+
client_msg_id: args.clientMsgId,
|
|
3039
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
3040
|
+
start: args.start
|
|
3041
|
+
})
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
2943
3044
|
function decodeEnvelope(bytes) {
|
|
2944
3045
|
const s = decodeUtf8(bytes);
|
|
2945
3046
|
try {
|
|
2946
3047
|
const o = JSON.parse(s);
|
|
3048
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
3049
|
+
return {
|
|
3050
|
+
type: "timer_set",
|
|
3051
|
+
text: null,
|
|
3052
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
3053
|
+
replyTo: null,
|
|
3054
|
+
timer: {
|
|
3055
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
3056
|
+
start: o.start === "read" ? "read" : "send"
|
|
3057
|
+
}
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
2947
3060
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2948
3061
|
return {
|
|
2949
3062
|
type: "delete",
|
|
@@ -2970,6 +3083,7 @@ function decodeEnvelope(bytes) {
|
|
|
2970
3083
|
};
|
|
2971
3084
|
}
|
|
2972
3085
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
3086
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2973
3087
|
return {
|
|
2974
3088
|
type: "edit",
|
|
2975
3089
|
text: null,
|
|
@@ -2978,15 +3092,20 @@ function decodeEnvelope(bytes) {
|
|
|
2978
3092
|
edit: {
|
|
2979
3093
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2980
3094
|
newText: o.new_text ?? ""
|
|
2981
|
-
}
|
|
3095
|
+
},
|
|
3096
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2982
3097
|
};
|
|
2983
3098
|
}
|
|
2984
3099
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
3100
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
3101
|
+
const expiry = decodeExpiry(o.expiry);
|
|
2985
3102
|
return {
|
|
2986
3103
|
type: "text",
|
|
2987
3104
|
text: o.text ?? null,
|
|
2988
3105
|
clientMsgId: o.client_msg_id ?? "",
|
|
2989
|
-
replyTo: o.reply_to ?? null
|
|
3106
|
+
replyTo: o.reply_to ?? null,
|
|
3107
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
3108
|
+
...expiry ? { expiry } : {}
|
|
2990
3109
|
};
|
|
2991
3110
|
}
|
|
2992
3111
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2998,6 +3117,27 @@ function decodeEnvelope(bytes) {
|
|
|
2998
3117
|
}
|
|
2999
3118
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
3000
3119
|
}
|
|
3120
|
+
function decodeExpiry(raw) {
|
|
3121
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
3122
|
+
const o = raw;
|
|
3123
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
3124
|
+
const start = o.start === "read" ? "read" : "send";
|
|
3125
|
+
return {
|
|
3126
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
3127
|
+
ttlSeconds: o.ttl_seconds,
|
|
3128
|
+
start,
|
|
3129
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
3130
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
function decodeBodyRanges(raw) {
|
|
3134
|
+
if (!raw || raw.length === 0) return void 0;
|
|
3135
|
+
return raw.map((r) => ({
|
|
3136
|
+
start: r.start,
|
|
3137
|
+
length: r.length,
|
|
3138
|
+
mentionedUserId: r.mentioned_user_id
|
|
3139
|
+
}));
|
|
3140
|
+
}
|
|
3001
3141
|
function resolveReply(ref, lookup) {
|
|
3002
3142
|
const parent = lookup(ref.client_msg_id);
|
|
3003
3143
|
if (parent !== null) {
|
|
@@ -3209,9 +3349,9 @@ var GroupMessaging = class {
|
|
|
3209
3349
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3210
3350
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3211
3351
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3212
|
-
async sendText(group, text, replyTo) {
|
|
3352
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
3213
3353
|
const clientMsgId = mintClientMsgId();
|
|
3214
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3354
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
3215
3355
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3216
3356
|
const body = {
|
|
3217
3357
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3237,7 +3377,13 @@ var GroupMessaging = class {
|
|
|
3237
3377
|
previewBody: replyTo.preview?.body ?? null,
|
|
3238
3378
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3239
3379
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3240
|
-
} : null
|
|
3380
|
+
} : null,
|
|
3381
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3382
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3383
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
3384
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
3385
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
3386
|
+
...expiry ? { expiry } : {}
|
|
3241
3387
|
};
|
|
3242
3388
|
try {
|
|
3243
3389
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3245,6 +3391,51 @@ var GroupMessaging = class {
|
|
|
3245
3391
|
}
|
|
3246
3392
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
3247
3393
|
}
|
|
3394
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
3395
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
3396
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
3397
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
3398
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
3399
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
3400
|
+
async sendTimerSet(group, args) {
|
|
3401
|
+
const plaintext = encodeTimerSet({
|
|
3402
|
+
clientMsgId: args.clientMsgId,
|
|
3403
|
+
ttlSeconds: args.ttlSeconds,
|
|
3404
|
+
start: args.start
|
|
3405
|
+
});
|
|
3406
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3407
|
+
const body = {
|
|
3408
|
+
ciphertext_b64: toBase64(ct),
|
|
3409
|
+
client_idem_key: randomId()
|
|
3410
|
+
};
|
|
3411
|
+
const wire = await palbeRequest(
|
|
3412
|
+
this.rt,
|
|
3413
|
+
"POST",
|
|
3414
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3415
|
+
{ body }
|
|
3416
|
+
);
|
|
3417
|
+
const stored = {
|
|
3418
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3419
|
+
direction: "outgoing",
|
|
3420
|
+
text: null,
|
|
3421
|
+
senderDeviceId: this.selfDeviceId,
|
|
3422
|
+
epoch: wire.epoch,
|
|
3423
|
+
serverSeq: wire.server_seq,
|
|
3424
|
+
at: Date.now(),
|
|
3425
|
+
clientMsgId: args.clientMsgId,
|
|
3426
|
+
replyTo: null,
|
|
3427
|
+
envelopeType: "timer_set",
|
|
3428
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
3429
|
+
};
|
|
3430
|
+
try {
|
|
3431
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3432
|
+
} catch {
|
|
3433
|
+
}
|
|
3434
|
+
return {
|
|
3435
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3436
|
+
clientMsgId: args.clientMsgId
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3248
3439
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
3249
3440
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
3250
3441
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -3305,7 +3496,8 @@ var GroupMessaging = class {
|
|
|
3305
3496
|
const plaintext = encodeEdit({
|
|
3306
3497
|
clientMsgId: args.clientMsgId,
|
|
3307
3498
|
targetClientMsgId: args.targetClientMsgId,
|
|
3308
|
-
newText: args.newText
|
|
3499
|
+
newText: args.newText,
|
|
3500
|
+
bodyRanges: args.bodyRanges
|
|
3309
3501
|
});
|
|
3310
3502
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3311
3503
|
const body = {
|
|
@@ -3331,7 +3523,10 @@ var GroupMessaging = class {
|
|
|
3331
3523
|
envelopeType: "edit",
|
|
3332
3524
|
edit: {
|
|
3333
3525
|
targetClientMsgId: args.targetClientMsgId,
|
|
3334
|
-
newText: args.newText
|
|
3526
|
+
newText: args.newText,
|
|
3527
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3528
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3529
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3335
3530
|
}
|
|
3336
3531
|
};
|
|
3337
3532
|
try {
|
|
@@ -3453,6 +3648,41 @@ var GroupMessaging = class {
|
|
|
3453
3648
|
}
|
|
3454
3649
|
};
|
|
3455
3650
|
|
|
3651
|
+
// src/messaging/mention-ranges.ts
|
|
3652
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3653
|
+
const n = text.length;
|
|
3654
|
+
function splitsSurrogatePair(index) {
|
|
3655
|
+
if (index <= 0 || index >= n) return false;
|
|
3656
|
+
const before = text.charCodeAt(index - 1);
|
|
3657
|
+
const at = text.charCodeAt(index);
|
|
3658
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3659
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3660
|
+
return beforeIsHigh && atIsLow;
|
|
3661
|
+
}
|
|
3662
|
+
const survivors = [];
|
|
3663
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3664
|
+
const r = ranges[idx];
|
|
3665
|
+
if (r === void 0) continue;
|
|
3666
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3667
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3668
|
+
survivors.push({ idx, range: r });
|
|
3669
|
+
}
|
|
3670
|
+
survivors.sort((lhs, rhs) => {
|
|
3671
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3672
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3673
|
+
return lhs.idx - rhs.idx;
|
|
3674
|
+
});
|
|
3675
|
+
const kept = [];
|
|
3676
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3677
|
+
for (const s of survivors) {
|
|
3678
|
+
if (s.range.start >= prevEnd) {
|
|
3679
|
+
kept.push(s.range);
|
|
3680
|
+
prevEnd = s.range.start + s.range.length;
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
return kept;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3456
3686
|
// src/messaging/reaction-fold.ts
|
|
3457
3687
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3458
3688
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3508,6 +3738,43 @@ var ReactionFold = class {
|
|
|
3508
3738
|
}
|
|
3509
3739
|
};
|
|
3510
3740
|
|
|
3741
|
+
// src/messaging/timer-fold.ts
|
|
3742
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3743
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
3744
|
+
return aSeq <= bSeq;
|
|
3745
|
+
}
|
|
3746
|
+
var TimerFold = class {
|
|
3747
|
+
cell = null;
|
|
3748
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
3749
|
+
ingest(e) {
|
|
3750
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
3751
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
3752
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
this.cell = {
|
|
3756
|
+
orderEpoch: e.epoch,
|
|
3757
|
+
orderSeq: e.serverSeq,
|
|
3758
|
+
ttlSeconds: e.ttlSeconds,
|
|
3759
|
+
start: e.start,
|
|
3760
|
+
actor: e.actorUserId
|
|
3761
|
+
};
|
|
3762
|
+
}
|
|
3763
|
+
/**
|
|
3764
|
+
* The active chat default, or null if no timer_set has applied.
|
|
3765
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
3766
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
3767
|
+
*/
|
|
3768
|
+
active() {
|
|
3769
|
+
if (this.cell === null) return null;
|
|
3770
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
3771
|
+
}
|
|
3772
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
3773
|
+
lastActor() {
|
|
3774
|
+
return this.cell?.actor ?? null;
|
|
3775
|
+
}
|
|
3776
|
+
};
|
|
3777
|
+
|
|
3511
3778
|
// src/messaging/chat.ts
|
|
3512
3779
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3513
3780
|
var Chat = class {
|
|
@@ -3534,12 +3801,35 @@ var Chat = class {
|
|
|
3534
3801
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3535
3802
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3536
3803
|
deleteFold = new DeleteFold();
|
|
3804
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
3805
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
3806
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
3807
|
+
* no per-message expiry (disappearing T10). */
|
|
3808
|
+
timerFold = new TimerFold();
|
|
3809
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
3810
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
3811
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
3812
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
3813
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
3814
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
3815
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
3816
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
3817
|
+
* tombstone (disappearing T10). */
|
|
3818
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
3819
|
+
purgedLoaded = false;
|
|
3537
3820
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3538
3821
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3539
3822
|
suppressed = /* @__PURE__ */ new Set();
|
|
3540
3823
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3541
3824
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3542
3825
|
suppressedLoaded = false;
|
|
3826
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3827
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3828
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3829
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3830
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3831
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3832
|
+
elevatedLoaded = false;
|
|
3543
3833
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3544
3834
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3545
3835
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3551,6 +3841,14 @@ var Chat = class {
|
|
|
3551
3841
|
wired = false;
|
|
3552
3842
|
liveUnsub = null;
|
|
3553
3843
|
listeners = /* @__PURE__ */ new Set();
|
|
3844
|
+
/**
|
|
3845
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3846
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3847
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3848
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3849
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3850
|
+
*/
|
|
3851
|
+
onMentionElevation;
|
|
3554
3852
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3555
3853
|
constructor(args) {
|
|
3556
3854
|
this.backend = args.backend;
|
|
@@ -3628,7 +3926,10 @@ var Chat = class {
|
|
|
3628
3926
|
reactions: {},
|
|
3629
3927
|
replyTo: null,
|
|
3630
3928
|
edited: false,
|
|
3631
|
-
isDeleted: true
|
|
3929
|
+
isDeleted: true,
|
|
3930
|
+
mentions: [],
|
|
3931
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
3932
|
+
expiresAt: null
|
|
3632
3933
|
});
|
|
3633
3934
|
continue;
|
|
3634
3935
|
}
|
|
@@ -3664,9 +3965,22 @@ var Chat = class {
|
|
|
3664
3965
|
this.wired = true;
|
|
3665
3966
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3666
3967
|
void this.loadSuppressed();
|
|
3667
|
-
void this.
|
|
3968
|
+
void this.loadElevated();
|
|
3969
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
3668
3970
|
void this.refreshMembers();
|
|
3669
3971
|
}
|
|
3972
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
3973
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
3974
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
3975
|
+
async loadPurged() {
|
|
3976
|
+
if (this.purgedLoaded || !this._group) return;
|
|
3977
|
+
this.purgedLoaded = true;
|
|
3978
|
+
try {
|
|
3979
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
3980
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
3981
|
+
} catch {
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3670
3984
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3671
3985
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3672
3986
|
async loadSuppressed() {
|
|
@@ -3685,6 +3999,17 @@ var Chat = class {
|
|
|
3685
3999
|
} catch {
|
|
3686
4000
|
}
|
|
3687
4001
|
}
|
|
4002
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
4003
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
4004
|
+
async loadElevated() {
|
|
4005
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
4006
|
+
this.elevatedLoaded = true;
|
|
4007
|
+
try {
|
|
4008
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
4009
|
+
for (const k of keys) this.elevated.add(k);
|
|
4010
|
+
} catch {
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
3688
4013
|
async hydrateHistory() {
|
|
3689
4014
|
if (this.historyLoaded || !this._group) return;
|
|
3690
4015
|
this.historyLoaded = true;
|
|
@@ -3712,11 +4037,16 @@ var Chat = class {
|
|
|
3712
4037
|
if (this.seenKeys.has(key)) continue;
|
|
3713
4038
|
this.seenKeys.add(key);
|
|
3714
4039
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3715
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
4040
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
3716
4041
|
}
|
|
3717
|
-
this.messageList.push(
|
|
4042
|
+
this.messageList.push(
|
|
4043
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
4044
|
+
);
|
|
3718
4045
|
changed = true;
|
|
3719
4046
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
4047
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
4048
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
4049
|
+
}
|
|
3720
4050
|
}
|
|
3721
4051
|
if (changed) {
|
|
3722
4052
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3731,6 +4061,7 @@ var Chat = class {
|
|
|
3731
4061
|
return;
|
|
3732
4062
|
}
|
|
3733
4063
|
if (incoming.serverSeq <= 0) return;
|
|
4064
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
3734
4065
|
const key = this.internalKey(incoming.serverSeq);
|
|
3735
4066
|
if (this.seenKeys.has(key)) return;
|
|
3736
4067
|
this.seenKeys.add(key);
|
|
@@ -3739,6 +4070,20 @@ var Chat = class {
|
|
|
3739
4070
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
3740
4071
|
}
|
|
3741
4072
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
4073
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
4074
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
4075
|
+
if (actorUserId !== null) {
|
|
4076
|
+
this.timerFold.ingest({
|
|
4077
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
4078
|
+
start: incoming.timer.start,
|
|
4079
|
+
actorUserId,
|
|
4080
|
+
epoch: incoming.epoch,
|
|
4081
|
+
serverSeq: incoming.serverSeq,
|
|
4082
|
+
eventClientMsgId: incoming.clientMsgId
|
|
4083
|
+
});
|
|
4084
|
+
}
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
3742
4087
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
3743
4088
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3744
4089
|
if (actorUserId !== null) {
|
|
@@ -3764,7 +4109,10 @@ var Chat = class {
|
|
|
3764
4109
|
newText: incoming.edit.newText,
|
|
3765
4110
|
epoch: incoming.epoch,
|
|
3766
4111
|
serverSeq: incoming.serverSeq,
|
|
3767
|
-
eventClientMsgId: incoming.clientMsgId
|
|
4112
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
4113
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
4114
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
4115
|
+
bodyRanges: incoming.bodyRanges
|
|
3768
4116
|
},
|
|
3769
4117
|
this.authorOfTarget
|
|
3770
4118
|
);
|
|
@@ -3792,6 +4140,7 @@ var Chat = class {
|
|
|
3792
4140
|
if (incomingReplyRef) {
|
|
3793
4141
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3794
4142
|
}
|
|
4143
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3795
4144
|
const msg = {
|
|
3796
4145
|
id: this.publicId(incoming.serverSeq),
|
|
3797
4146
|
kind: this.kindOf(incoming),
|
|
@@ -3808,8 +4157,13 @@ var Chat = class {
|
|
|
3808
4157
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3809
4158
|
edited: false,
|
|
3810
4159
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3811
|
-
isDeleted: false
|
|
4160
|
+
isDeleted: false,
|
|
4161
|
+
mentions,
|
|
4162
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
4163
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
4164
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
3812
4165
|
};
|
|
4166
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3813
4167
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3814
4168
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3815
4169
|
text: incoming.text,
|
|
@@ -3819,7 +4173,10 @@ var Chat = class {
|
|
|
3819
4173
|
if (incomingClientMsgId) {
|
|
3820
4174
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3821
4175
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3822
|
-
this.deleteFold.reevaluatePending(
|
|
4176
|
+
this.deleteFold.reevaluatePending(
|
|
4177
|
+
incomingClientMsgId,
|
|
4178
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
4179
|
+
);
|
|
3823
4180
|
}
|
|
3824
4181
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3825
4182
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3828,11 +4185,200 @@ var Chat = class {
|
|
|
3828
4185
|
incoming.serverSeq
|
|
3829
4186
|
);
|
|
3830
4187
|
this.emit();
|
|
4188
|
+
void this.armPurge(
|
|
4189
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
4190
|
+
incoming.serverSeq,
|
|
4191
|
+
incomingClientMsgId
|
|
4192
|
+
);
|
|
4193
|
+
}
|
|
4194
|
+
// ── Disappearing (TTL — T10) ──
|
|
4195
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
4196
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
4197
|
+
toExpirySpec(e) {
|
|
4198
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
4199
|
+
}
|
|
4200
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
4201
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
4202
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
4203
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
4204
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
4205
|
+
defaultExpiry() {
|
|
4206
|
+
const active = this.timerFold.active();
|
|
4207
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
4208
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
4209
|
+
}
|
|
4210
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
4211
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
4212
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
4213
|
+
* UI countdown baseline. */
|
|
4214
|
+
deadlineFor(expiry) {
|
|
4215
|
+
if (!expiry) return null;
|
|
4216
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
4217
|
+
}
|
|
4218
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
4219
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
4220
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
4221
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
4222
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
4223
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
4224
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
4225
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
4226
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
4227
|
+
const group = this._group;
|
|
4228
|
+
const fresh = {
|
|
4229
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
4230
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4231
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
4232
|
+
};
|
|
4233
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
4234
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
4235
|
+
const result = remainingSeconds({
|
|
4236
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
4237
|
+
anchor: effective,
|
|
4238
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
4239
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4240
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
4241
|
+
});
|
|
4242
|
+
let purgeInSeconds;
|
|
4243
|
+
if (result.kind === "purgeNow") {
|
|
4244
|
+
purgeInSeconds = 0;
|
|
4245
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
4246
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
4247
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
4248
|
+
} else {
|
|
4249
|
+
purgeInSeconds = result.seconds;
|
|
4250
|
+
}
|
|
4251
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4252
|
+
if (prior) clearTimeout(prior);
|
|
4253
|
+
this.purgeTimers.delete(serverSeq);
|
|
4254
|
+
if (purgeInSeconds <= 0) {
|
|
4255
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4256
|
+
return;
|
|
4257
|
+
}
|
|
4258
|
+
const handle = setTimeout(() => {
|
|
4259
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4260
|
+
}, purgeInSeconds * 1e3);
|
|
4261
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4262
|
+
}
|
|
4263
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
4264
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
4265
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
4266
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
4267
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
4268
|
+
if (!this._group) return;
|
|
4269
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
4270
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4271
|
+
if (prior) clearTimeout(prior);
|
|
4272
|
+
this.purgeTimers.delete(serverSeq);
|
|
4273
|
+
if (remainingMs <= 0) {
|
|
4274
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
const handle = setTimeout(() => {
|
|
4278
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4279
|
+
}, remainingMs);
|
|
4280
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4281
|
+
}
|
|
4282
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
4283
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
4284
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
4285
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
4286
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
4287
|
+
async purge(serverSeq, clientMsgId) {
|
|
4288
|
+
if (!this._group) return;
|
|
4289
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4290
|
+
if (prior) clearTimeout(prior);
|
|
4291
|
+
this.purgeTimers.delete(serverSeq);
|
|
4292
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
4293
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
4294
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
4295
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
4296
|
+
this.emit();
|
|
4297
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
4298
|
+
if (clientMsgId) {
|
|
4299
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
4303
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
4304
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
4305
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
4306
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
4307
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
4308
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
4309
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
4310
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
4311
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
4312
|
+
};
|
|
4313
|
+
// ── Mentions (mentions T6) ──
|
|
4314
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
4315
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
4316
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
4317
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
4318
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
4319
|
+
resolveMentions(text, bodyRanges) {
|
|
4320
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
4321
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
4322
|
+
if (normalized.length === 0) return [];
|
|
4323
|
+
return normalized.map((r) => ({
|
|
4324
|
+
start: r.start,
|
|
4325
|
+
length: r.length,
|
|
4326
|
+
mentionedUserId: r.mentionedUserId,
|
|
4327
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
4328
|
+
}));
|
|
4329
|
+
}
|
|
4330
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
4331
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
4332
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
4333
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
4334
|
+
resolveMentionNames(m) {
|
|
4335
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
4336
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
4337
|
+
}
|
|
4338
|
+
let changed = false;
|
|
4339
|
+
const reresolved = m.mentions.map((span) => {
|
|
4340
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
4341
|
+
if (name === span.displayName) return span;
|
|
4342
|
+
changed = true;
|
|
4343
|
+
return { ...span, displayName: name };
|
|
4344
|
+
});
|
|
4345
|
+
if (!changed) return m;
|
|
4346
|
+
return { ...m, mentions: reresolved };
|
|
4347
|
+
}
|
|
4348
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
4349
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
4350
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
4351
|
+
editMentions(targetClientMsgId, newText) {
|
|
4352
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
4353
|
+
if (!ranges) return [];
|
|
4354
|
+
return this.resolveMentions(newText, ranges);
|
|
4355
|
+
}
|
|
4356
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
4357
|
+
displayNameOf(userId) {
|
|
4358
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
4359
|
+
}
|
|
4360
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
4361
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
4362
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
4363
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
4364
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
4365
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
4366
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
4367
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
4368
|
+
const me = this.backend.selfUserId;
|
|
4369
|
+
if (envelopeType === "edit") return;
|
|
4370
|
+
if (senderUserId === me) return;
|
|
4371
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
4372
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4373
|
+
const key = `${me}|${idPart}`;
|
|
4374
|
+
if (this.elevated.has(key)) return;
|
|
4375
|
+
this.elevated.add(key);
|
|
4376
|
+
if (this._group) {
|
|
4377
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
4378
|
+
});
|
|
4379
|
+
}
|
|
4380
|
+
this.onMentionElevation?.(message);
|
|
3831
4381
|
}
|
|
3832
|
-
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3833
|
-
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3834
|
-
* so it can be passed to the pure EditFold. */
|
|
3835
|
-
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3836
4382
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3837
4383
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3838
4384
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3889,9 +4435,10 @@ var Chat = class {
|
|
|
3889
4435
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3890
4436
|
const text = editText ?? base;
|
|
3891
4437
|
const edited = foldEdited || m.edited;
|
|
3892
|
-
|
|
4438
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
4439
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3893
4440
|
changed = true;
|
|
3894
|
-
return { ...m, text, edited };
|
|
4441
|
+
return { ...m, text, edited, mentions };
|
|
3895
4442
|
});
|
|
3896
4443
|
if (changed) this.emit();
|
|
3897
4444
|
}
|
|
@@ -3908,8 +4455,9 @@ var Chat = class {
|
|
|
3908
4455
|
if (editText === null && !foldEdited) return m;
|
|
3909
4456
|
const text = editText ?? m.text;
|
|
3910
4457
|
const edited = foldEdited || m.edited;
|
|
3911
|
-
|
|
3912
|
-
|
|
4458
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
4459
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
4460
|
+
return { ...m, text, edited, mentions };
|
|
3913
4461
|
}
|
|
3914
4462
|
/** @internal — called by the backend's conv subscription. */
|
|
3915
4463
|
applyConv(event, payload) {
|
|
@@ -3967,6 +4515,18 @@ var Chat = class {
|
|
|
3967
4515
|
}
|
|
3968
4516
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3969
4517
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
4518
|
+
this.reresolveAllMentionNames();
|
|
4519
|
+
}
|
|
4520
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
4521
|
+
* change). Re-emits only if any name actually changed. */
|
|
4522
|
+
reresolveAllMentionNames() {
|
|
4523
|
+
let changed = false;
|
|
4524
|
+
this.messageList = this.messageList.map((m) => {
|
|
4525
|
+
const reresolved = this.resolveMentionNames(m);
|
|
4526
|
+
if (reresolved !== m) changed = true;
|
|
4527
|
+
return reresolved;
|
|
4528
|
+
});
|
|
4529
|
+
if (changed) this.emit();
|
|
3970
4530
|
}
|
|
3971
4531
|
seedMembersFromGroup(group) {
|
|
3972
4532
|
const seed = [
|
|
@@ -4034,11 +4594,50 @@ var Chat = class {
|
|
|
4034
4594
|
};
|
|
4035
4595
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
4036
4596
|
}
|
|
4037
|
-
const
|
|
4038
|
-
|
|
4597
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4598
|
+
const start = opts?.expiresIn?.start ?? "send";
|
|
4599
|
+
const expiry = opts?.expiresIn ? {
|
|
4600
|
+
v: 1,
|
|
4601
|
+
ttlSeconds: opts.expiresIn.ttlSeconds,
|
|
4602
|
+
start,
|
|
4603
|
+
senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
4604
|
+
} : null;
|
|
4605
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
4606
|
+
group,
|
|
4607
|
+
text,
|
|
4608
|
+
replyRef,
|
|
4609
|
+
bodyRanges,
|
|
4610
|
+
expiry
|
|
4611
|
+
);
|
|
4612
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
4613
|
+
if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
|
|
4039
4614
|
return receipt;
|
|
4040
4615
|
}
|
|
4041
|
-
|
|
4616
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
4617
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
4618
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
4619
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
4620
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
4621
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
4622
|
+
async setDisappearing(opts) {
|
|
4623
|
+
const group = await this.materializeIfNeeded();
|
|
4624
|
+
const clientMsgId = mintClientMsgId();
|
|
4625
|
+
const start = opts.start ?? "send";
|
|
4626
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
4627
|
+
clientMsgId,
|
|
4628
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4629
|
+
start
|
|
4630
|
+
});
|
|
4631
|
+
this.timerFold.ingest({
|
|
4632
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4633
|
+
start,
|
|
4634
|
+
actorUserId: this.backend.selfUserId,
|
|
4635
|
+
epoch: receipt.epoch,
|
|
4636
|
+
serverSeq: receipt.serverSeq,
|
|
4637
|
+
eventClientMsgId: clientMsgId
|
|
4638
|
+
});
|
|
4639
|
+
}
|
|
4640
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
4042
4641
|
if (receipt.serverSeq <= 0) return;
|
|
4043
4642
|
const key = this.internalKey(receipt.serverSeq);
|
|
4044
4643
|
if (this.seenKeys.has(key)) return;
|
|
@@ -4063,7 +4662,13 @@ var Chat = class {
|
|
|
4063
4662
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
4064
4663
|
edited: false,
|
|
4065
4664
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4066
|
-
isDeleted: false
|
|
4665
|
+
isDeleted: false,
|
|
4666
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4667
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4668
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
4669
|
+
// Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
|
|
4670
|
+
// via the messageList overlay; default null here (a plain own-send has no deadline).
|
|
4671
|
+
expiresAt: null
|
|
4067
4672
|
});
|
|
4068
4673
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
4069
4674
|
this.emit();
|
|
@@ -4151,15 +4756,18 @@ var Chat = class {
|
|
|
4151
4756
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
4152
4757
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
4153
4758
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
4154
|
-
* message self IS the author, so the author-gate passes.
|
|
4155
|
-
|
|
4759
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4760
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4761
|
+
async edit(message, newText, opts) {
|
|
4156
4762
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
4157
4763
|
const group = await this.materializeIfNeeded();
|
|
4158
4764
|
const clientMsgId = mintClientMsgId();
|
|
4765
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4159
4766
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
4160
4767
|
clientMsgId,
|
|
4161
4768
|
targetClientMsgId: message.clientMsgId,
|
|
4162
|
-
newText
|
|
4769
|
+
newText,
|
|
4770
|
+
bodyRanges
|
|
4163
4771
|
});
|
|
4164
4772
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4165
4773
|
this.editFold.ingest(
|
|
@@ -4169,7 +4777,8 @@ var Chat = class {
|
|
|
4169
4777
|
newText,
|
|
4170
4778
|
epoch: receipt.epoch,
|
|
4171
4779
|
serverSeq: receipt.serverSeq,
|
|
4172
|
-
eventClientMsgId: clientMsgId
|
|
4780
|
+
eventClientMsgId: clientMsgId,
|
|
4781
|
+
bodyRanges
|
|
4173
4782
|
},
|
|
4174
4783
|
this.authorOfTarget
|
|
4175
4784
|
);
|
|
@@ -4222,6 +4831,18 @@ var Chat = class {
|
|
|
4222
4831
|
}
|
|
4223
4832
|
}
|
|
4224
4833
|
};
|
|
4834
|
+
function sameMentions(a, b) {
|
|
4835
|
+
if (a.length !== b.length) return false;
|
|
4836
|
+
for (let i = 0; i < a.length; i++) {
|
|
4837
|
+
const x = a[i];
|
|
4838
|
+
const y = b[i];
|
|
4839
|
+
if (!x || !y) return false;
|
|
4840
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4841
|
+
return false;
|
|
4842
|
+
}
|
|
4843
|
+
}
|
|
4844
|
+
return true;
|
|
4845
|
+
}
|
|
4225
4846
|
function sameReactions(a, b) {
|
|
4226
4847
|
const ak = Object.keys(a);
|
|
4227
4848
|
const bk = Object.keys(b);
|
|
@@ -4402,6 +5023,7 @@ var MessageDeliverySource = class {
|
|
|
4402
5023
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4403
5024
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4404
5025
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
5026
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
4405
5027
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4406
5028
|
const stored = {
|
|
4407
5029
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4433,14 +5055,21 @@ var MessageDeliverySource = class {
|
|
|
4433
5055
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4434
5056
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4435
5057
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4436
|
-
// `'text'`/no-edit (backward-compat).
|
|
5058
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
5059
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
4437
5060
|
...isEdit && decoded.edit ? {
|
|
4438
5061
|
envelopeType: "edit",
|
|
4439
5062
|
edit: {
|
|
4440
5063
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4441
|
-
newText: decoded.edit.newText
|
|
5064
|
+
newText: decoded.edit.newText,
|
|
5065
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4442
5066
|
}
|
|
4443
5067
|
} : {},
|
|
5068
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
5069
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
5070
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
5071
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
5072
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4444
5073
|
// Thread the delete discriminator + target through the persisted row so a
|
|
4445
5074
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4446
5075
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -4452,7 +5081,18 @@ var MessageDeliverySource = class {
|
|
|
4452
5081
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4453
5082
|
scope: decoded.delete.scope
|
|
4454
5083
|
}
|
|
4455
|
-
} : {}
|
|
5084
|
+
} : {},
|
|
5085
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
5086
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
5087
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
5088
|
+
...isTimerSet && decoded.timer ? {
|
|
5089
|
+
envelopeType: "timer_set",
|
|
5090
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
5091
|
+
} : {},
|
|
5092
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
5093
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
5094
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
5095
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
4456
5096
|
};
|
|
4457
5097
|
try {
|
|
4458
5098
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -4472,7 +5112,15 @@ var MessageDeliverySource = class {
|
|
|
4472
5112
|
envelopeType: decoded.type ?? "text",
|
|
4473
5113
|
reaction: isReaction ? decoded.reaction : null,
|
|
4474
5114
|
edit: isEdit ? decoded.edit : null,
|
|
4475
|
-
delete: isDelete ? decoded.delete : null
|
|
5115
|
+
delete: isDelete ? decoded.delete : null,
|
|
5116
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
5117
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
5118
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
5119
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
5120
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
5121
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
5122
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
5123
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
4476
5124
|
});
|
|
4477
5125
|
return true;
|
|
4478
5126
|
}
|
|
@@ -4551,6 +5199,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
4551
5199
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
4552
5200
|
}
|
|
4553
5201
|
|
|
5202
|
+
// src/messaging/disappearing.ts
|
|
5203
|
+
var DisappearingStore = class {
|
|
5204
|
+
constructor(kv) {
|
|
5205
|
+
this.kv = kv;
|
|
5206
|
+
}
|
|
5207
|
+
kv;
|
|
5208
|
+
key(rfc) {
|
|
5209
|
+
return `disappear:${rfc}`;
|
|
5210
|
+
}
|
|
5211
|
+
async load(rfc) {
|
|
5212
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
5213
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5214
|
+
try {
|
|
5215
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
5216
|
+
return {
|
|
5217
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
5218
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
5219
|
+
anchors: r.anchors ?? {}
|
|
5220
|
+
};
|
|
5221
|
+
} catch {
|
|
5222
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
5225
|
+
async save(rfc, rec) {
|
|
5226
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
5227
|
+
}
|
|
5228
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
5229
|
+
async tombstonedSeqs(rfc) {
|
|
5230
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
5231
|
+
}
|
|
5232
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
5233
|
+
async purgedClientMsgIds(rfc) {
|
|
5234
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
5235
|
+
}
|
|
5236
|
+
/**
|
|
5237
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
5238
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
5239
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
5240
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
5241
|
+
*/
|
|
5242
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
5243
|
+
const rec = await this.load(rfc);
|
|
5244
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
5245
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
5246
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
5247
|
+
}
|
|
5248
|
+
await this.save(rfc, rec);
|
|
5249
|
+
}
|
|
5250
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
5251
|
+
async anchor(rfc, clientMsgId) {
|
|
5252
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
5253
|
+
}
|
|
5254
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
5255
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
5256
|
+
const rec = await this.load(rfc);
|
|
5257
|
+
if (rec.anchors[clientMsgId]) return;
|
|
5258
|
+
rec.anchors[clientMsgId] = a;
|
|
5259
|
+
await this.save(rfc, rec);
|
|
5260
|
+
}
|
|
5261
|
+
};
|
|
5262
|
+
|
|
4554
5263
|
// src/messaging/history.ts
|
|
4555
5264
|
var MessageStore = class {
|
|
4556
5265
|
constructor(kv) {
|
|
@@ -4627,6 +5336,36 @@ var GroupCatalog = class {
|
|
|
4627
5336
|
}
|
|
4628
5337
|
};
|
|
4629
5338
|
|
|
5339
|
+
// src/messaging/mention-elevation.ts
|
|
5340
|
+
var MentionElevationStore = class {
|
|
5341
|
+
constructor(kv) {
|
|
5342
|
+
this.kv = kv;
|
|
5343
|
+
}
|
|
5344
|
+
kv;
|
|
5345
|
+
key(rfcGroupId) {
|
|
5346
|
+
return `elev:${rfcGroupId}`;
|
|
5347
|
+
}
|
|
5348
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
5349
|
+
async load(rfcGroupId) {
|
|
5350
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
5351
|
+
if (!raw) return [];
|
|
5352
|
+
try {
|
|
5353
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
5354
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5355
|
+
} catch {
|
|
5356
|
+
return [];
|
|
5357
|
+
}
|
|
5358
|
+
}
|
|
5359
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
5360
|
+
async save(rfcGroupId, keys) {
|
|
5361
|
+
const sorted = [...new Set(keys)].sort();
|
|
5362
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
5363
|
+
}
|
|
5364
|
+
async wipe() {
|
|
5365
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
5366
|
+
}
|
|
5367
|
+
};
|
|
5368
|
+
|
|
4630
5369
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4631
5370
|
var palbe_mls_bg_exports = {};
|
|
4632
5371
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6334,6 +7073,8 @@ var MessagingCoordinator = class {
|
|
|
6334
7073
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6335
7074
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6336
7075
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
7076
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
7077
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
6337
7078
|
this.registry.attachChatList(
|
|
6338
7079
|
(chats) => {
|
|
6339
7080
|
this.chatList = chats;
|
|
@@ -6349,6 +7090,8 @@ var MessagingCoordinator = class {
|
|
|
6349
7090
|
groupStore;
|
|
6350
7091
|
kpStore;
|
|
6351
7092
|
suppressionStore;
|
|
7093
|
+
elevationStore;
|
|
7094
|
+
disappearingStore;
|
|
6352
7095
|
registry = new GroupRegistry();
|
|
6353
7096
|
resolved = null;
|
|
6354
7097
|
resolvePromise = null;
|
|
@@ -6504,9 +7247,9 @@ var MessagingCoordinator = class {
|
|
|
6504
7247
|
});
|
|
6505
7248
|
return group;
|
|
6506
7249
|
}
|
|
6507
|
-
async sendText(group, text, replyTo) {
|
|
7250
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6508
7251
|
const r = await this.resolve();
|
|
6509
|
-
return r.groups.sendText(group, text, replyTo);
|
|
7252
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6510
7253
|
}
|
|
6511
7254
|
async sendReaction(group, args) {
|
|
6512
7255
|
const r = await this.resolve();
|
|
@@ -6520,6 +7263,10 @@ var MessagingCoordinator = class {
|
|
|
6520
7263
|
const r = await this.resolve();
|
|
6521
7264
|
return r.groups.sendDelete(group, args);
|
|
6522
7265
|
}
|
|
7266
|
+
async sendTimerSet(group, args) {
|
|
7267
|
+
const r = await this.resolve();
|
|
7268
|
+
return r.groups.sendTimerSet(group, args);
|
|
7269
|
+
}
|
|
6523
7270
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6524
7271
|
loadSuppressed(group) {
|
|
6525
7272
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -6528,10 +7275,36 @@ var MessagingCoordinator = class {
|
|
|
6528
7275
|
saveSuppressed(group, keys) {
|
|
6529
7276
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6530
7277
|
}
|
|
7278
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
7279
|
+
loadElevated(group) {
|
|
7280
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
7281
|
+
}
|
|
7282
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
7283
|
+
saveElevated(group, keys) {
|
|
7284
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
7285
|
+
}
|
|
7286
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
7287
|
+
tombstonedSeqs(group) {
|
|
7288
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7289
|
+
}
|
|
7290
|
+
purgedClientMsgIds(group) {
|
|
7291
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7292
|
+
}
|
|
7293
|
+
anchor(group, clientMsgId) {
|
|
7294
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
7295
|
+
}
|
|
7296
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
7297
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
7298
|
+
}
|
|
7299
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
7300
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
7301
|
+
}
|
|
6531
7302
|
async history(group, limit, before) {
|
|
6532
7303
|
const r = await this.resolve();
|
|
6533
7304
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
6534
|
-
|
|
7305
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7306
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7307
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
6535
7308
|
}
|
|
6536
7309
|
async members(group) {
|
|
6537
7310
|
const r = await this.resolve();
|
|
@@ -6608,9 +7381,10 @@ var MessagingCoordinator = class {
|
|
|
6608
7381
|
return res.devices.map((d) => d.device_id);
|
|
6609
7382
|
}
|
|
6610
7383
|
};
|
|
6611
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
7384
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
7385
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
6612
7386
|
const fold = new ReactionFold();
|
|
6613
|
-
for (const s of
|
|
7387
|
+
for (const s of visible) {
|
|
6614
7388
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
6615
7389
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6616
7390
|
if (actor === null) continue;
|
|
@@ -6626,17 +7400,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6626
7400
|
}
|
|
6627
7401
|
const editFold = new EditFold();
|
|
6628
7402
|
const deleteFold = new DeleteFold();
|
|
7403
|
+
const pageTimerFold = new TimerFold();
|
|
6629
7404
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6630
|
-
for (const s of
|
|
6631
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7405
|
+
for (const s of visible) {
|
|
7406
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6632
7407
|
continue;
|
|
6633
7408
|
const cid = s.clientMsgId ?? "";
|
|
6634
7409
|
if (!cid) continue;
|
|
6635
7410
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6636
7411
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6637
7412
|
}
|
|
6638
|
-
const authorOfTarget = (cid) =>
|
|
6639
|
-
|
|
7413
|
+
const authorOfTarget = (cid) => {
|
|
7414
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
7415
|
+
const a = authorByClientMsgId.get(cid);
|
|
7416
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
7417
|
+
};
|
|
7418
|
+
for (const s of visible) {
|
|
6640
7419
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6641
7420
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6642
7421
|
editFold.ingest(
|
|
@@ -6646,13 +7425,16 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6646
7425
|
newText: s.edit.newText,
|
|
6647
7426
|
epoch: s.epoch,
|
|
6648
7427
|
serverSeq: s.serverSeq,
|
|
6649
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
7428
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
7429
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
7430
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
7431
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6650
7432
|
},
|
|
6651
7433
|
authorOfTarget
|
|
6652
7434
|
);
|
|
6653
7435
|
}
|
|
6654
7436
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6655
|
-
for (const s of
|
|
7437
|
+
for (const s of visible) {
|
|
6656
7438
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6657
7439
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6658
7440
|
deleteFold.ingest(
|
|
@@ -6666,10 +7448,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6666
7448
|
authorOfTarget
|
|
6667
7449
|
);
|
|
6668
7450
|
}
|
|
6669
|
-
for (const [cid
|
|
7451
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
6670
7452
|
const lookup = /* @__PURE__ */ new Map();
|
|
6671
|
-
for (const s of
|
|
6672
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7453
|
+
for (const s of visible) {
|
|
7454
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6673
7455
|
continue;
|
|
6674
7456
|
const cid = s.clientMsgId ?? "";
|
|
6675
7457
|
if (cid && s.text !== null) {
|
|
@@ -6678,7 +7460,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6678
7460
|
}
|
|
6679
7461
|
}
|
|
6680
7462
|
const out = [];
|
|
6681
|
-
for (const s of
|
|
7463
|
+
for (const s of visible) {
|
|
7464
|
+
if (s.envelopeType === "timer_set") {
|
|
7465
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
7466
|
+
if (actor !== null && s.timer) {
|
|
7467
|
+
pageTimerFold.ingest({
|
|
7468
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
7469
|
+
start: s.timer.start,
|
|
7470
|
+
actorUserId: actor,
|
|
7471
|
+
epoch: s.epoch,
|
|
7472
|
+
serverSeq: s.serverSeq,
|
|
7473
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
7474
|
+
});
|
|
7475
|
+
}
|
|
7476
|
+
continue;
|
|
7477
|
+
}
|
|
6682
7478
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6683
7479
|
continue;
|
|
6684
7480
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -6696,10 +7492,23 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6696
7492
|
replyTo: null,
|
|
6697
7493
|
reactions: {},
|
|
6698
7494
|
edited: false,
|
|
6699
|
-
isDeleted: true
|
|
7495
|
+
isDeleted: true,
|
|
7496
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
7497
|
+
mentions: [],
|
|
7498
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
7499
|
+
expiresAt: null
|
|
6700
7500
|
});
|
|
6701
7501
|
continue;
|
|
6702
7502
|
}
|
|
7503
|
+
let expiresAt = null;
|
|
7504
|
+
if (s.expiry) {
|
|
7505
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
7506
|
+
} else {
|
|
7507
|
+
const active = pageTimerFold.active();
|
|
7508
|
+
if (active && active.ttlSeconds !== null) {
|
|
7509
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
7510
|
+
}
|
|
7511
|
+
}
|
|
6703
7512
|
let replyTo = null;
|
|
6704
7513
|
if (s.replyTo) {
|
|
6705
7514
|
const ref = {
|
|
@@ -6716,23 +7525,37 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6716
7525
|
}
|
|
6717
7526
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6718
7527
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
7528
|
+
const text = editText ?? s.text;
|
|
7529
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
7530
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6719
7531
|
out.push({
|
|
6720
7532
|
id: `${displayId}#${s.serverSeq}`,
|
|
6721
7533
|
kind: s.text != null ? "text" : "system",
|
|
6722
7534
|
direction: s.direction,
|
|
6723
7535
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6724
|
-
text
|
|
7536
|
+
text,
|
|
6725
7537
|
serverSeq: s.serverSeq,
|
|
6726
7538
|
sentAt: new Date(s.at),
|
|
6727
7539
|
clientMsgId,
|
|
6728
7540
|
replyTo,
|
|
6729
7541
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6730
7542
|
edited,
|
|
6731
|
-
isDeleted: false
|
|
7543
|
+
isDeleted: false,
|
|
7544
|
+
mentions,
|
|
7545
|
+
expiresAt
|
|
6732
7546
|
});
|
|
6733
7547
|
}
|
|
6734
7548
|
return out;
|
|
6735
7549
|
}
|
|
7550
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
7551
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
7552
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
7553
|
+
start: r.start,
|
|
7554
|
+
length: r.length,
|
|
7555
|
+
mentionedUserId: r.mentionedUserId,
|
|
7556
|
+
displayName: null
|
|
7557
|
+
}));
|
|
7558
|
+
}
|
|
6736
7559
|
|
|
6737
7560
|
// src/messaging/facade.ts
|
|
6738
7561
|
var PalbeMessaging = class {
|
|
@@ -7466,7 +8289,7 @@ function defaultSessionStorage(key) {
|
|
|
7466
8289
|
}
|
|
7467
8290
|
|
|
7468
8291
|
// src/version.ts
|
|
7469
|
-
var VERSION = "1.
|
|
8292
|
+
var VERSION = "1.6.0";
|
|
7470
8293
|
|
|
7471
8294
|
// src/runtime.ts
|
|
7472
8295
|
function buildRuntime(config) {
|