@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
|
@@ -2591,6 +2591,47 @@ var PalbeFlags = class {
|
|
|
2591
2591
|
}
|
|
2592
2592
|
};
|
|
2593
2593
|
|
|
2594
|
+
// src/messaging/deadline-calculator.ts
|
|
2595
|
+
function remainingSeconds(args) {
|
|
2596
|
+
const ttl = args.ttlSeconds;
|
|
2597
|
+
const wallDeltaSec = (args.nowWallEpochMs - args.anchor.wAnchorEpochMs) / 1e3;
|
|
2598
|
+
let elapsed;
|
|
2599
|
+
if (args.nowBootToken === args.anchor.bAnchorToken) {
|
|
2600
|
+
const monoDeltaSec = Math.max(0, args.nowMonotonicMs - args.anchor.mAnchorMs) / 1e3;
|
|
2601
|
+
elapsed = Math.max(monoDeltaSec, wallDeltaSec);
|
|
2602
|
+
} else {
|
|
2603
|
+
elapsed = wallDeltaSec;
|
|
2604
|
+
}
|
|
2605
|
+
const remaining = Math.min(ttl, ttl - elapsed);
|
|
2606
|
+
return remaining <= 0 ? { kind: "purgeNow" } : { kind: "remaining", seconds: remaining };
|
|
2607
|
+
}
|
|
2608
|
+
var cachedBootToken = null;
|
|
2609
|
+
var MonotonicClock = {
|
|
2610
|
+
nowMs() {
|
|
2611
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2612
|
+
},
|
|
2613
|
+
nowWallEpochMs() {
|
|
2614
|
+
return Date.now();
|
|
2615
|
+
},
|
|
2616
|
+
bootToken() {
|
|
2617
|
+
if (cachedBootToken !== null) return cachedBootToken;
|
|
2618
|
+
try {
|
|
2619
|
+
const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("pb_boot_token") : null;
|
|
2620
|
+
if (existing) {
|
|
2621
|
+
cachedBootToken = existing;
|
|
2622
|
+
return existing;
|
|
2623
|
+
}
|
|
2624
|
+
const fresh = crypto.randomUUID();
|
|
2625
|
+
if (typeof sessionStorage !== "undefined") sessionStorage.setItem("pb_boot_token", fresh);
|
|
2626
|
+
cachedBootToken = fresh;
|
|
2627
|
+
return fresh;
|
|
2628
|
+
} catch {
|
|
2629
|
+
cachedBootToken = crypto.randomUUID();
|
|
2630
|
+
return cachedBootToken;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
|
|
2594
2635
|
// src/messaging/delete-fold.ts
|
|
2595
2636
|
var DeleteFold = class {
|
|
2596
2637
|
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
@@ -2603,17 +2644,24 @@ var DeleteFold = class {
|
|
|
2603
2644
|
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2604
2645
|
held = [];
|
|
2605
2646
|
/**
|
|
2606
|
-
* Ingest one tombstone. `authorOfTarget` resolves the target message's
|
|
2607
|
-
*
|
|
2647
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's author via
|
|
2648
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → defer
|
|
2649
|
+
* (target absent locally → pending/held); `'purged'` → NO-OP, mark seen (the target
|
|
2650
|
+
* was TTL-purged — a delete of an already-gone message is already satisfied; never
|
|
2651
|
+
* park in pending, never re-attempt).
|
|
2608
2652
|
*/
|
|
2609
2653
|
ingest(e, authorOfTarget) {
|
|
2610
2654
|
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2611
2655
|
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2612
2656
|
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2613
|
-
const
|
|
2614
|
-
if (
|
|
2657
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2658
|
+
if (res.kind === "purged") {
|
|
2615
2659
|
this.seen.add(e.eventClientMsgId);
|
|
2616
|
-
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
if (res.kind === "author") {
|
|
2663
|
+
this.seen.add(e.eventClientMsgId);
|
|
2664
|
+
if (e.actorUserId === null || e.actorUserId !== res.userId) return;
|
|
2617
2665
|
this.tombstoned.add(e.targetClientMsgId);
|
|
2618
2666
|
} else if (e.actorUserId !== null) {
|
|
2619
2667
|
this.seen.add(e.eventClientMsgId);
|
|
@@ -2633,12 +2681,13 @@ var DeleteFold = class {
|
|
|
2633
2681
|
* the in-order path.
|
|
2634
2682
|
*/
|
|
2635
2683
|
reevaluatePending(target, author) {
|
|
2684
|
+
const res = author;
|
|
2636
2685
|
const actor = this.pending.get(target);
|
|
2637
2686
|
if (actor !== void 0) {
|
|
2638
|
-
if (
|
|
2639
|
-
this.tombstoned.add(target);
|
|
2687
|
+
if (res.kind === "author") {
|
|
2688
|
+
if (actor === res.userId) this.tombstoned.add(target);
|
|
2640
2689
|
this.pending.delete(target);
|
|
2641
|
-
} else if (
|
|
2690
|
+
} else if (res.kind === "purged") {
|
|
2642
2691
|
this.pending.delete(target);
|
|
2643
2692
|
}
|
|
2644
2693
|
}
|
|
@@ -2646,7 +2695,7 @@ var DeleteFold = class {
|
|
|
2646
2695
|
const pendingHeld = this.held;
|
|
2647
2696
|
this.held = [];
|
|
2648
2697
|
for (const e of pendingHeld) {
|
|
2649
|
-
this.ingest(e, (t) => t === target ?
|
|
2698
|
+
this.ingest(e, (t) => t === target ? res : { kind: "unknown" });
|
|
2650
2699
|
}
|
|
2651
2700
|
}
|
|
2652
2701
|
heldContains(eventClientMsgId) {
|
|
@@ -2672,15 +2721,23 @@ var EditFold = class {
|
|
|
2672
2721
|
// targets that have had ≥1 valid edit applied (write-once)
|
|
2673
2722
|
editedTargets = /* @__PURE__ */ new Set();
|
|
2674
2723
|
/**
|
|
2675
|
-
* Ingest one edit. `authorOfTarget` resolves the target message's
|
|
2676
|
-
*
|
|
2724
|
+
* Ingest one edit. `authorOfTarget` resolves the target message's author via
|
|
2725
|
+
* {@link AuthorResolution}: `'author'` → run the author-gate; `'unknown'` → HOLD
|
|
2726
|
+
* (target/author not yet known); `'purged'` → DROP (the target was TTL-purged —
|
|
2727
|
+
* editing a disappeared message is a no-op; mark the event seen so it never re-holds
|
|
2728
|
+
* and a later author "resolution" cannot resurrect it).
|
|
2677
2729
|
*/
|
|
2678
2730
|
ingest(e, authorOfTarget) {
|
|
2679
|
-
const
|
|
2680
|
-
if (
|
|
2731
|
+
const res = authorOfTarget(e.targetClientMsgId);
|
|
2732
|
+
if (res.kind === "unknown") {
|
|
2681
2733
|
this.holdIfNew(e);
|
|
2682
2734
|
return;
|
|
2683
2735
|
}
|
|
2736
|
+
if (res.kind === "purged") {
|
|
2737
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
const author = res.userId;
|
|
2684
2741
|
if (e.editorUserId === null) {
|
|
2685
2742
|
this.holdIfNew(e);
|
|
2686
2743
|
return;
|
|
@@ -2699,7 +2756,8 @@ var EditFold = class {
|
|
|
2699
2756
|
orderEpoch: e.epoch,
|
|
2700
2757
|
orderSeq: e.serverSeq,
|
|
2701
2758
|
lastEventId: e.eventClientMsgId,
|
|
2702
|
-
text: e.newText
|
|
2759
|
+
text: e.newText,
|
|
2760
|
+
bodyRanges: e.bodyRanges ?? null
|
|
2703
2761
|
});
|
|
2704
2762
|
this.editedTargets.add(e.targetClientMsgId);
|
|
2705
2763
|
}
|
|
@@ -2720,6 +2778,15 @@ var EditFold = class {
|
|
|
2720
2778
|
isEdited(targetClientMsgId) {
|
|
2721
2779
|
return this.editedTargets.has(targetClientMsgId);
|
|
2722
2780
|
}
|
|
2781
|
+
/**
|
|
2782
|
+
* The WINNING edit's replacement mention ranges for a target (raw, un-normalized),
|
|
2783
|
+
* or null when no valid edit applied or the winning edit carried none. The Chat
|
|
2784
|
+
* normalizes these against the edited text to compute the edited message's mentions
|
|
2785
|
+
* (mentions T6). LWW-consistent: always the same edit that `text(...)` returns.
|
|
2786
|
+
*/
|
|
2787
|
+
bodyRanges(targetClientMsgId) {
|
|
2788
|
+
return this.states.get(targetClientMsgId)?.bodyRanges ?? null;
|
|
2789
|
+
}
|
|
2723
2790
|
/**
|
|
2724
2791
|
* Re-run HELD edits when the roster/target newly resolves (call on member/roster
|
|
2725
2792
|
* change and when a target message arrives). Clears `held` and re-ingests each
|
|
@@ -2893,7 +2960,14 @@ function encodeEdit(args) {
|
|
|
2893
2960
|
type: "edit",
|
|
2894
2961
|
client_msg_id: args.clientMsgId,
|
|
2895
2962
|
target_client_msg_id: args.targetClientMsgId,
|
|
2896
|
-
new_text: args.newText
|
|
2963
|
+
new_text: args.newText,
|
|
2964
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2965
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2966
|
+
start: r.start,
|
|
2967
|
+
length: r.length,
|
|
2968
|
+
mentioned_user_id: r.mentionedUserId
|
|
2969
|
+
}))
|
|
2970
|
+
} : {}
|
|
2897
2971
|
})
|
|
2898
2972
|
);
|
|
2899
2973
|
}
|
|
@@ -2915,14 +2989,53 @@ function encodeEnvelope(args) {
|
|
|
2915
2989
|
type: "text",
|
|
2916
2990
|
client_msg_id: args.clientMsgId,
|
|
2917
2991
|
text: args.text,
|
|
2918
|
-
...args.replyTo ? { reply_to: args.replyTo } : {}
|
|
2992
|
+
...args.replyTo ? { reply_to: args.replyTo } : {},
|
|
2993
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? {
|
|
2994
|
+
body_ranges: args.bodyRanges.map((r) => ({
|
|
2995
|
+
start: r.start,
|
|
2996
|
+
length: r.length,
|
|
2997
|
+
mentioned_user_id: r.mentionedUserId
|
|
2998
|
+
}))
|
|
2999
|
+
} : {},
|
|
3000
|
+
...args.expiry ? {
|
|
3001
|
+
expiry: {
|
|
3002
|
+
v: args.expiry.v,
|
|
3003
|
+
ttl_seconds: args.expiry.ttlSeconds,
|
|
3004
|
+
start: args.expiry.start,
|
|
3005
|
+
// present IFF send (drop a stray senderSendTs on a read anchor)
|
|
3006
|
+
...args.expiry.start === "send" && args.expiry.senderSendTs != null ? { sender_send_ts: args.expiry.senderSendTs } : {}
|
|
3007
|
+
}
|
|
3008
|
+
} : {}
|
|
2919
3009
|
};
|
|
2920
3010
|
return encodeUtf8(JSON.stringify(env));
|
|
2921
3011
|
}
|
|
3012
|
+
function encodeTimerSet(args) {
|
|
3013
|
+
return encodeUtf8(
|
|
3014
|
+
JSON.stringify({
|
|
3015
|
+
v: 1,
|
|
3016
|
+
type: "timer_set",
|
|
3017
|
+
client_msg_id: args.clientMsgId,
|
|
3018
|
+
...args.ttlSeconds != null ? { ttl_seconds: args.ttlSeconds } : {},
|
|
3019
|
+
start: args.start
|
|
3020
|
+
})
|
|
3021
|
+
);
|
|
3022
|
+
}
|
|
2922
3023
|
function decodeEnvelope(bytes) {
|
|
2923
3024
|
const s = decodeUtf8(bytes);
|
|
2924
3025
|
try {
|
|
2925
3026
|
const o = JSON.parse(s);
|
|
3027
|
+
if (typeof o === "object" && o !== null && o.type === "timer_set") {
|
|
3028
|
+
return {
|
|
3029
|
+
type: "timer_set",
|
|
3030
|
+
text: null,
|
|
3031
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
3032
|
+
replyTo: null,
|
|
3033
|
+
timer: {
|
|
3034
|
+
ttlSeconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : null,
|
|
3035
|
+
start: o.start === "read" ? "read" : "send"
|
|
3036
|
+
}
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
2926
3039
|
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2927
3040
|
return {
|
|
2928
3041
|
type: "delete",
|
|
@@ -2949,6 +3062,7 @@ function decodeEnvelope(bytes) {
|
|
|
2949
3062
|
};
|
|
2950
3063
|
}
|
|
2951
3064
|
if (typeof o === "object" && o !== null && o.type === "edit") {
|
|
3065
|
+
const editRanges = decodeBodyRanges(o.body_ranges);
|
|
2952
3066
|
return {
|
|
2953
3067
|
type: "edit",
|
|
2954
3068
|
text: null,
|
|
@@ -2957,15 +3071,20 @@ function decodeEnvelope(bytes) {
|
|
|
2957
3071
|
edit: {
|
|
2958
3072
|
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2959
3073
|
newText: o.new_text ?? ""
|
|
2960
|
-
}
|
|
3074
|
+
},
|
|
3075
|
+
...editRanges ? { bodyRanges: editRanges } : {}
|
|
2961
3076
|
};
|
|
2962
3077
|
}
|
|
2963
3078
|
if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
|
|
3079
|
+
const textRanges = decodeBodyRanges(o.body_ranges);
|
|
3080
|
+
const expiry = decodeExpiry(o.expiry);
|
|
2964
3081
|
return {
|
|
2965
3082
|
type: "text",
|
|
2966
3083
|
text: o.text ?? null,
|
|
2967
3084
|
clientMsgId: o.client_msg_id ?? "",
|
|
2968
|
-
replyTo: o.reply_to ?? null
|
|
3085
|
+
replyTo: o.reply_to ?? null,
|
|
3086
|
+
...textRanges ? { bodyRanges: textRanges } : {},
|
|
3087
|
+
...expiry ? { expiry } : {}
|
|
2969
3088
|
};
|
|
2970
3089
|
}
|
|
2971
3090
|
if (typeof o === "object" && o !== null && o.type === "text") {
|
|
@@ -2977,6 +3096,27 @@ function decodeEnvelope(bytes) {
|
|
|
2977
3096
|
}
|
|
2978
3097
|
return { text: s, clientMsgId: "", replyTo: null };
|
|
2979
3098
|
}
|
|
3099
|
+
function decodeExpiry(raw) {
|
|
3100
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
3101
|
+
const o = raw;
|
|
3102
|
+
if (typeof o.ttl_seconds !== "number") return void 0;
|
|
3103
|
+
const start = o.start === "read" ? "read" : "send";
|
|
3104
|
+
return {
|
|
3105
|
+
v: typeof o.v === "number" ? o.v : 1,
|
|
3106
|
+
ttlSeconds: o.ttl_seconds,
|
|
3107
|
+
start,
|
|
3108
|
+
// tolerant: only honor sender_send_ts on a send anchor; null otherwise.
|
|
3109
|
+
senderSendTs: start === "send" && typeof o.sender_send_ts === "number" ? o.sender_send_ts : null
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
function decodeBodyRanges(raw) {
|
|
3113
|
+
if (!raw || raw.length === 0) return void 0;
|
|
3114
|
+
return raw.map((r) => ({
|
|
3115
|
+
start: r.start,
|
|
3116
|
+
length: r.length,
|
|
3117
|
+
mentionedUserId: r.mentioned_user_id
|
|
3118
|
+
}));
|
|
3119
|
+
}
|
|
2980
3120
|
function resolveReply(ref, lookup) {
|
|
2981
3121
|
const parent = lookup(ref.client_msg_id);
|
|
2982
3122
|
if (parent !== null) {
|
|
@@ -3188,9 +3328,9 @@ var GroupMessaging = class {
|
|
|
3188
3328
|
/** Send a text message. Encrypt at the current epoch, POST, return the receipt +
|
|
3189
3329
|
* the client-minted `clientMsgId` (needed by the Chat layer for reply indexing).
|
|
3190
3330
|
* NEVER rebases (a 422 stale_application surfaces to the caller). */
|
|
3191
|
-
async sendText(group, text, replyTo) {
|
|
3331
|
+
async sendText(group, text, replyTo, bodyRanges, expiry) {
|
|
3192
3332
|
const clientMsgId = mintClientMsgId();
|
|
3193
|
-
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo });
|
|
3333
|
+
const plaintext = encodeEnvelope({ text, clientMsgId, replyTo, bodyRanges, expiry });
|
|
3194
3334
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3195
3335
|
const body = {
|
|
3196
3336
|
ciphertext_b64: toBase64(ct),
|
|
@@ -3216,7 +3356,13 @@ var GroupMessaging = class {
|
|
|
3216
3356
|
previewBody: replyTo.preview?.body ?? null,
|
|
3217
3357
|
previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
|
|
3218
3358
|
previewKind: replyTo.preview?.kind ?? "text"
|
|
3219
|
-
} : null
|
|
3359
|
+
} : null,
|
|
3360
|
+
// Persist the OUTGOING bubble's mention ranges so an own-sent mention re-resolves
|
|
3361
|
+
// onto its bubble after a reload (own-send reload parity — the T4-reviewer Minor).
|
|
3362
|
+
...bodyRanges && bodyRanges.length > 0 ? { bodyRanges } : {},
|
|
3363
|
+
// Disappearing T10: persist the per-message TTL so the own-send re-arms its purge
|
|
3364
|
+
// after a cold launch (the projection derives the deadline from this row's expiry).
|
|
3365
|
+
...expiry ? { expiry } : {}
|
|
3220
3366
|
};
|
|
3221
3367
|
try {
|
|
3222
3368
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -3224,6 +3370,51 @@ var GroupMessaging = class {
|
|
|
3224
3370
|
}
|
|
3225
3371
|
return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
|
|
3226
3372
|
}
|
|
3373
|
+
/** Send a per-chat default disappearing-timer control envelope (`timer_set`). Encrypts
|
|
3374
|
+
* a `type:'timer_set'` envelope at the current epoch and sends through the SAME MLS
|
|
3375
|
+
* application path as `sendText` (the server stays blind — it's an opaque app message,
|
|
3376
|
+
* NEVER a bubble). `ttlSeconds === null` disables the default (omitted from the wire).
|
|
3377
|
+
* Persists the outgoing `timer_set` row so the chat default re-folds on cold launch
|
|
3378
|
+
* (the page-local TimerFold in projectHistory). NEVER rebases (epoch-bound). */
|
|
3379
|
+
async sendTimerSet(group, args) {
|
|
3380
|
+
const plaintext = encodeTimerSet({
|
|
3381
|
+
clientMsgId: args.clientMsgId,
|
|
3382
|
+
ttlSeconds: args.ttlSeconds,
|
|
3383
|
+
start: args.start
|
|
3384
|
+
});
|
|
3385
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3386
|
+
const body = {
|
|
3387
|
+
ciphertext_b64: toBase64(ct),
|
|
3388
|
+
client_idem_key: randomId()
|
|
3389
|
+
};
|
|
3390
|
+
const wire = await palbeRequest(
|
|
3391
|
+
this.rt,
|
|
3392
|
+
"POST",
|
|
3393
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3394
|
+
{ body }
|
|
3395
|
+
);
|
|
3396
|
+
const stored = {
|
|
3397
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3398
|
+
direction: "outgoing",
|
|
3399
|
+
text: null,
|
|
3400
|
+
senderDeviceId: this.selfDeviceId,
|
|
3401
|
+
epoch: wire.epoch,
|
|
3402
|
+
serverSeq: wire.server_seq,
|
|
3403
|
+
at: Date.now(),
|
|
3404
|
+
clientMsgId: args.clientMsgId,
|
|
3405
|
+
replyTo: null,
|
|
3406
|
+
envelopeType: "timer_set",
|
|
3407
|
+
timer: { ttlSeconds: args.ttlSeconds, start: args.start }
|
|
3408
|
+
};
|
|
3409
|
+
try {
|
|
3410
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3411
|
+
} catch {
|
|
3412
|
+
}
|
|
3413
|
+
return {
|
|
3414
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3415
|
+
clientMsgId: args.clientMsgId
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3227
3418
|
/** Send a reaction (add/remove of an emoji on a target message). Encrypts a
|
|
3228
3419
|
* `type:'reaction'` envelope at the current epoch and sends through the SAME
|
|
3229
3420
|
* MLS application path as `sendText` (the server stays blind — a reaction is
|
|
@@ -3284,7 +3475,8 @@ var GroupMessaging = class {
|
|
|
3284
3475
|
const plaintext = encodeEdit({
|
|
3285
3476
|
clientMsgId: args.clientMsgId,
|
|
3286
3477
|
targetClientMsgId: args.targetClientMsgId,
|
|
3287
|
-
newText: args.newText
|
|
3478
|
+
newText: args.newText,
|
|
3479
|
+
bodyRanges: args.bodyRanges
|
|
3288
3480
|
});
|
|
3289
3481
|
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3290
3482
|
const body = {
|
|
@@ -3310,7 +3502,10 @@ var GroupMessaging = class {
|
|
|
3310
3502
|
envelopeType: "edit",
|
|
3311
3503
|
edit: {
|
|
3312
3504
|
targetClientMsgId: args.targetClientMsgId,
|
|
3313
|
-
newText: args.newText
|
|
3505
|
+
newText: args.newText,
|
|
3506
|
+
// Persist the edit's REPLACEMENT ranges so the edited message's mentions
|
|
3507
|
+
// re-resolve from this edit after a reload (own-send reload parity — T6).
|
|
3508
|
+
...args.bodyRanges && args.bodyRanges.length > 0 ? { bodyRanges: args.bodyRanges } : {}
|
|
3314
3509
|
}
|
|
3315
3510
|
};
|
|
3316
3511
|
try {
|
|
@@ -3432,6 +3627,41 @@ var GroupMessaging = class {
|
|
|
3432
3627
|
}
|
|
3433
3628
|
};
|
|
3434
3629
|
|
|
3630
|
+
// src/messaging/mention-ranges.ts
|
|
3631
|
+
function normalizeMentionRangesUtf16(ranges, text) {
|
|
3632
|
+
const n = text.length;
|
|
3633
|
+
function splitsSurrogatePair(index) {
|
|
3634
|
+
if (index <= 0 || index >= n) return false;
|
|
3635
|
+
const before = text.charCodeAt(index - 1);
|
|
3636
|
+
const at = text.charCodeAt(index);
|
|
3637
|
+
const beforeIsHigh = before >= 55296 && before <= 56319;
|
|
3638
|
+
const atIsLow = at >= 56320 && at <= 57343;
|
|
3639
|
+
return beforeIsHigh && atIsLow;
|
|
3640
|
+
}
|
|
3641
|
+
const survivors = [];
|
|
3642
|
+
for (let idx = 0; idx < ranges.length; idx++) {
|
|
3643
|
+
const r = ranges[idx];
|
|
3644
|
+
if (r === void 0) continue;
|
|
3645
|
+
if (r.start < 0 || r.length <= 0 || r.start + r.length > n) continue;
|
|
3646
|
+
if (splitsSurrogatePair(r.start) || splitsSurrogatePair(r.start + r.length)) continue;
|
|
3647
|
+
survivors.push({ idx, range: r });
|
|
3648
|
+
}
|
|
3649
|
+
survivors.sort((lhs, rhs) => {
|
|
3650
|
+
if (lhs.range.start !== rhs.range.start) return lhs.range.start - rhs.range.start;
|
|
3651
|
+
if (lhs.range.length !== rhs.range.length) return rhs.range.length - lhs.range.length;
|
|
3652
|
+
return lhs.idx - rhs.idx;
|
|
3653
|
+
});
|
|
3654
|
+
const kept = [];
|
|
3655
|
+
let prevEnd = Number.NEGATIVE_INFINITY;
|
|
3656
|
+
for (const s of survivors) {
|
|
3657
|
+
if (s.range.start >= prevEnd) {
|
|
3658
|
+
kept.push(s.range);
|
|
3659
|
+
prevEnd = s.range.start + s.range.length;
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
return kept;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3435
3665
|
// src/messaging/reaction-fold.ts
|
|
3436
3666
|
function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3437
3667
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -3487,6 +3717,43 @@ var ReactionFold = class {
|
|
|
3487
3717
|
}
|
|
3488
3718
|
};
|
|
3489
3719
|
|
|
3720
|
+
// src/messaging/timer-fold.ts
|
|
3721
|
+
function orderLte2(aEpoch, aSeq, bEpoch, bSeq) {
|
|
3722
|
+
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
3723
|
+
return aSeq <= bSeq;
|
|
3724
|
+
}
|
|
3725
|
+
var TimerFold = class {
|
|
3726
|
+
cell = null;
|
|
3727
|
+
seenEvents = /* @__PURE__ */ new Set();
|
|
3728
|
+
ingest(e) {
|
|
3729
|
+
if (this.seenEvents.has(e.eventClientMsgId)) return;
|
|
3730
|
+
this.seenEvents.add(e.eventClientMsgId);
|
|
3731
|
+
if (this.cell !== null && orderLte2(e.epoch, e.serverSeq, this.cell.orderEpoch, this.cell.orderSeq)) {
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
this.cell = {
|
|
3735
|
+
orderEpoch: e.epoch,
|
|
3736
|
+
orderSeq: e.serverSeq,
|
|
3737
|
+
ttlSeconds: e.ttlSeconds,
|
|
3738
|
+
start: e.start,
|
|
3739
|
+
actor: e.actorUserId
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
/**
|
|
3743
|
+
* The active chat default, or null if no timer_set has applied.
|
|
3744
|
+
* `ttlSeconds === null` means DISABLED (still applied — distinct from "never
|
|
3745
|
+
* set"). `start` is meaningful only when ttlSeconds !== null.
|
|
3746
|
+
*/
|
|
3747
|
+
active() {
|
|
3748
|
+
if (this.cell === null) return null;
|
|
3749
|
+
return { ttlSeconds: this.cell.ttlSeconds, start: this.cell.start };
|
|
3750
|
+
}
|
|
3751
|
+
/** The userId behind the winning timer_set (for the optional system line). null if unset. */
|
|
3752
|
+
lastActor() {
|
|
3753
|
+
return this.cell?.actor ?? null;
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
|
|
3490
3757
|
// src/messaging/chat.ts
|
|
3491
3758
|
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3492
3759
|
var Chat = class {
|
|
@@ -3513,12 +3780,35 @@ var Chat = class {
|
|
|
3513
3780
|
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3514
3781
|
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3515
3782
|
deleteFold = new DeleteFold();
|
|
3783
|
+
/** The per-chat default disappearing-timer fold — the latest valid `timer_set` (LWW
|
|
3784
|
+
* on (epoch, serverSeq), author = the resolved MLS sender). A `timer_set` is NEVER a
|
|
3785
|
+
* bubble; it routes here. The active default governs a subsequent bubble that carries
|
|
3786
|
+
* no per-message expiry (disappearing T10). */
|
|
3787
|
+
timerFold = new TimerFold();
|
|
3788
|
+
/** Advisory in-memory purge timers, keyed by serverSeq. The DURABLE truth is the
|
|
3789
|
+
* persisted anchor + a re-check on every load; this just drives live eviction while
|
|
3790
|
+
* the tab is open. Cancelled when the message purges (disappearing T10). */
|
|
3791
|
+
purgeTimers = /* @__PURE__ */ new Map();
|
|
3792
|
+
/** In-memory mirror of the durable `purgedClientMsgIds` set (the STRING namespace),
|
|
3793
|
+
* hydrated from `backend.purgedClientMsgIds` and grown by each live purge. Consulted
|
|
3794
|
+
* by `authorOfTarget` so a late edit/delete targeting a TTL-purged message resolves to
|
|
3795
|
+
* `'purged'` (DROP / no-op — never resurrects). Namespace-separate from the int seq
|
|
3796
|
+
* tombstone (disappearing T10). */
|
|
3797
|
+
purgedCids = /* @__PURE__ */ new Set();
|
|
3798
|
+
purgedLoaded = false;
|
|
3516
3799
|
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3517
3800
|
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3518
3801
|
suppressed = /* @__PURE__ */ new Set();
|
|
3519
3802
|
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3520
3803
|
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3521
3804
|
suppressedLoaded = false;
|
|
3805
|
+
/** Self-elevation dedup keys (`<selfUserId>|<clientMsgId or seq:n>`). Once a
|
|
3806
|
+
* mention of me from another sender fires `onMentionElevation`, its key lands here
|
|
3807
|
+
* + is persisted, so a re-delivery / cold-launch re-hydrate never re-fires. */
|
|
3808
|
+
elevated = /* @__PURE__ */ new Set();
|
|
3809
|
+
/** True once the persisted elevation set has been loaded (so a re-delivered mention
|
|
3810
|
+
* on the cold-launch hydrate path dedups against the persisted decision). */
|
|
3811
|
+
elevatedLoaded = false;
|
|
3522
3812
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3523
3813
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3524
3814
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3530,6 +3820,14 @@ var Chat = class {
|
|
|
3530
3820
|
wired = false;
|
|
3531
3821
|
liveUnsub = null;
|
|
3532
3822
|
listeners = /* @__PURE__ */ new Set();
|
|
3823
|
+
/**
|
|
3824
|
+
* Fires ONCE per `(selfUserId, clientMsgId)` when an INCOMING message mentions THIS
|
|
3825
|
+
* user from ANOTHER sender (not an edit). The dedup survives re-delivery + reload
|
|
3826
|
+
* via the persisted elevation set, so this never double-fires for one mention. The
|
|
3827
|
+
* app wires it to a buzz/badge (e.g. an in-app banner). Best-effort cooperative —
|
|
3828
|
+
* the SDK guarantees the DECISION, not the buzz. Mirrors iOS `Chat.onMentionElevation`.
|
|
3829
|
+
*/
|
|
3830
|
+
onMentionElevation;
|
|
3533
3831
|
/** @internal — obtain via pb.messaging.directChat / groupChat / chat(id). */
|
|
3534
3832
|
constructor(args) {
|
|
3535
3833
|
this.backend = args.backend;
|
|
@@ -3607,7 +3905,10 @@ var Chat = class {
|
|
|
3607
3905
|
reactions: {},
|
|
3608
3906
|
replyTo: null,
|
|
3609
3907
|
edited: false,
|
|
3610
|
-
isDeleted: true
|
|
3908
|
+
isDeleted: true,
|
|
3909
|
+
mentions: [],
|
|
3910
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
3911
|
+
expiresAt: null
|
|
3611
3912
|
});
|
|
3612
3913
|
continue;
|
|
3613
3914
|
}
|
|
@@ -3643,9 +3944,22 @@ var Chat = class {
|
|
|
3643
3944
|
this.wired = true;
|
|
3644
3945
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3645
3946
|
void this.loadSuppressed();
|
|
3646
|
-
void this.
|
|
3947
|
+
void this.loadElevated();
|
|
3948
|
+
void this.loadPurged().then(() => this.hydrateHistory());
|
|
3647
3949
|
void this.refreshMembers();
|
|
3648
3950
|
}
|
|
3951
|
+
/** Hydrate the durable `purgedClientMsgIds` set (once) into the in-memory mirror so the
|
|
3952
|
+
* live Edit/Delete fold author-gate sees TTL-purged targets as 'purged' on cold launch
|
|
3953
|
+
* (disappearing T10). No re-emit: it only gates the orphan-fold resolution. */
|
|
3954
|
+
async loadPurged() {
|
|
3955
|
+
if (this.purgedLoaded || !this._group) return;
|
|
3956
|
+
this.purgedLoaded = true;
|
|
3957
|
+
try {
|
|
3958
|
+
const ids = await this.backend.purgedClientMsgIds(this._group);
|
|
3959
|
+
for (const id of ids) this.purgedCids.add(id);
|
|
3960
|
+
} catch {
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3649
3963
|
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3650
3964
|
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3651
3965
|
async loadSuppressed() {
|
|
@@ -3664,6 +3978,17 @@ var Chat = class {
|
|
|
3664
3978
|
} catch {
|
|
3665
3979
|
}
|
|
3666
3980
|
}
|
|
3981
|
+
/** Hydrate the persisted self-elevation dedup keys (once). No re-emit: the set only
|
|
3982
|
+
* gates the elevation DECISION, it does not change what renders. */
|
|
3983
|
+
async loadElevated() {
|
|
3984
|
+
if (this.elevatedLoaded || !this._group) return;
|
|
3985
|
+
this.elevatedLoaded = true;
|
|
3986
|
+
try {
|
|
3987
|
+
const keys = await this.backend.loadElevated(this._group);
|
|
3988
|
+
for (const k of keys) this.elevated.add(k);
|
|
3989
|
+
} catch {
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3667
3992
|
async hydrateHistory() {
|
|
3668
3993
|
if (this.historyLoaded || !this._group) return;
|
|
3669
3994
|
this.historyLoaded = true;
|
|
@@ -3691,11 +4016,16 @@ var Chat = class {
|
|
|
3691
4016
|
if (this.seenKeys.has(key)) continue;
|
|
3692
4017
|
this.seenKeys.add(key);
|
|
3693
4018
|
if (m.clientMsgId && !m.isDeleted) {
|
|
3694
|
-
this.deleteFold.reevaluatePending(m.clientMsgId, m.
|
|
4019
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, this.authorOfTarget(m.clientMsgId));
|
|
3695
4020
|
}
|
|
3696
|
-
this.messageList.push(
|
|
4021
|
+
this.messageList.push(
|
|
4022
|
+
this.applyEditOverlay(this.applyReactionTally(this.resolveMentionNames(m)))
|
|
4023
|
+
);
|
|
3697
4024
|
changed = true;
|
|
3698
4025
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
4026
|
+
if (m.expiresAt && m.clientMsgId && !m.isDeleted) {
|
|
4027
|
+
void this.armFromDeadline(m.expiresAt, m.serverSeq, m.clientMsgId);
|
|
4028
|
+
}
|
|
3699
4029
|
}
|
|
3700
4030
|
if (changed) {
|
|
3701
4031
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3710,6 +4040,7 @@ var Chat = class {
|
|
|
3710
4040
|
return;
|
|
3711
4041
|
}
|
|
3712
4042
|
if (incoming.serverSeq <= 0) return;
|
|
4043
|
+
if ((await this.backend.tombstonedSeqs(this._group)).has(incoming.serverSeq)) return;
|
|
3713
4044
|
const key = this.internalKey(incoming.serverSeq);
|
|
3714
4045
|
if (this.seenKeys.has(key)) return;
|
|
3715
4046
|
this.seenKeys.add(key);
|
|
@@ -3718,6 +4049,20 @@ var Chat = class {
|
|
|
3718
4049
|
senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
|
|
3719
4050
|
}
|
|
3720
4051
|
const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
|
|
4052
|
+
if (incoming.envelopeType === "timer_set" && incoming.timer) {
|
|
4053
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
4054
|
+
if (actorUserId !== null) {
|
|
4055
|
+
this.timerFold.ingest({
|
|
4056
|
+
ttlSeconds: incoming.timer.ttlSeconds,
|
|
4057
|
+
start: incoming.timer.start,
|
|
4058
|
+
actorUserId,
|
|
4059
|
+
epoch: incoming.epoch,
|
|
4060
|
+
serverSeq: incoming.serverSeq,
|
|
4061
|
+
eventClientMsgId: incoming.clientMsgId
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
4064
|
+
return;
|
|
4065
|
+
}
|
|
3721
4066
|
if (incoming.envelopeType === "reaction" && incoming.reaction) {
|
|
3722
4067
|
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3723
4068
|
if (actorUserId !== null) {
|
|
@@ -3743,7 +4088,10 @@ var Chat = class {
|
|
|
3743
4088
|
newText: incoming.edit.newText,
|
|
3744
4089
|
epoch: incoming.epoch,
|
|
3745
4090
|
serverSeq: incoming.serverSeq,
|
|
3746
|
-
eventClientMsgId: incoming.clientMsgId
|
|
4091
|
+
eventClientMsgId: incoming.clientMsgId,
|
|
4092
|
+
// Mentions T6: carry the edit's REPLACEMENT ranges so the edited message's
|
|
4093
|
+
// mentions reflect them (recomputed against the new text on recomputeEdit).
|
|
4094
|
+
bodyRanges: incoming.bodyRanges
|
|
3747
4095
|
},
|
|
3748
4096
|
this.authorOfTarget
|
|
3749
4097
|
);
|
|
@@ -3771,6 +4119,7 @@ var Chat = class {
|
|
|
3771
4119
|
if (incomingReplyRef) {
|
|
3772
4120
|
resolvedReplyTo = resolveReply(incomingReplyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
3773
4121
|
}
|
|
4122
|
+
const mentions = this.resolveMentions(incoming.text, incoming.bodyRanges);
|
|
3774
4123
|
const msg = {
|
|
3775
4124
|
id: this.publicId(incoming.serverSeq),
|
|
3776
4125
|
kind: this.kindOf(incoming),
|
|
@@ -3787,8 +4136,13 @@ var Chat = class {
|
|
|
3787
4136
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3788
4137
|
edited: false,
|
|
3789
4138
|
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3790
|
-
isDeleted: false
|
|
4139
|
+
isDeleted: false,
|
|
4140
|
+
mentions,
|
|
4141
|
+
// Disappearing T10: the LOCAL deadline (own per-message expiry ELSE the chat default
|
|
4142
|
+
// active AS OF arrival). null when this message is non-disappearing.
|
|
4143
|
+
expiresAt: this.deadlineFor(this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry())
|
|
3791
4144
|
};
|
|
4145
|
+
this.elevateIfMentioned(msg, mentions, senderUser, incoming.envelopeType);
|
|
3792
4146
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3793
4147
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
3794
4148
|
text: incoming.text,
|
|
@@ -3798,7 +4152,10 @@ var Chat = class {
|
|
|
3798
4152
|
if (incomingClientMsgId) {
|
|
3799
4153
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3800
4154
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3801
|
-
this.deleteFold.reevaluatePending(
|
|
4155
|
+
this.deleteFold.reevaluatePending(
|
|
4156
|
+
incomingClientMsgId,
|
|
4157
|
+
this.authorOfTarget(incomingClientMsgId)
|
|
4158
|
+
);
|
|
3802
4159
|
}
|
|
3803
4160
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3804
4161
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3807,11 +4164,200 @@ var Chat = class {
|
|
|
3807
4164
|
incoming.serverSeq
|
|
3808
4165
|
);
|
|
3809
4166
|
this.emit();
|
|
4167
|
+
void this.armPurge(
|
|
4168
|
+
this.toExpirySpec(incoming.expiry) ?? this.defaultExpiry(),
|
|
4169
|
+
incoming.serverSeq,
|
|
4170
|
+
incomingClientMsgId
|
|
4171
|
+
);
|
|
4172
|
+
}
|
|
4173
|
+
// ── Disappearing (TTL — T10) ──
|
|
4174
|
+
/** Normalize a decoded `IncomingMessage.expiry` / `StoredMessage.expiry` into the
|
|
4175
|
+
* `ExpirySpec` the arm path consumes (or null when absent). */
|
|
4176
|
+
toExpirySpec(e) {
|
|
4177
|
+
return e ? { v: e.v, ttlSeconds: e.ttlSeconds, start: e.start, senderSendTs: e.senderSendTs } : null;
|
|
4178
|
+
}
|
|
4179
|
+
/** The chat-default expiry derived from the active `timer_set` fold, as an `ExpirySpec`
|
|
4180
|
+
* so a bubble with no per-message expiry inherits it. null when no default is active or
|
|
4181
|
+
* the default was explicitly DISABLED (`ttlSeconds === null`). `senderSendTs` is null —
|
|
4182
|
+
* the default has no per-message sender clock; the arrival anchor drives the deadline
|
|
4183
|
+
* (mirrors iOS `defaultExpiry()`). */
|
|
4184
|
+
defaultExpiry() {
|
|
4185
|
+
const active = this.timerFold.active();
|
|
4186
|
+
if (!active || active.ttlSeconds === null) return null;
|
|
4187
|
+
return { v: 1, ttlSeconds: active.ttlSeconds, start: active.start, senderSendTs: null };
|
|
4188
|
+
}
|
|
4189
|
+
/** The surfaced display deadline for an effective expiry (a local, monotonic-derived
|
|
4190
|
+
* value — the wall-clock projection of the TTL from now). null for a non-disappearing
|
|
4191
|
+
* message. The durable purge is driven by `armPurge`'s write-once anchor; this is the
|
|
4192
|
+
* UI countdown baseline. */
|
|
4193
|
+
deadlineFor(expiry) {
|
|
4194
|
+
if (!expiry) return null;
|
|
4195
|
+
return new Date(Date.now() + expiry.ttlSeconds * 1e3);
|
|
4196
|
+
}
|
|
4197
|
+
/** Arm a message's TTL purge on first decrypt-and-display. Captures the WRITE-ONCE
|
|
4198
|
+
* monotonic/wall/boot anchor (so the deadline survives a reload — a re-arm after relaunch
|
|
4199
|
+
* reads back the ORIGINAL capture, never a fresh one → the deadline never resets),
|
|
4200
|
+
* computes the remaining time via `remainingSeconds`, applies the send-anchor clamp
|
|
4201
|
+
* `min(sender_send_ts+ttl, first_arrival+ttl)` (read-anchor uses the write-once first-read
|
|
4202
|
+
* capture), then either purges immediately or schedules an advisory `setTimeout`. A null
|
|
4203
|
+
* expiry / empty clientMsgId is a no-op. Mirrors iOS `armPurge`. */
|
|
4204
|
+
async armPurge(expiry, serverSeq, clientMsgId) {
|
|
4205
|
+
if (!expiry || !clientMsgId || !this._group) return;
|
|
4206
|
+
const group = this._group;
|
|
4207
|
+
const fresh = {
|
|
4208
|
+
mAnchorMs: MonotonicClock.nowMs(),
|
|
4209
|
+
wAnchorEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4210
|
+
bAnchorToken: MonotonicClock.bootToken()
|
|
4211
|
+
};
|
|
4212
|
+
await this.backend.writeAnchorOnce(group, clientMsgId, fresh);
|
|
4213
|
+
const effective = await this.backend.anchor(group, clientMsgId) ?? fresh;
|
|
4214
|
+
const result = remainingSeconds({
|
|
4215
|
+
ttlSeconds: expiry.ttlSeconds,
|
|
4216
|
+
anchor: effective,
|
|
4217
|
+
nowMonotonicMs: MonotonicClock.nowMs(),
|
|
4218
|
+
nowWallEpochMs: MonotonicClock.nowWallEpochMs(),
|
|
4219
|
+
nowBootToken: MonotonicClock.bootToken()
|
|
4220
|
+
});
|
|
4221
|
+
let purgeInSeconds;
|
|
4222
|
+
if (result.kind === "purgeNow") {
|
|
4223
|
+
purgeInSeconds = 0;
|
|
4224
|
+
} else if (expiry.start === "send" && expiry.senderSendTs !== null) {
|
|
4225
|
+
const sendRemaining = expiry.senderSendTs + expiry.ttlSeconds - MonotonicClock.nowWallEpochMs() / 1e3;
|
|
4226
|
+
purgeInSeconds = sendRemaining <= 0 ? 0 : Math.min(result.seconds, sendRemaining);
|
|
4227
|
+
} else {
|
|
4228
|
+
purgeInSeconds = result.seconds;
|
|
4229
|
+
}
|
|
4230
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4231
|
+
if (prior) clearTimeout(prior);
|
|
4232
|
+
this.purgeTimers.delete(serverSeq);
|
|
4233
|
+
if (purgeInSeconds <= 0) {
|
|
4234
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
const handle = setTimeout(() => {
|
|
4238
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4239
|
+
}, purgeInSeconds * 1e3);
|
|
4240
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4241
|
+
}
|
|
4242
|
+
/** Re-arm a purge from a derived deadline (cold-launch hydrate path). The deadline is
|
|
4243
|
+
* the projection's monotonic-derived `expiresAt`; schedule an advisory timer for the
|
|
4244
|
+
* remaining time (purge immediately if the deadline has already passed). The durable
|
|
4245
|
+
* tombstone is written by `purge` when it fires (the crash-safe commit point). */
|
|
4246
|
+
async armFromDeadline(deadline, serverSeq, clientMsgId) {
|
|
4247
|
+
if (!this._group) return;
|
|
4248
|
+
const remainingMs = deadline.getTime() - Date.now();
|
|
4249
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4250
|
+
if (prior) clearTimeout(prior);
|
|
4251
|
+
this.purgeTimers.delete(serverSeq);
|
|
4252
|
+
if (remainingMs <= 0) {
|
|
4253
|
+
await this.purge(serverSeq, clientMsgId);
|
|
4254
|
+
return;
|
|
4255
|
+
}
|
|
4256
|
+
const handle = setTimeout(() => {
|
|
4257
|
+
void this.purge(serverSeq, clientMsgId);
|
|
4258
|
+
}, remainingMs);
|
|
4259
|
+
this.purgeTimers.set(serverSeq, handle);
|
|
4260
|
+
}
|
|
4261
|
+
/** Purge message M (TTL eviction). TOMBSTONE-FIRST (the crash-safe commit point):
|
|
4262
|
+
* persist the `server_seq` tombstone + the `client_msg_id` purge id in ONE durable
|
|
4263
|
+
* record, THEN drop M's body from `messageList` + `emit()`, then re-evaluate any HELD
|
|
4264
|
+
* edit / PARKED delete targeting the now-purged cid so an orphan annotation DROPs/no-ops
|
|
4265
|
+
* (the resolver now returns `'purged'`). Idempotent. Mirrors iOS `purge`. */
|
|
4266
|
+
async purge(serverSeq, clientMsgId) {
|
|
4267
|
+
if (!this._group) return;
|
|
4268
|
+
const prior = this.purgeTimers.get(serverSeq);
|
|
4269
|
+
if (prior) clearTimeout(prior);
|
|
4270
|
+
this.purgeTimers.delete(serverSeq);
|
|
4271
|
+
await this.backend.tombstone(this._group, serverSeq, clientMsgId);
|
|
4272
|
+
if (clientMsgId) this.purgedCids.add(clientMsgId);
|
|
4273
|
+
this.messageList = this.messageList.filter((m) => m.serverSeq !== serverSeq);
|
|
4274
|
+
this.seenKeys.delete(this.internalKey(serverSeq));
|
|
4275
|
+
this.emit();
|
|
4276
|
+
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
4277
|
+
if (clientMsgId) {
|
|
4278
|
+
this.deleteFold.reevaluatePending(clientMsgId, this.authorOfTarget(clientMsgId));
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
/** The Edit/Delete fold author-gate input via {@link AuthorResolution} (disappearing
|
|
4282
|
+
* T10 — the orphan-aware resolver): `'purged'` when the target's clientMsgId is in the
|
|
4283
|
+
* durable purge set (a late edit DROPs / a late delete no-ops — never resurrects a
|
|
4284
|
+
* disappeared message); `'author'` when its author is locally known → run the
|
|
4285
|
+
* author-gate; `'unknown'` otherwise → HOLD. The live twin of `projectHistory`'s
|
|
4286
|
+
* resolver. Captured as a bound arrow so it can be passed to the pure folds. */
|
|
4287
|
+
authorOfTarget = (targetClientMsgId) => {
|
|
4288
|
+
if (this.purgedCids.has(targetClientMsgId)) return { kind: "purged" };
|
|
4289
|
+
const a = this.authorByClientMsgId.get(targetClientMsgId);
|
|
4290
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
4291
|
+
};
|
|
4292
|
+
// ── Mentions (mentions T6) ──
|
|
4293
|
+
/** Resolve a message's decode-time mention spans: NORMALIZE the raw `body_ranges`
|
|
4294
|
+
* against `text` (the pinned `normalizeMentionRangesUtf16` cross-SDK contract) then
|
|
4295
|
+
* resolve each surviving range's `mentionedUserId` to a roster display name. An id
|
|
4296
|
+
* not in the roster resolves to `null` (the renderer falls back to the `text` slice).
|
|
4297
|
+
* Pure over (text, bodyRanges, memberCache); never throws. Mirrors iOS T3. */
|
|
4298
|
+
resolveMentions(text, bodyRanges) {
|
|
4299
|
+
if (text === null || !bodyRanges || bodyRanges.length === 0) return [];
|
|
4300
|
+
const normalized = normalizeMentionRangesUtf16(bodyRanges, text);
|
|
4301
|
+
if (normalized.length === 0) return [];
|
|
4302
|
+
return normalized.map((r) => ({
|
|
4303
|
+
start: r.start,
|
|
4304
|
+
length: r.length,
|
|
4305
|
+
mentionedUserId: r.mentionedUserId,
|
|
4306
|
+
displayName: this.displayNameOf(r.mentionedUserId)
|
|
4307
|
+
}));
|
|
4308
|
+
}
|
|
4309
|
+
/** Re-resolve the roster display name on already-NORMALIZED spans (the history
|
|
4310
|
+
* projection produces them with null names — resolution is LIVE, not snapshotted).
|
|
4311
|
+
* A member rename then reflects on old messages. Returns the message unchanged when
|
|
4312
|
+
* it has no mentions (the common case) or no name changed. Mirrors iOS T3. */
|
|
4313
|
+
resolveMentionNames(m) {
|
|
4314
|
+
if (!m.mentions || m.mentions.length === 0) {
|
|
4315
|
+
return m.mentions ? m : { ...m, mentions: [] };
|
|
4316
|
+
}
|
|
4317
|
+
let changed = false;
|
|
4318
|
+
const reresolved = m.mentions.map((span) => {
|
|
4319
|
+
const name = this.displayNameOf(span.mentionedUserId);
|
|
4320
|
+
if (name === span.displayName) return span;
|
|
4321
|
+
changed = true;
|
|
4322
|
+
return { ...span, displayName: name };
|
|
4323
|
+
});
|
|
4324
|
+
if (!changed) return m;
|
|
4325
|
+
return { ...m, mentions: reresolved };
|
|
4326
|
+
}
|
|
4327
|
+
/** The WINNING edit's resolved mentions for a target (normalize its replacement
|
|
4328
|
+
* ranges against the new text + roster names), or `[]` if no winning edit / no
|
|
4329
|
+
* ranges. The edited message's mentions reflect the EDIT's ranges (mirrors iOS T3). */
|
|
4330
|
+
editMentions(targetClientMsgId, newText) {
|
|
4331
|
+
const ranges = this.editFold.bodyRanges(targetClientMsgId);
|
|
4332
|
+
if (!ranges) return [];
|
|
4333
|
+
return this.resolveMentions(newText, ranges);
|
|
4334
|
+
}
|
|
4335
|
+
/** Resolve a userId → its roster display name (null if not a known member). */
|
|
4336
|
+
displayNameOf(userId) {
|
|
4337
|
+
return this.memberCache.find((mm) => mm.userId === userId)?.displayName ?? null;
|
|
4338
|
+
}
|
|
4339
|
+
/** Compute the SELF-ELEVATION decision for a freshly-ingested INCOMING bubble and,
|
|
4340
|
+
* when it fires, record the dedup key (persisted) + invoke `onMentionElevation`.
|
|
4341
|
+
* Gate (mirrors iOS T3): a surviving mention targets THIS user AND the sender is not
|
|
4342
|
+
* me AND it's NOT an edit AND the `(selfUserId, clientMsgId|seq)` key isn't already
|
|
4343
|
+
* elevated. Dedup-once: the in-memory set gates the session, the persisted set
|
|
4344
|
+
* survives reload. An EDIT never reaches here (it folds, not a bubble) — the
|
|
4345
|
+
* `envelopeType !== 'edit'` guard is belt-and-braces. */
|
|
4346
|
+
elevateIfMentioned(message, mentions, senderUserId, envelopeType) {
|
|
4347
|
+
const me = this.backend.selfUserId;
|
|
4348
|
+
if (envelopeType === "edit") return;
|
|
4349
|
+
if (senderUserId === me) return;
|
|
4350
|
+
if (!mentions.some((mm) => mm.mentionedUserId === me)) return;
|
|
4351
|
+
const idPart = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4352
|
+
const key = `${me}|${idPart}`;
|
|
4353
|
+
if (this.elevated.has(key)) return;
|
|
4354
|
+
this.elevated.add(key);
|
|
4355
|
+
if (this._group) {
|
|
4356
|
+
void this.backend.saveElevated(this._group, [...this.elevated]).catch(() => {
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
this.onMentionElevation?.(message);
|
|
3810
4360
|
}
|
|
3811
|
-
/** The EditFold author-gate input: the target message's resolved author userId
|
|
3812
|
-
* (null = target unknown/dangling → the fold HOLDs). Captured as a bound arrow
|
|
3813
|
-
* so it can be passed to the pure EditFold. */
|
|
3814
|
-
authorOfTarget = (targetClientMsgId) => this.authorByClientMsgId.get(targetClientMsgId) ?? null;
|
|
3815
4361
|
/** Seed the per-target base text + author for the edit fold. Base is write-once
|
|
3816
4362
|
* (a later own/peer edit must not overwrite the original we render against). The
|
|
3817
4363
|
* author is (re)recorded whenever a non-empty resolution is available. */
|
|
@@ -3868,9 +4414,10 @@ var Chat = class {
|
|
|
3868
4414
|
const base = this.originalTextByClientMsgId.has(targetClientMsgId) ? this.originalTextByClientMsgId.get(targetClientMsgId) ?? null : m.text;
|
|
3869
4415
|
const text = editText ?? base;
|
|
3870
4416
|
const edited = foldEdited || m.edited;
|
|
3871
|
-
|
|
4417
|
+
const mentions = editText !== null ? this.editMentions(targetClientMsgId, text) : m.mentions;
|
|
4418
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
3872
4419
|
changed = true;
|
|
3873
|
-
return { ...m, text, edited };
|
|
4420
|
+
return { ...m, text, edited, mentions };
|
|
3874
4421
|
});
|
|
3875
4422
|
if (changed) this.emit();
|
|
3876
4423
|
}
|
|
@@ -3887,8 +4434,9 @@ var Chat = class {
|
|
|
3887
4434
|
if (editText === null && !foldEdited) return m;
|
|
3888
4435
|
const text = editText ?? m.text;
|
|
3889
4436
|
const edited = foldEdited || m.edited;
|
|
3890
|
-
|
|
3891
|
-
|
|
4437
|
+
const mentions = editText !== null ? this.editMentions(m.clientMsgId, text) : m.mentions;
|
|
4438
|
+
if (m.text === text && m.edited === edited && sameMentions(m.mentions, mentions)) return m;
|
|
4439
|
+
return { ...m, text, edited, mentions };
|
|
3892
4440
|
}
|
|
3893
4441
|
/** @internal — called by the backend's conv subscription. */
|
|
3894
4442
|
applyConv(event, payload) {
|
|
@@ -3946,6 +4494,18 @@ var Chat = class {
|
|
|
3946
4494
|
}
|
|
3947
4495
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3948
4496
|
for (const cid of this.originalTextByClientMsgId.keys()) this.recomputeEdit(cid);
|
|
4497
|
+
this.reresolveAllMentionNames();
|
|
4498
|
+
}
|
|
4499
|
+
/** Re-resolve roster display names across the whole transcript (called on a roster
|
|
4500
|
+
* change). Re-emits only if any name actually changed. */
|
|
4501
|
+
reresolveAllMentionNames() {
|
|
4502
|
+
let changed = false;
|
|
4503
|
+
this.messageList = this.messageList.map((m) => {
|
|
4504
|
+
const reresolved = this.resolveMentionNames(m);
|
|
4505
|
+
if (reresolved !== m) changed = true;
|
|
4506
|
+
return reresolved;
|
|
4507
|
+
});
|
|
4508
|
+
if (changed) this.emit();
|
|
3949
4509
|
}
|
|
3950
4510
|
seedMembersFromGroup(group) {
|
|
3951
4511
|
const seed = [
|
|
@@ -4013,11 +4573,50 @@ var Chat = class {
|
|
|
4013
4573
|
};
|
|
4014
4574
|
resolvedReplyTo = resolveReply(replyRef, (id) => this.byClientMsgId.get(id) ?? null);
|
|
4015
4575
|
}
|
|
4016
|
-
const
|
|
4017
|
-
|
|
4576
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4577
|
+
const start = opts?.expiresIn?.start ?? "send";
|
|
4578
|
+
const expiry = opts?.expiresIn ? {
|
|
4579
|
+
v: 1,
|
|
4580
|
+
ttlSeconds: opts.expiresIn.ttlSeconds,
|
|
4581
|
+
start,
|
|
4582
|
+
senderSendTs: start === "send" ? Math.floor(Date.now() / 1e3) : null
|
|
4583
|
+
} : null;
|
|
4584
|
+
const { receipt, clientMsgId } = await this.backend.sendText(
|
|
4585
|
+
group,
|
|
4586
|
+
text,
|
|
4587
|
+
replyRef,
|
|
4588
|
+
bodyRanges,
|
|
4589
|
+
expiry
|
|
4590
|
+
);
|
|
4591
|
+
this.appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges);
|
|
4592
|
+
if (expiry) void this.armPurge(expiry, receipt.serverSeq, clientMsgId);
|
|
4018
4593
|
return receipt;
|
|
4019
4594
|
}
|
|
4020
|
-
|
|
4595
|
+
/** Set (or DISABLE) this chat's DEFAULT disappearing timer. Emits a `timer_set` control
|
|
4596
|
+
* envelope (server-blind — an opaque application message, NEVER a bubble) and folds the
|
|
4597
|
+
* own-set locally so the default applies immediately to subsequent sends that carry no
|
|
4598
|
+
* per-message expiry. `ttlSeconds === null` DISABLES the default. FIRE-AND-FORGET
|
|
4599
|
+
* ADVISORY: returns on the LOCAL emit only; it exposes NO "active for all peers" signal.
|
|
4600
|
+
* Mirrors iOS `setDisappearing(ttlSeconds:start:)`. */
|
|
4601
|
+
async setDisappearing(opts) {
|
|
4602
|
+
const group = await this.materializeIfNeeded();
|
|
4603
|
+
const clientMsgId = mintClientMsgId();
|
|
4604
|
+
const start = opts.start ?? "send";
|
|
4605
|
+
const { receipt } = await this.backend.sendTimerSet(group, {
|
|
4606
|
+
clientMsgId,
|
|
4607
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4608
|
+
start
|
|
4609
|
+
});
|
|
4610
|
+
this.timerFold.ingest({
|
|
4611
|
+
ttlSeconds: opts.ttlSeconds,
|
|
4612
|
+
start,
|
|
4613
|
+
actorUserId: this.backend.selfUserId,
|
|
4614
|
+
epoch: receipt.epoch,
|
|
4615
|
+
serverSeq: receipt.serverSeq,
|
|
4616
|
+
eventClientMsgId: clientMsgId
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
appendOwnSend(text, receipt, clientMsgId, resolvedReplyTo, bodyRanges) {
|
|
4021
4620
|
if (receipt.serverSeq <= 0) return;
|
|
4022
4621
|
const key = this.internalKey(receipt.serverSeq);
|
|
4023
4622
|
if (this.seenKeys.has(key)) return;
|
|
@@ -4042,7 +4641,13 @@ var Chat = class {
|
|
|
4042
4641
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
4043
4642
|
edited: false,
|
|
4044
4643
|
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
4045
|
-
isDeleted: false
|
|
4644
|
+
isDeleted: false,
|
|
4645
|
+
// Mentions T6: resolve the own-sent bubble's mentions for its own render (the
|
|
4646
|
+
// sender never gets a wire echo of its own message — this is the only local copy).
|
|
4647
|
+
mentions: this.resolveMentions(text, bodyRanges),
|
|
4648
|
+
// Disappearing T10: the surfaced deadline is set by armPurge (own-send with a TTL)
|
|
4649
|
+
// via the messageList overlay; default null here (a plain own-send has no deadline).
|
|
4650
|
+
expiresAt: null
|
|
4046
4651
|
});
|
|
4047
4652
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
4048
4653
|
this.emit();
|
|
@@ -4130,15 +4735,18 @@ var Chat = class {
|
|
|
4130
4735
|
* instantly; the durable echo on the next pump is a fold no-op (dedup on the
|
|
4131
4736
|
* SAME wire clientMsgId). Never appends a bubble; PRESERVES the target's
|
|
4132
4737
|
* reactions + reply context. Only the original author's edits count — for an own
|
|
4133
|
-
* message self IS the author, so the author-gate passes.
|
|
4134
|
-
|
|
4738
|
+
* message self IS the author, so the author-gate passes. `opts.mentions` carries the
|
|
4739
|
+
* edit's REPLACEMENT mention ranges → the edited message's mentions reflect them. */
|
|
4740
|
+
async edit(message, newText, opts) {
|
|
4135
4741
|
if (!message.clientMsgId || message.kind !== "text") return;
|
|
4136
4742
|
const group = await this.materializeIfNeeded();
|
|
4137
4743
|
const clientMsgId = mintClientMsgId();
|
|
4744
|
+
const bodyRanges = opts?.mentions ?? null;
|
|
4138
4745
|
const { receipt } = await this.backend.sendEdit(group, {
|
|
4139
4746
|
clientMsgId,
|
|
4140
4747
|
targetClientMsgId: message.clientMsgId,
|
|
4141
|
-
newText
|
|
4748
|
+
newText,
|
|
4749
|
+
bodyRanges
|
|
4142
4750
|
});
|
|
4143
4751
|
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
4144
4752
|
this.editFold.ingest(
|
|
@@ -4148,7 +4756,8 @@ var Chat = class {
|
|
|
4148
4756
|
newText,
|
|
4149
4757
|
epoch: receipt.epoch,
|
|
4150
4758
|
serverSeq: receipt.serverSeq,
|
|
4151
|
-
eventClientMsgId: clientMsgId
|
|
4759
|
+
eventClientMsgId: clientMsgId,
|
|
4760
|
+
bodyRanges
|
|
4152
4761
|
},
|
|
4153
4762
|
this.authorOfTarget
|
|
4154
4763
|
);
|
|
@@ -4201,6 +4810,18 @@ var Chat = class {
|
|
|
4201
4810
|
}
|
|
4202
4811
|
}
|
|
4203
4812
|
};
|
|
4813
|
+
function sameMentions(a, b) {
|
|
4814
|
+
if (a.length !== b.length) return false;
|
|
4815
|
+
for (let i = 0; i < a.length; i++) {
|
|
4816
|
+
const x = a[i];
|
|
4817
|
+
const y = b[i];
|
|
4818
|
+
if (!x || !y) return false;
|
|
4819
|
+
if (x.start !== y.start || x.length !== y.length || x.mentionedUserId !== y.mentionedUserId || x.displayName !== y.displayName) {
|
|
4820
|
+
return false;
|
|
4821
|
+
}
|
|
4822
|
+
}
|
|
4823
|
+
return true;
|
|
4824
|
+
}
|
|
4204
4825
|
function sameReactions(a, b) {
|
|
4205
4826
|
const ak = Object.keys(a);
|
|
4206
4827
|
const bk = Object.keys(b);
|
|
@@ -4381,6 +5002,7 @@ var MessageDeliverySource = class {
|
|
|
4381
5002
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
4382
5003
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4383
5004
|
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
5005
|
+
const isTimerSet = decoded.type === "timer_set" && decoded.timer != null;
|
|
4384
5006
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
4385
5007
|
const stored = {
|
|
4386
5008
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -4412,14 +5034,21 @@ var MessageDeliverySource = class {
|
|
|
4412
5034
|
// Thread the edit discriminator + new text through the persisted row so an
|
|
4413
5035
|
// edit folded LIVE re-folds onto its target after a reload (the reload-parity
|
|
4414
5036
|
// boundary — mirrors iOS T3). Omitted for non-edits → old rows hydrate as
|
|
4415
|
-
// `'text'`/no-edit (backward-compat).
|
|
5037
|
+
// `'text'`/no-edit (backward-compat). The edit's replacement body_ranges ride
|
|
5038
|
+
// along so the edited message's mentions re-resolve on cold launch (T6).
|
|
4416
5039
|
...isEdit && decoded.edit ? {
|
|
4417
5040
|
envelopeType: "edit",
|
|
4418
5041
|
edit: {
|
|
4419
5042
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
4420
|
-
newText: decoded.edit.newText
|
|
5043
|
+
newText: decoded.edit.newText,
|
|
5044
|
+
...decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {}
|
|
4421
5045
|
}
|
|
4422
5046
|
} : {},
|
|
5047
|
+
// Thread the TEXT bubble's mention ranges (raw) through the persisted row so a
|
|
5048
|
+
// mention surfaced LIVE re-resolves onto its bubble after a reload (the
|
|
5049
|
+
// reload-parity boundary for mentions — T6, mirrors iOS T3). Only on a text
|
|
5050
|
+
// bubble (not a reaction/edit/delete row); omitted when absent (backward-compat).
|
|
5051
|
+
...!isReaction && !isEdit && !isDelete && decoded.bodyRanges ? { bodyRanges: decoded.bodyRanges } : {},
|
|
4423
5052
|
// Thread the delete discriminator + target through the persisted row so a
|
|
4424
5053
|
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4425
5054
|
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
@@ -4431,7 +5060,18 @@ var MessageDeliverySource = class {
|
|
|
4431
5060
|
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4432
5061
|
scope: decoded.delete.scope
|
|
4433
5062
|
}
|
|
4434
|
-
} : {}
|
|
5063
|
+
} : {},
|
|
5064
|
+
// Disappearing T10: thread the timer_set discriminator + payload through the
|
|
5065
|
+
// persisted row so the chat default re-folds on cold launch (the page-local
|
|
5066
|
+
// TimerFold in projectHistory). Omitted for non-timer_set rows (backward-compat).
|
|
5067
|
+
...isTimerSet && decoded.timer ? {
|
|
5068
|
+
envelopeType: "timer_set",
|
|
5069
|
+
timer: { ttlSeconds: decoded.timer.ttlSeconds, start: decoded.timer.start }
|
|
5070
|
+
} : {},
|
|
5071
|
+
// Disappearing T10: thread a TEXT bubble's per-message expiry through the persisted
|
|
5072
|
+
// row so the message re-arms its purge on cold launch (the projection derives the
|
|
5073
|
+
// deadline from this). Only on a text bubble; omitted when absent (backward-compat).
|
|
5074
|
+
...!isReaction && !isEdit && !isDelete && !isTimerSet && decoded.expiry ? { expiry: decoded.expiry } : {}
|
|
4435
5075
|
};
|
|
4436
5076
|
try {
|
|
4437
5077
|
await this.messageStore.append(group.rfcGroupId, stored);
|
|
@@ -4451,7 +5091,15 @@ var MessageDeliverySource = class {
|
|
|
4451
5091
|
envelopeType: decoded.type ?? "text",
|
|
4452
5092
|
reaction: isReaction ? decoded.reaction : null,
|
|
4453
5093
|
edit: isEdit ? decoded.edit : null,
|
|
4454
|
-
delete: isDelete ? decoded.delete : null
|
|
5094
|
+
delete: isDelete ? decoded.delete : null,
|
|
5095
|
+
// The raw mention ranges (text bubble or the edit's replacement ranges); the
|
|
5096
|
+
// Chat normalizes + resolves names → ChatMessage.mentions (T6).
|
|
5097
|
+
bodyRanges: decoded.bodyRanges ?? null,
|
|
5098
|
+
// Disappearing T10: the decoded timer_set payload (chat default control) + a text
|
|
5099
|
+
// bubble's per-message expiry. The Chat routes timer_set into its TimerFold and
|
|
5100
|
+
// arms a bubble's purge from the expiry (or the active default).
|
|
5101
|
+
timer: isTimerSet ? decoded.timer : null,
|
|
5102
|
+
expiry: !isReaction && !isEdit && !isDelete && !isTimerSet ? decoded.expiry ?? null : null
|
|
4455
5103
|
});
|
|
4456
5104
|
return true;
|
|
4457
5105
|
}
|
|
@@ -4530,6 +5178,67 @@ function isOwnEchoOrConsumed(e) {
|
|
|
4530
5178
|
return msg.includes("message from self") || msg.includes("key not available, invalid generation");
|
|
4531
5179
|
}
|
|
4532
5180
|
|
|
5181
|
+
// src/messaging/disappearing.ts
|
|
5182
|
+
var DisappearingStore = class {
|
|
5183
|
+
constructor(kv) {
|
|
5184
|
+
this.kv = kv;
|
|
5185
|
+
}
|
|
5186
|
+
kv;
|
|
5187
|
+
key(rfc) {
|
|
5188
|
+
return `disappear:${rfc}`;
|
|
5189
|
+
}
|
|
5190
|
+
async load(rfc) {
|
|
5191
|
+
const raw = await this.kv.get(this.key(rfc));
|
|
5192
|
+
if (!raw) return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5193
|
+
try {
|
|
5194
|
+
const r = JSON.parse(decodeUtf8(raw));
|
|
5195
|
+
return {
|
|
5196
|
+
tombstonedSeqs: r.tombstonedSeqs ?? [],
|
|
5197
|
+
purgedClientMsgIds: r.purgedClientMsgIds ?? [],
|
|
5198
|
+
anchors: r.anchors ?? {}
|
|
5199
|
+
};
|
|
5200
|
+
} catch {
|
|
5201
|
+
return { tombstonedSeqs: [], purgedClientMsgIds: [], anchors: {} };
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
async save(rfc, rec) {
|
|
5205
|
+
await this.kv.set(this.key(rfc), encodeUtf8(JSON.stringify(rec)));
|
|
5206
|
+
}
|
|
5207
|
+
/** The persisted INTEGER `server_seq` tombstone set for a chat (transcript exclusion + redelivery drop). */
|
|
5208
|
+
async tombstonedSeqs(rfc) {
|
|
5209
|
+
return new Set((await this.load(rfc)).tombstonedSeqs);
|
|
5210
|
+
}
|
|
5211
|
+
/** The persisted STRING `client_msg_id` purge set for a chat (orphan-fold → `'purged'`). */
|
|
5212
|
+
async purgedClientMsgIds(rfc) {
|
|
5213
|
+
return new Set((await this.load(rfc)).purgedClientMsgIds);
|
|
5214
|
+
}
|
|
5215
|
+
/**
|
|
5216
|
+
* Tombstone-first commit point: the INTEGER seq and the STRING client_msg_id are
|
|
5217
|
+
* written together in ONE durable record. Idempotent (re-tombstoning the same seq /
|
|
5218
|
+
* id is a no-op). This write is THE purge commit — once it lands, a transcript rebuilt
|
|
5219
|
+
* from the store excludes the seq and a redelivery is dropped, even across a crash.
|
|
5220
|
+
*/
|
|
5221
|
+
async tombstone(rfc, serverSeq, clientMsgId) {
|
|
5222
|
+
const rec = await this.load(rfc);
|
|
5223
|
+
if (!rec.tombstonedSeqs.includes(serverSeq)) rec.tombstonedSeqs.push(serverSeq);
|
|
5224
|
+
if (clientMsgId && !rec.purgedClientMsgIds.includes(clientMsgId)) {
|
|
5225
|
+
rec.purgedClientMsgIds.push(clientMsgId);
|
|
5226
|
+
}
|
|
5227
|
+
await this.save(rfc, rec);
|
|
5228
|
+
}
|
|
5229
|
+
/** The persisted write-once anchor triple for a `clientMsgId`, or null if none. */
|
|
5230
|
+
async anchor(rfc, clientMsgId) {
|
|
5231
|
+
return (await this.load(rfc)).anchors[clientMsgId] ?? null;
|
|
5232
|
+
}
|
|
5233
|
+
/** Write-once: a second call for the same `clientMsgId` is a no-op (deadline never resets). */
|
|
5234
|
+
async writeAnchorOnce(rfc, clientMsgId, a) {
|
|
5235
|
+
const rec = await this.load(rfc);
|
|
5236
|
+
if (rec.anchors[clientMsgId]) return;
|
|
5237
|
+
rec.anchors[clientMsgId] = a;
|
|
5238
|
+
await this.save(rfc, rec);
|
|
5239
|
+
}
|
|
5240
|
+
};
|
|
5241
|
+
|
|
4533
5242
|
// src/messaging/history.ts
|
|
4534
5243
|
var MessageStore = class {
|
|
4535
5244
|
constructor(kv) {
|
|
@@ -4606,6 +5315,36 @@ var GroupCatalog = class {
|
|
|
4606
5315
|
}
|
|
4607
5316
|
};
|
|
4608
5317
|
|
|
5318
|
+
// src/messaging/mention-elevation.ts
|
|
5319
|
+
var MentionElevationStore = class {
|
|
5320
|
+
constructor(kv) {
|
|
5321
|
+
this.kv = kv;
|
|
5322
|
+
}
|
|
5323
|
+
kv;
|
|
5324
|
+
key(rfcGroupId) {
|
|
5325
|
+
return `elev:${rfcGroupId}`;
|
|
5326
|
+
}
|
|
5327
|
+
/** Load the persisted elevation keys for a chat (empty array if none). */
|
|
5328
|
+
async load(rfcGroupId) {
|
|
5329
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
5330
|
+
if (!raw) return [];
|
|
5331
|
+
try {
|
|
5332
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
5333
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5334
|
+
} catch {
|
|
5335
|
+
return [];
|
|
5336
|
+
}
|
|
5337
|
+
}
|
|
5338
|
+
/** Persist the full elevation key set for a chat (deterministic, deduped order). */
|
|
5339
|
+
async save(rfcGroupId, keys) {
|
|
5340
|
+
const sorted = [...new Set(keys)].sort();
|
|
5341
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
5342
|
+
}
|
|
5343
|
+
async wipe() {
|
|
5344
|
+
for (const k of await this.kv.keys("elev:")) await this.kv.delete(k);
|
|
5345
|
+
}
|
|
5346
|
+
};
|
|
5347
|
+
|
|
4609
5348
|
// src/messaging/wasm/pkg/palbe_mls_bg.js
|
|
4610
5349
|
var palbe_mls_bg_exports = {};
|
|
4611
5350
|
__export(palbe_mls_bg_exports, {
|
|
@@ -6312,6 +7051,8 @@ var MessagingCoordinator = class {
|
|
|
6312
7051
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
6313
7052
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6314
7053
|
this.suppressionStore = new SuppressionStore(this.kv);
|
|
7054
|
+
this.elevationStore = new MentionElevationStore(this.kv);
|
|
7055
|
+
this.disappearingStore = new DisappearingStore(this.kv);
|
|
6315
7056
|
this.registry.attachChatList(
|
|
6316
7057
|
(chats) => {
|
|
6317
7058
|
this.chatList = chats;
|
|
@@ -6327,6 +7068,8 @@ var MessagingCoordinator = class {
|
|
|
6327
7068
|
groupStore;
|
|
6328
7069
|
kpStore;
|
|
6329
7070
|
suppressionStore;
|
|
7071
|
+
elevationStore;
|
|
7072
|
+
disappearingStore;
|
|
6330
7073
|
registry = new GroupRegistry();
|
|
6331
7074
|
resolved = null;
|
|
6332
7075
|
resolvePromise = null;
|
|
@@ -6482,9 +7225,9 @@ var MessagingCoordinator = class {
|
|
|
6482
7225
|
});
|
|
6483
7226
|
return group;
|
|
6484
7227
|
}
|
|
6485
|
-
async sendText(group, text, replyTo) {
|
|
7228
|
+
async sendText(group, text, replyTo, bodyRanges) {
|
|
6486
7229
|
const r = await this.resolve();
|
|
6487
|
-
return r.groups.sendText(group, text, replyTo);
|
|
7230
|
+
return r.groups.sendText(group, text, replyTo, bodyRanges);
|
|
6488
7231
|
}
|
|
6489
7232
|
async sendReaction(group, args) {
|
|
6490
7233
|
const r = await this.resolve();
|
|
@@ -6498,6 +7241,10 @@ var MessagingCoordinator = class {
|
|
|
6498
7241
|
const r = await this.resolve();
|
|
6499
7242
|
return r.groups.sendDelete(group, args);
|
|
6500
7243
|
}
|
|
7244
|
+
async sendTimerSet(group, args) {
|
|
7245
|
+
const r = await this.resolve();
|
|
7246
|
+
return r.groups.sendTimerSet(group, args);
|
|
7247
|
+
}
|
|
6501
7248
|
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6502
7249
|
loadSuppressed(group) {
|
|
6503
7250
|
return this.suppressionStore.load(group.rfcGroupId);
|
|
@@ -6506,10 +7253,36 @@ var MessagingCoordinator = class {
|
|
|
6506
7253
|
saveSuppressed(group, keys) {
|
|
6507
7254
|
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6508
7255
|
}
|
|
7256
|
+
/** Load this chat's persisted self-elevation dedup keys (durable-only). */
|
|
7257
|
+
loadElevated(group) {
|
|
7258
|
+
return this.elevationStore.load(group.rfcGroupId);
|
|
7259
|
+
}
|
|
7260
|
+
/** Persist this chat's self-elevation dedup keys (durable-only, no wire). */
|
|
7261
|
+
saveElevated(group, keys) {
|
|
7262
|
+
return this.elevationStore.save(group.rfcGroupId, keys);
|
|
7263
|
+
}
|
|
7264
|
+
// ── Disappearing / TTL seam (durable-only, no wire) ──
|
|
7265
|
+
tombstonedSeqs(group) {
|
|
7266
|
+
return this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7267
|
+
}
|
|
7268
|
+
purgedClientMsgIds(group) {
|
|
7269
|
+
return this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7270
|
+
}
|
|
7271
|
+
anchor(group, clientMsgId) {
|
|
7272
|
+
return this.disappearingStore.anchor(group.rfcGroupId, clientMsgId);
|
|
7273
|
+
}
|
|
7274
|
+
writeAnchorOnce(group, clientMsgId, a) {
|
|
7275
|
+
return this.disappearingStore.writeAnchorOnce(group.rfcGroupId, clientMsgId, a);
|
|
7276
|
+
}
|
|
7277
|
+
tombstone(group, serverSeq, clientMsgId) {
|
|
7278
|
+
return this.disappearingStore.tombstone(group.rfcGroupId, serverSeq, clientMsgId);
|
|
7279
|
+
}
|
|
6509
7280
|
async history(group, limit, before) {
|
|
6510
7281
|
const r = await this.resolve();
|
|
6511
7282
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
6512
|
-
|
|
7283
|
+
const tombstoned = await this.disappearingStore.tombstonedSeqs(group.rfcGroupId);
|
|
7284
|
+
const purged = await this.disappearingStore.purgedClientMsgIds(group.rfcGroupId);
|
|
7285
|
+
return projectHistory(group.displayId, rows, this.selfUserId, void 0, tombstoned, purged);
|
|
6513
7286
|
}
|
|
6514
7287
|
async members(group) {
|
|
6515
7288
|
const r = await this.resolve();
|
|
@@ -6586,9 +7359,10 @@ var MessagingCoordinator = class {
|
|
|
6586
7359
|
return res.devices.map((d) => d.device_id);
|
|
6587
7360
|
}
|
|
6588
7361
|
};
|
|
6589
|
-
function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
7362
|
+
function projectHistory(displayId, rows, selfUserId, resolveActor, tombstonedSeqs = /* @__PURE__ */ new Set(), purgedClientMsgIds = /* @__PURE__ */ new Set()) {
|
|
7363
|
+
const visible = rows.filter((s) => !tombstonedSeqs.has(s.serverSeq));
|
|
6590
7364
|
const fold = new ReactionFold();
|
|
6591
|
-
for (const s of
|
|
7365
|
+
for (const s of visible) {
|
|
6592
7366
|
if (s.envelopeType !== "reaction" || !s.reaction) continue;
|
|
6593
7367
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6594
7368
|
if (actor === null) continue;
|
|
@@ -6604,17 +7378,22 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6604
7378
|
}
|
|
6605
7379
|
const editFold = new EditFold();
|
|
6606
7380
|
const deleteFold = new DeleteFold();
|
|
7381
|
+
const pageTimerFold = new TimerFold();
|
|
6607
7382
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6608
|
-
for (const s of
|
|
6609
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7383
|
+
for (const s of visible) {
|
|
7384
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6610
7385
|
continue;
|
|
6611
7386
|
const cid = s.clientMsgId ?? "";
|
|
6612
7387
|
if (!cid) continue;
|
|
6613
7388
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
6614
7389
|
if (author != null) authorByClientMsgId.set(cid, author);
|
|
6615
7390
|
}
|
|
6616
|
-
const authorOfTarget = (cid) =>
|
|
6617
|
-
|
|
7391
|
+
const authorOfTarget = (cid) => {
|
|
7392
|
+
if (purgedClientMsgIds.has(cid)) return { kind: "purged" };
|
|
7393
|
+
const a = authorByClientMsgId.get(cid);
|
|
7394
|
+
return a !== void 0 ? { kind: "author", userId: a } : { kind: "unknown" };
|
|
7395
|
+
};
|
|
7396
|
+
for (const s of visible) {
|
|
6618
7397
|
if (s.envelopeType !== "edit" || !s.edit) continue;
|
|
6619
7398
|
const editor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6620
7399
|
editFold.ingest(
|
|
@@ -6624,13 +7403,16 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6624
7403
|
newText: s.edit.newText,
|
|
6625
7404
|
epoch: s.epoch,
|
|
6626
7405
|
serverSeq: s.serverSeq,
|
|
6627
|
-
eventClientMsgId: s.clientMsgId ?? `${s.id}
|
|
7406
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`,
|
|
7407
|
+
// Mentions T6: the edit's replacement ranges ride the fold so the WINNING
|
|
7408
|
+
// edit's ranges drive the edited message's mentions on cold launch.
|
|
7409
|
+
bodyRanges: s.edit.bodyRanges ?? null
|
|
6628
7410
|
},
|
|
6629
7411
|
authorOfTarget
|
|
6630
7412
|
);
|
|
6631
7413
|
}
|
|
6632
7414
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6633
|
-
for (const s of
|
|
7415
|
+
for (const s of visible) {
|
|
6634
7416
|
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6635
7417
|
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6636
7418
|
deleteFold.ingest(
|
|
@@ -6644,10 +7426,10 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6644
7426
|
authorOfTarget
|
|
6645
7427
|
);
|
|
6646
7428
|
}
|
|
6647
|
-
for (const [cid
|
|
7429
|
+
for (const [cid] of authorByClientMsgId) deleteFold.reevaluatePending(cid, authorOfTarget(cid));
|
|
6648
7430
|
const lookup = /* @__PURE__ */ new Map();
|
|
6649
|
-
for (const s of
|
|
6650
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
7431
|
+
for (const s of visible) {
|
|
7432
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete" || s.envelopeType === "timer_set")
|
|
6651
7433
|
continue;
|
|
6652
7434
|
const cid = s.clientMsgId ?? "";
|
|
6653
7435
|
if (cid && s.text !== null) {
|
|
@@ -6656,7 +7438,21 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6656
7438
|
}
|
|
6657
7439
|
}
|
|
6658
7440
|
const out = [];
|
|
6659
|
-
for (const s of
|
|
7441
|
+
for (const s of visible) {
|
|
7442
|
+
if (s.envelopeType === "timer_set") {
|
|
7443
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
7444
|
+
if (actor !== null && s.timer) {
|
|
7445
|
+
pageTimerFold.ingest({
|
|
7446
|
+
ttlSeconds: s.timer.ttlSeconds,
|
|
7447
|
+
start: s.timer.start,
|
|
7448
|
+
actorUserId: actor,
|
|
7449
|
+
epoch: s.epoch,
|
|
7450
|
+
serverSeq: s.serverSeq,
|
|
7451
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
7452
|
+
});
|
|
7453
|
+
}
|
|
7454
|
+
continue;
|
|
7455
|
+
}
|
|
6660
7456
|
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6661
7457
|
continue;
|
|
6662
7458
|
const clientMsgId = s.clientMsgId ?? "";
|
|
@@ -6674,10 +7470,23 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6674
7470
|
replyTo: null,
|
|
6675
7471
|
reactions: {},
|
|
6676
7472
|
edited: false,
|
|
6677
|
-
isDeleted: true
|
|
7473
|
+
isDeleted: true,
|
|
7474
|
+
// Mentions T6: delete DOMINATES mentions too — a tombstoned message has none.
|
|
7475
|
+
mentions: [],
|
|
7476
|
+
// delete DOMINATES disappearing too — a tombstoned message carries no deadline.
|
|
7477
|
+
expiresAt: null
|
|
6678
7478
|
});
|
|
6679
7479
|
continue;
|
|
6680
7480
|
}
|
|
7481
|
+
let expiresAt = null;
|
|
7482
|
+
if (s.expiry) {
|
|
7483
|
+
expiresAt = new Date(s.at + s.expiry.ttlSeconds * 1e3);
|
|
7484
|
+
} else {
|
|
7485
|
+
const active = pageTimerFold.active();
|
|
7486
|
+
if (active && active.ttlSeconds !== null) {
|
|
7487
|
+
expiresAt = new Date(s.at + active.ttlSeconds * 1e3);
|
|
7488
|
+
}
|
|
7489
|
+
}
|
|
6681
7490
|
let replyTo = null;
|
|
6682
7491
|
if (s.replyTo) {
|
|
6683
7492
|
const ref = {
|
|
@@ -6694,23 +7503,37 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6694
7503
|
}
|
|
6695
7504
|
const editText = clientMsgId ? editFold.text(clientMsgId) : null;
|
|
6696
7505
|
const edited = clientMsgId ? editFold.isEdited(clientMsgId) : false;
|
|
7506
|
+
const text = editText ?? s.text;
|
|
7507
|
+
const rawRanges = editText !== null ? editFold.bodyRanges(clientMsgId) : s.bodyRanges;
|
|
7508
|
+
const mentions = normalizeMentionsNullNames(rawRanges, text);
|
|
6697
7509
|
out.push({
|
|
6698
7510
|
id: `${displayId}#${s.serverSeq}`,
|
|
6699
7511
|
kind: s.text != null ? "text" : "system",
|
|
6700
7512
|
direction: s.direction,
|
|
6701
7513
|
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6702
|
-
text
|
|
7514
|
+
text,
|
|
6703
7515
|
serverSeq: s.serverSeq,
|
|
6704
7516
|
sentAt: new Date(s.at),
|
|
6705
7517
|
clientMsgId,
|
|
6706
7518
|
replyTo,
|
|
6707
7519
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6708
7520
|
edited,
|
|
6709
|
-
isDeleted: false
|
|
7521
|
+
isDeleted: false,
|
|
7522
|
+
mentions,
|
|
7523
|
+
expiresAt
|
|
6710
7524
|
});
|
|
6711
7525
|
}
|
|
6712
7526
|
return out;
|
|
6713
7527
|
}
|
|
7528
|
+
function normalizeMentionsNullNames(raw, text) {
|
|
7529
|
+
if (text === null || !raw || raw.length === 0) return [];
|
|
7530
|
+
return normalizeMentionRangesUtf16(raw, text).map((r) => ({
|
|
7531
|
+
start: r.start,
|
|
7532
|
+
length: r.length,
|
|
7533
|
+
mentionedUserId: r.mentionedUserId,
|
|
7534
|
+
displayName: null
|
|
7535
|
+
}));
|
|
7536
|
+
}
|
|
6714
7537
|
|
|
6715
7538
|
// src/messaging/facade.ts
|
|
6716
7539
|
var PalbeMessaging = class {
|
|
@@ -7444,7 +8267,7 @@ function defaultSessionStorage(key) {
|
|
|
7444
8267
|
}
|
|
7445
8268
|
|
|
7446
8269
|
// src/version.ts
|
|
7447
|
-
var VERSION = "1.
|
|
8270
|
+
var VERSION = "1.6.0";
|
|
7448
8271
|
|
|
7449
8272
|
// src/runtime.ts
|
|
7450
8273
|
function buildRuntime(config) {
|
|
@@ -7804,4 +8627,4 @@ export {
|
|
|
7804
8627
|
pb,
|
|
7805
8628
|
createBoundClient
|
|
7806
8629
|
};
|
|
7807
|
-
//# sourceMappingURL=chunk-
|
|
8630
|
+
//# sourceMappingURL=chunk-A5WIPBGQ.js.map
|