@palbase/web 1.3.0 → 1.4.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-Cq7R-VH_.d.ts → analytics-facade-CbXprYrS.d.ts} +72 -0
- package/dist/{analytics-facade-B8UATgS4.d.cts → analytics-facade-DA9TGKLF.d.cts} +72 -0
- package/dist/{chunk-UX43AB4W.js → chunk-3EVGYJ5F.js} +380 -15
- package/dist/chunk-3EVGYJ5F.js.map +1 -0
- package/dist/index.cjs +379 -14
- 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 +379 -14
- 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 +379 -14
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +1 -1
- package/dist/next/index.cjs +379 -14
- 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-DSJiH1uV.d.ts → pb-CLfTLQzH.d.ts} +1 -1
- package/dist/{pb-DivIiUGR.d.cts → pb-SVs7vTOp.d.cts} +1 -1
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/package.json +3 -3
- package/dist/chunk-UX43AB4W.js.map +0 -1
package/dist/internal.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { u as PalbeConfig, K as PalbeRuntime } from './analytics-facade-
|
|
2
|
-
export { L as buildRuntime } from './analytics-facade-
|
|
1
|
+
import { u as PalbeConfig, K as PalbeRuntime } from './analytics-facade-DA9TGKLF.cjs';
|
|
2
|
+
export { L as buildRuntime } from './analytics-facade-DA9TGKLF.cjs';
|
|
3
3
|
import { B as BackendError } from './errors-fDoNdTrJ.cjs';
|
|
4
|
-
export { P as PB, c as createBoundClient } from './pb-
|
|
4
|
+
export { P as PB, c as createBoundClient } from './pb-SVs7vTOp.cjs';
|
|
5
5
|
import 'livekit-client';
|
|
6
6
|
import './storage-BPaeSG8K.cjs';
|
|
7
7
|
import './pooled-flags-Bwq4usn0.js';
|
package/dist/internal.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { u as PalbeConfig, K as PalbeRuntime } from './analytics-facade-
|
|
2
|
-
export { L as buildRuntime } from './analytics-facade-
|
|
1
|
+
import { u as PalbeConfig, K as PalbeRuntime } from './analytics-facade-CbXprYrS.js';
|
|
2
|
+
export { L as buildRuntime } from './analytics-facade-CbXprYrS.js';
|
|
3
3
|
import { B as BackendError } from './errors-fDoNdTrJ.js';
|
|
4
|
-
export { P as PB, c as createBoundClient } from './pb-
|
|
4
|
+
export { P as PB, c as createBoundClient } from './pb-CLfTLQzH.js';
|
|
5
5
|
import 'livekit-client';
|
|
6
6
|
import './storage-BPaeSG8K.js';
|
|
7
7
|
import './pooled-flags-Bwq4usn0.js';
|
package/dist/internal.js
CHANGED
package/dist/next/client.cjs
CHANGED
|
@@ -2413,6 +2413,69 @@ var PalbeFlags = class {
|
|
|
2413
2413
|
}
|
|
2414
2414
|
};
|
|
2415
2415
|
|
|
2416
|
+
// src/messaging/delete-fold.ts
|
|
2417
|
+
var DeleteFold = class {
|
|
2418
|
+
// targets with a confirmed valid tombstone — MONOTONIC (never removed within retention).
|
|
2419
|
+
tombstoned = /* @__PURE__ */ new Set();
|
|
2420
|
+
// target → the tombstone's authenticated actor userId, awaiting the target's arrival.
|
|
2421
|
+
pending = /* @__PURE__ */ new Map();
|
|
2422
|
+
// dedup of real wire events the fold could evaluate (tombstoned or parked in pending).
|
|
2423
|
+
seen = /* @__PURE__ */ new Set();
|
|
2424
|
+
// events parked because NEITHER the actor NOR the target's author was resolvable at ingest;
|
|
2425
|
+
// keyed by eventClientMsgId so a re-delivered unverifiable event is held exactly once.
|
|
2426
|
+
held = [];
|
|
2427
|
+
/**
|
|
2428
|
+
* Ingest one tombstone. `authorOfTarget` resolves the target message's AUTHOR
|
|
2429
|
+
* userId (null = target absent locally → defer).
|
|
2430
|
+
*/
|
|
2431
|
+
ingest(e, authorOfTarget) {
|
|
2432
|
+
if (this.tombstoned.has(e.targetClientMsgId)) return;
|
|
2433
|
+
if (this.seen.has(e.eventClientMsgId)) return;
|
|
2434
|
+
if (this.heldContains(e.eventClientMsgId)) return;
|
|
2435
|
+
const author = authorOfTarget(e.targetClientMsgId);
|
|
2436
|
+
if (author !== null) {
|
|
2437
|
+
this.seen.add(e.eventClientMsgId);
|
|
2438
|
+
if (e.actorUserId === null || e.actorUserId !== author) return;
|
|
2439
|
+
this.tombstoned.add(e.targetClientMsgId);
|
|
2440
|
+
} else if (e.actorUserId !== null) {
|
|
2441
|
+
this.seen.add(e.eventClientMsgId);
|
|
2442
|
+
this.pending.set(e.targetClientMsgId, e.actorUserId);
|
|
2443
|
+
} else {
|
|
2444
|
+
this.held.push(e);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
/** True once a valid tombstone has absorbed this target. */
|
|
2448
|
+
isTombstoned(targetClientMsgId) {
|
|
2449
|
+
return this.tombstoned.has(targetClientMsgId);
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* When a target message newly arrives with a resolved `author`, re-check any
|
|
2453
|
+
* pending tombstone for it AND re-attempt any held (unverifiable) tombstones
|
|
2454
|
+
* whose target is now resolvable. The deferred gate is the SAME comparison as
|
|
2455
|
+
* the in-order path.
|
|
2456
|
+
*/
|
|
2457
|
+
reevaluatePending(target, author) {
|
|
2458
|
+
const actor = this.pending.get(target);
|
|
2459
|
+
if (actor !== void 0) {
|
|
2460
|
+
if (author !== null && actor === author) {
|
|
2461
|
+
this.tombstoned.add(target);
|
|
2462
|
+
this.pending.delete(target);
|
|
2463
|
+
} else if (author !== null) {
|
|
2464
|
+
this.pending.delete(target);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
if (this.held.length === 0) return;
|
|
2468
|
+
const pendingHeld = this.held;
|
|
2469
|
+
this.held = [];
|
|
2470
|
+
for (const e of pendingHeld) {
|
|
2471
|
+
this.ingest(e, (t) => t === target ? author : null);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
heldContains(eventClientMsgId) {
|
|
2475
|
+
return this.held.some((h) => h.eventClientMsgId === eventClientMsgId);
|
|
2476
|
+
}
|
|
2477
|
+
};
|
|
2478
|
+
|
|
2416
2479
|
// src/messaging/edit-fold.ts
|
|
2417
2480
|
function orderLt(aEpoch, aSeq, bEpoch, bSeq) {
|
|
2418
2481
|
if (aEpoch !== bEpoch) return aEpoch < bEpoch;
|
|
@@ -2634,6 +2697,17 @@ async function listDevices(rt, userId) {
|
|
|
2634
2697
|
}
|
|
2635
2698
|
|
|
2636
2699
|
// src/messaging/group-messaging.ts
|
|
2700
|
+
function encodeDelete(args) {
|
|
2701
|
+
return encodeUtf8(
|
|
2702
|
+
JSON.stringify({
|
|
2703
|
+
v: 1,
|
|
2704
|
+
type: "delete",
|
|
2705
|
+
client_msg_id: args.clientMsgId,
|
|
2706
|
+
target_client_msg_id: args.targetClientMsgId,
|
|
2707
|
+
scope: "everyone"
|
|
2708
|
+
})
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2637
2711
|
function encodeEdit(args) {
|
|
2638
2712
|
return encodeUtf8(
|
|
2639
2713
|
JSON.stringify({
|
|
@@ -2671,6 +2745,18 @@ function decodeEnvelope(bytes) {
|
|
|
2671
2745
|
const s = decodeUtf8(bytes);
|
|
2672
2746
|
try {
|
|
2673
2747
|
const o = JSON.parse(s);
|
|
2748
|
+
if (typeof o === "object" && o !== null && o.type === "delete") {
|
|
2749
|
+
return {
|
|
2750
|
+
type: "delete",
|
|
2751
|
+
text: null,
|
|
2752
|
+
clientMsgId: o.client_msg_id ?? "",
|
|
2753
|
+
replyTo: null,
|
|
2754
|
+
delete: {
|
|
2755
|
+
targetClientMsgId: o.target_client_msg_id ?? "",
|
|
2756
|
+
scope: o.scope ?? "everyone"
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2674
2760
|
if (typeof o === "object" && o !== null && o.type === "reaction") {
|
|
2675
2761
|
return {
|
|
2676
2762
|
type: "reaction",
|
|
@@ -3058,6 +3144,55 @@ var GroupMessaging = class {
|
|
|
3058
3144
|
clientMsgId: args.clientMsgId
|
|
3059
3145
|
};
|
|
3060
3146
|
}
|
|
3147
|
+
/** Send a delete-for-everyone tombstone on a target message. Encrypts a
|
|
3148
|
+
* `type:'delete'` envelope at the current epoch and sends through the SAME MLS
|
|
3149
|
+
* application path as `sendText` (the server stays blind — a delete is just
|
|
3150
|
+
* another opaque application message; the original ciphertext row is NOT
|
|
3151
|
+
* removed). Persists the outgoing delete row so the tombstone re-folds onto its
|
|
3152
|
+
* target after a reload (the own-send half of the reload parity — the iOS-review
|
|
3153
|
+
* CRITICAL boundary; the projection's `.delete` branch re-folds it). NEVER
|
|
3154
|
+
* rebases (epoch-bound like any application message). */
|
|
3155
|
+
async sendDelete(group, args) {
|
|
3156
|
+
const plaintext = encodeDelete({
|
|
3157
|
+
clientMsgId: args.clientMsgId,
|
|
3158
|
+
targetClientMsgId: args.targetClientMsgId
|
|
3159
|
+
});
|
|
3160
|
+
const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
|
|
3161
|
+
const body = {
|
|
3162
|
+
ciphertext_b64: toBase64(ct),
|
|
3163
|
+
client_idem_key: randomId()
|
|
3164
|
+
};
|
|
3165
|
+
const wire = await palbeRequest(
|
|
3166
|
+
this.rt,
|
|
3167
|
+
"POST",
|
|
3168
|
+
MessagingPaths.groupMessages(group.displayId),
|
|
3169
|
+
{ body }
|
|
3170
|
+
);
|
|
3171
|
+
const stored = {
|
|
3172
|
+
id: `${group.rfcGroupId}#${wire.server_seq}`,
|
|
3173
|
+
direction: "outgoing",
|
|
3174
|
+
text: null,
|
|
3175
|
+
senderDeviceId: this.selfDeviceId,
|
|
3176
|
+
epoch: wire.epoch,
|
|
3177
|
+
serverSeq: wire.server_seq,
|
|
3178
|
+
at: Date.now(),
|
|
3179
|
+
clientMsgId: args.clientMsgId,
|
|
3180
|
+
replyTo: null,
|
|
3181
|
+
envelopeType: "delete",
|
|
3182
|
+
delete: {
|
|
3183
|
+
targetClientMsgId: args.targetClientMsgId,
|
|
3184
|
+
scope: "everyone"
|
|
3185
|
+
}
|
|
3186
|
+
};
|
|
3187
|
+
try {
|
|
3188
|
+
await this.messageStore.append(group.rfcGroupId, stored);
|
|
3189
|
+
} catch {
|
|
3190
|
+
}
|
|
3191
|
+
return {
|
|
3192
|
+
receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
|
|
3193
|
+
clientMsgId: args.clientMsgId
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3061
3196
|
// ── The rebase loop ──
|
|
3062
3197
|
async commitWithRebase(rfcGroupId, build) {
|
|
3063
3198
|
const gidBytes = fromBase64(rfcGroupId);
|
|
@@ -3175,6 +3310,7 @@ var ReactionFold = class {
|
|
|
3175
3310
|
};
|
|
3176
3311
|
|
|
3177
3312
|
// src/messaging/chat.ts
|
|
3313
|
+
var DELETED_DESCRIPTOR = "\u{1F6AB} This message was deleted";
|
|
3178
3314
|
var Chat = class {
|
|
3179
3315
|
/** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
|
|
3180
3316
|
id;
|
|
@@ -3196,6 +3332,15 @@ var Chat = class {
|
|
|
3196
3332
|
reactionFold = new ReactionFold();
|
|
3197
3333
|
/** The single authoritative edit fold for this chat (live + own-send + history). */
|
|
3198
3334
|
editFold = new EditFold();
|
|
3335
|
+
/** The single authoritative delete-for-everyone fold (live + own-send + history).
|
|
3336
|
+
* A tombstone scrubs its target in place (delete DOMINATES edit at render). */
|
|
3337
|
+
deleteFold = new DeleteFold();
|
|
3338
|
+
/** delete-for-me suppression keys (clientMsgId, or `seq:<serverSeq>` for legacy)
|
|
3339
|
+
* — the message is OMITTED from this view. Local + persisted per chat, NO wire. */
|
|
3340
|
+
suppressed = /* @__PURE__ */ new Set();
|
|
3341
|
+
/** True once the persisted suppression set has been loaded (so the omit applies
|
|
3342
|
+
* even on the cold-launch hydrate path before a fresh deleteForMe). */
|
|
3343
|
+
suppressedLoaded = false;
|
|
3199
3344
|
/** Per-target BASE (original) text, so the rendered text is `edit ?? original`.
|
|
3200
3345
|
* Seeded once per target (write-once base) so a 2nd own edit can't corrupt it. */
|
|
3201
3346
|
originalTextByClientMsgId = /* @__PURE__ */ new Map();
|
|
@@ -3244,7 +3389,7 @@ var Chat = class {
|
|
|
3244
3389
|
return this.kind === "direct";
|
|
3245
3390
|
}
|
|
3246
3391
|
get messages() {
|
|
3247
|
-
return this.
|
|
3392
|
+
return this.surfaced();
|
|
3248
3393
|
}
|
|
3249
3394
|
get members() {
|
|
3250
3395
|
return this.memberCache;
|
|
@@ -3253,13 +3398,49 @@ var Chat = class {
|
|
|
3253
3398
|
return this.typingList;
|
|
3254
3399
|
}
|
|
3255
3400
|
get lastMessage() {
|
|
3256
|
-
return this.
|
|
3401
|
+
return this.surfaced().at(-1) ?? null;
|
|
3257
3402
|
}
|
|
3258
3403
|
get unreadCount() {
|
|
3259
|
-
return this.
|
|
3260
|
-
(m) => m.direction === "incoming" && m.serverSeq > this.readWatermark
|
|
3404
|
+
return this.surfaced().filter(
|
|
3405
|
+
(m) => m.direction === "incoming" && !m.isDeleted && m.serverSeq > this.readWatermark
|
|
3261
3406
|
).length;
|
|
3262
3407
|
}
|
|
3408
|
+
/**
|
|
3409
|
+
* The RENDER PRECEDENCE — the single composition point (live AND history project
|
|
3410
|
+
* through it identically). Over the raw `messageList` (which already carries the
|
|
3411
|
+
* folded edit text + reactions + reply):
|
|
3412
|
+
* (1) in the delete-for-me suppression set → OMIT the message entirely;
|
|
3413
|
+
* (2) else tombstoned (delete-for-everyone) → the neutral "deleted" descriptor
|
|
3414
|
+
* with reactions/reply/edit HIDDEN (delete DOMINATES edit — short-circuit);
|
|
3415
|
+
* (3) else the row as-is (edit overlay + reactions + reply already applied).
|
|
3416
|
+
* Pure over (messageList, deleteFold, suppressed) — recomputed on every read so a
|
|
3417
|
+
* just-folded delete / just-suppressed key takes effect without rewriting rows.
|
|
3418
|
+
*/
|
|
3419
|
+
surfaced() {
|
|
3420
|
+
const out = [];
|
|
3421
|
+
for (const m of this.messageList) {
|
|
3422
|
+
const key = this.suppressionKey(m);
|
|
3423
|
+
if (this.suppressed.has(key)) continue;
|
|
3424
|
+
const tombstoned = m.clientMsgId && this.deleteFold.isTombstoned(m.clientMsgId) || m.isDeleted;
|
|
3425
|
+
if (tombstoned) {
|
|
3426
|
+
out.push({
|
|
3427
|
+
...m,
|
|
3428
|
+
text: DELETED_DESCRIPTOR,
|
|
3429
|
+
reactions: {},
|
|
3430
|
+
replyTo: null,
|
|
3431
|
+
edited: false,
|
|
3432
|
+
isDeleted: true
|
|
3433
|
+
});
|
|
3434
|
+
continue;
|
|
3435
|
+
}
|
|
3436
|
+
out.push(m);
|
|
3437
|
+
}
|
|
3438
|
+
return out;
|
|
3439
|
+
}
|
|
3440
|
+
/** The delete-for-me suppression key: clientMsgId when present, else `seq:<n>`. */
|
|
3441
|
+
suppressionKey(m) {
|
|
3442
|
+
return m.clientMsgId ? m.clientMsgId : `seq:${m.serverSeq}`;
|
|
3443
|
+
}
|
|
3263
3444
|
get title() {
|
|
3264
3445
|
if (this.titleOverride) return this.titleOverride;
|
|
3265
3446
|
if (this._group?.name) return this._group.name;
|
|
@@ -3283,9 +3464,28 @@ var Chat = class {
|
|
|
3283
3464
|
if (this.wired || this._state !== "active" || !this._group) return;
|
|
3284
3465
|
this.wired = true;
|
|
3285
3466
|
this.liveUnsub = this.backend.subscribeLive(this._group, this);
|
|
3467
|
+
void this.loadSuppressed();
|
|
3286
3468
|
void this.hydrateHistory();
|
|
3287
3469
|
void this.refreshMembers();
|
|
3288
3470
|
}
|
|
3471
|
+
/** Hydrate the persisted delete-for-me suppression keys (once), then re-emit so
|
|
3472
|
+
* any already-surfaced suppressed message is omitted (cold-launch parity). */
|
|
3473
|
+
async loadSuppressed() {
|
|
3474
|
+
if (this.suppressedLoaded || !this._group) return;
|
|
3475
|
+
this.suppressedLoaded = true;
|
|
3476
|
+
try {
|
|
3477
|
+
const keys = await this.backend.loadSuppressed(this._group);
|
|
3478
|
+
let changed = false;
|
|
3479
|
+
for (const k of keys) {
|
|
3480
|
+
if (!this.suppressed.has(k)) {
|
|
3481
|
+
this.suppressed.add(k);
|
|
3482
|
+
changed = true;
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
if (changed) this.emit();
|
|
3486
|
+
} catch {
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3289
3489
|
async hydrateHistory() {
|
|
3290
3490
|
if (this.historyLoaded || !this._group) return;
|
|
3291
3491
|
this.historyLoaded = true;
|
|
@@ -3296,13 +3496,13 @@ var Chat = class {
|
|
|
3296
3496
|
let changed = false;
|
|
3297
3497
|
for (const m of incoming) {
|
|
3298
3498
|
if (m.serverSeq <= 0) continue;
|
|
3299
|
-
if (m.clientMsgId && m.text !== null) {
|
|
3499
|
+
if (m.clientMsgId && m.text !== null && !m.isDeleted) {
|
|
3300
3500
|
this.byClientMsgId.set(m.clientMsgId, {
|
|
3301
3501
|
text: m.text,
|
|
3302
3502
|
senderUserId: m.senderUserId ?? ""
|
|
3303
3503
|
});
|
|
3304
3504
|
}
|
|
3305
|
-
if (m.clientMsgId) {
|
|
3505
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3306
3506
|
this.seedEditBase(m.clientMsgId, m.text, m.senderUserId ?? "");
|
|
3307
3507
|
}
|
|
3308
3508
|
}
|
|
@@ -3312,6 +3512,9 @@ var Chat = class {
|
|
|
3312
3512
|
const key = this.internalKey(m.serverSeq);
|
|
3313
3513
|
if (this.seenKeys.has(key)) continue;
|
|
3314
3514
|
this.seenKeys.add(key);
|
|
3515
|
+
if (m.clientMsgId && !m.isDeleted) {
|
|
3516
|
+
this.deleteFold.reevaluatePending(m.clientMsgId, m.senderUserId);
|
|
3517
|
+
}
|
|
3315
3518
|
this.messageList.push(this.applyEditOverlay(this.applyReactionTally(m)));
|
|
3316
3519
|
changed = true;
|
|
3317
3520
|
this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
|
|
@@ -3369,6 +3572,21 @@ var Chat = class {
|
|
|
3369
3572
|
this.recomputeEdit(incoming.edit.targetClientMsgId);
|
|
3370
3573
|
return;
|
|
3371
3574
|
}
|
|
3575
|
+
if (incoming.envelopeType === "delete" && incoming.delete) {
|
|
3576
|
+
const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
|
|
3577
|
+
this.deleteFold.ingest(
|
|
3578
|
+
{
|
|
3579
|
+
targetClientMsgId: incoming.delete.targetClientMsgId,
|
|
3580
|
+
actorUserId,
|
|
3581
|
+
epoch: incoming.epoch,
|
|
3582
|
+
serverSeq: incoming.serverSeq,
|
|
3583
|
+
eventClientMsgId: incoming.clientMsgId
|
|
3584
|
+
},
|
|
3585
|
+
this.authorOfTarget
|
|
3586
|
+
);
|
|
3587
|
+
this.emit();
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3372
3590
|
const incomingClientMsgId = incoming.clientMsgId;
|
|
3373
3591
|
const incomingReplyRef = incoming.replyRef;
|
|
3374
3592
|
let resolvedReplyTo = null;
|
|
@@ -3389,7 +3607,9 @@ var Chat = class {
|
|
|
3389
3607
|
// BEFORE its target — the dangling case — renders the moment the target lands).
|
|
3390
3608
|
reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {},
|
|
3391
3609
|
// Default false; applyEditOverlay below folds any edit that arrived first.
|
|
3392
|
-
edited: false
|
|
3610
|
+
edited: false,
|
|
3611
|
+
// Default false; surfaced() applies the tombstone scrub if a delete folded.
|
|
3612
|
+
isDeleted: false
|
|
3393
3613
|
};
|
|
3394
3614
|
if (incomingClientMsgId && incoming.text !== null) {
|
|
3395
3615
|
this.byClientMsgId.set(incomingClientMsgId, {
|
|
@@ -3400,6 +3620,7 @@ var Chat = class {
|
|
|
3400
3620
|
if (incomingClientMsgId) {
|
|
3401
3621
|
this.seedEditBase(incomingClientMsgId, incoming.text, senderUser);
|
|
3402
3622
|
this.editFold.reevaluateHeld(this.authorOfTarget);
|
|
3623
|
+
this.deleteFold.reevaluatePending(incomingClientMsgId, senderUser);
|
|
3403
3624
|
}
|
|
3404
3625
|
this.messageList.push(this.applyEditOverlay(msg));
|
|
3405
3626
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
@@ -3641,7 +3862,9 @@ var Chat = class {
|
|
|
3641
3862
|
// the dangling-target invariant uniform across every append path).
|
|
3642
3863
|
reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {},
|
|
3643
3864
|
// Own-sent edits fold via edit() after the fact; new sends start unedited.
|
|
3644
|
-
edited: false
|
|
3865
|
+
edited: false,
|
|
3866
|
+
// Own-sent deletes fold via deleteForEveryone() after the fact; start live.
|
|
3867
|
+
isDeleted: false
|
|
3645
3868
|
});
|
|
3646
3869
|
this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
|
|
3647
3870
|
this.emit();
|
|
@@ -3753,6 +3976,52 @@ var Chat = class {
|
|
|
3753
3976
|
);
|
|
3754
3977
|
this.recomputeEdit(message.clientMsgId);
|
|
3755
3978
|
}
|
|
3979
|
+
// ── Delete ──
|
|
3980
|
+
/** Delete a message for EVERYONE (a cooperative encrypted tombstone). Only the
|
|
3981
|
+
* ORIGINAL SENDER can do this — for an own message self IS the author, so the
|
|
3982
|
+
* author-gate passes. Legacy messages (empty clientMsgId) are DISABLED (a
|
|
3983
|
+
* tombstone keys on the target's clientMsgId, which they lack) — no-op. Sends a
|
|
3984
|
+
* `type:'delete'` envelope through the SAME MLS path as a text message (the
|
|
3985
|
+
* server stays blind), folds the own delete locally so the target scrubs in
|
|
3986
|
+
* place instantly (the durable echo dedups on the SAME wire clientMsgId), and
|
|
3987
|
+
* re-emits. NEVER appends a bubble. delete-for-me'ing the target becomes moot. */
|
|
3988
|
+
async deleteForEveryone(message) {
|
|
3989
|
+
if (!message.clientMsgId) return;
|
|
3990
|
+
const group = await this.materializeIfNeeded();
|
|
3991
|
+
const clientMsgId = mintClientMsgId();
|
|
3992
|
+
const { receipt } = await this.backend.sendDelete(group, {
|
|
3993
|
+
clientMsgId,
|
|
3994
|
+
targetClientMsgId: message.clientMsgId
|
|
3995
|
+
});
|
|
3996
|
+
this.seedEditBase(message.clientMsgId, message.text, this.backend.selfUserId);
|
|
3997
|
+
this.deleteFold.ingest(
|
|
3998
|
+
{
|
|
3999
|
+
targetClientMsgId: message.clientMsgId,
|
|
4000
|
+
actorUserId: this.backend.selfUserId,
|
|
4001
|
+
epoch: receipt.epoch,
|
|
4002
|
+
serverSeq: receipt.serverSeq,
|
|
4003
|
+
eventClientMsgId: clientMsgId
|
|
4004
|
+
},
|
|
4005
|
+
this.authorOfTarget
|
|
4006
|
+
);
|
|
4007
|
+
this.emit();
|
|
4008
|
+
}
|
|
4009
|
+
/** Delete a message for ME only — a LOCAL, per-device suppression. NO wire, NO
|
|
4010
|
+
* attribution, no server contact: the message is OMITTED from THIS view and the
|
|
4011
|
+
* suppression key persists per chat (survives reload). The key is the message's
|
|
4012
|
+
* clientMsgId when present, else `seq:<serverSeq>` for legacy messages. */
|
|
4013
|
+
async deleteForMe(message) {
|
|
4014
|
+
const key = message.clientMsgId ? message.clientMsgId : `seq:${message.serverSeq}`;
|
|
4015
|
+
if (this.suppressed.has(key)) return;
|
|
4016
|
+
this.suppressed.add(key);
|
|
4017
|
+
this.emit();
|
|
4018
|
+
if (this._group) {
|
|
4019
|
+
try {
|
|
4020
|
+
await this.backend.saveSuppressed(this._group, [...this.suppressed]);
|
|
4021
|
+
} catch {
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
3756
4025
|
};
|
|
3757
4026
|
function sameReactions(a, b) {
|
|
3758
4027
|
const ak = Object.keys(a);
|
|
@@ -3933,6 +4202,7 @@ var MessageDeliverySource = class {
|
|
|
3933
4202
|
const { text, clientMsgId, replyTo } = decoded;
|
|
3934
4203
|
const isReaction = decoded.type === "reaction" && decoded.reaction != null;
|
|
3935
4204
|
const isEdit = decoded.type === "edit" && decoded.edit != null;
|
|
4205
|
+
const isDelete = decoded.type === "delete" && decoded.delete != null;
|
|
3936
4206
|
const senderDeviceId = row.sender_device_id ?? decodeSenderDeviceId(received.sender);
|
|
3937
4207
|
const stored = {
|
|
3938
4208
|
id: `${group.rfcGroupId}#${row.server_seq}`,
|
|
@@ -3971,6 +4241,18 @@ var MessageDeliverySource = class {
|
|
|
3971
4241
|
targetClientMsgId: decoded.edit.targetClientMsgId,
|
|
3972
4242
|
newText: decoded.edit.newText
|
|
3973
4243
|
}
|
|
4244
|
+
} : {},
|
|
4245
|
+
// Thread the delete discriminator + target through the persisted row so a
|
|
4246
|
+
// delete-for-everyone tombstone folded LIVE re-folds onto its target after
|
|
4247
|
+
// a reload (the reload-parity boundary — the iOS-review CRITICAL lesson;
|
|
4248
|
+
// the projection's `.delete` branch re-folds it so it never leaks a blank
|
|
4249
|
+
// bubble). Omitted for non-deletes → old rows hydrate as `'text'`/no-delete.
|
|
4250
|
+
...isDelete && decoded.delete ? {
|
|
4251
|
+
envelopeType: "delete",
|
|
4252
|
+
delete: {
|
|
4253
|
+
targetClientMsgId: decoded.delete.targetClientMsgId,
|
|
4254
|
+
scope: decoded.delete.scope
|
|
4255
|
+
}
|
|
3974
4256
|
} : {}
|
|
3975
4257
|
};
|
|
3976
4258
|
try {
|
|
@@ -3990,7 +4272,8 @@ var MessageDeliverySource = class {
|
|
|
3990
4272
|
replyRef: replyTo,
|
|
3991
4273
|
envelopeType: decoded.type ?? "text",
|
|
3992
4274
|
reaction: isReaction ? decoded.reaction : null,
|
|
3993
|
-
edit: isEdit ? decoded.edit : null
|
|
4275
|
+
edit: isEdit ? decoded.edit : null,
|
|
4276
|
+
delete: isDelete ? decoded.delete : null
|
|
3994
4277
|
});
|
|
3995
4278
|
return true;
|
|
3996
4279
|
}
|
|
@@ -5812,6 +6095,36 @@ var SignatureKeyStore = class {
|
|
|
5812
6095
|
}
|
|
5813
6096
|
};
|
|
5814
6097
|
|
|
6098
|
+
// src/messaging/suppression.ts
|
|
6099
|
+
var SuppressionStore = class {
|
|
6100
|
+
constructor(kv) {
|
|
6101
|
+
this.kv = kv;
|
|
6102
|
+
}
|
|
6103
|
+
kv;
|
|
6104
|
+
key(rfcGroupId) {
|
|
6105
|
+
return `supp:${rfcGroupId}`;
|
|
6106
|
+
}
|
|
6107
|
+
/** Load the persisted suppression keys for a chat (empty array if none). */
|
|
6108
|
+
async load(rfcGroupId) {
|
|
6109
|
+
const raw = await this.kv.get(this.key(rfcGroupId));
|
|
6110
|
+
if (!raw) return [];
|
|
6111
|
+
try {
|
|
6112
|
+
const parsed = JSON.parse(decodeUtf8(raw));
|
|
6113
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
6114
|
+
} catch {
|
|
6115
|
+
return [];
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
6118
|
+
/** Persist the full suppression key set for a chat (deterministic order). */
|
|
6119
|
+
async save(rfcGroupId, keys) {
|
|
6120
|
+
const sorted = [...new Set(keys)].sort();
|
|
6121
|
+
await this.kv.set(this.key(rfcGroupId), encodeUtf8(JSON.stringify(sorted)));
|
|
6122
|
+
}
|
|
6123
|
+
async wipe() {
|
|
6124
|
+
for (const k of await this.kv.keys("supp:")) await this.kv.delete(k);
|
|
6125
|
+
}
|
|
6126
|
+
};
|
|
6127
|
+
|
|
5815
6128
|
// src/messaging/coordinator.ts
|
|
5816
6129
|
var MessagingCoordinator = class {
|
|
5817
6130
|
constructor(rt) {
|
|
@@ -5821,6 +6134,7 @@ var MessagingCoordinator = class {
|
|
|
5821
6134
|
this.sigStore = new SignatureKeyStore(this.kv);
|
|
5822
6135
|
this.groupStore = new GroupStateStorage(this.kv);
|
|
5823
6136
|
this.kpStore = new KeyPackageStorage(this.kv);
|
|
6137
|
+
this.suppressionStore = new SuppressionStore(this.kv);
|
|
5824
6138
|
this.registry.attachChatList(
|
|
5825
6139
|
(chats) => {
|
|
5826
6140
|
this.chatList = chats;
|
|
@@ -5835,6 +6149,7 @@ var MessagingCoordinator = class {
|
|
|
5835
6149
|
sigStore;
|
|
5836
6150
|
groupStore;
|
|
5837
6151
|
kpStore;
|
|
6152
|
+
suppressionStore;
|
|
5838
6153
|
registry = new GroupRegistry();
|
|
5839
6154
|
resolved = null;
|
|
5840
6155
|
resolvePromise = null;
|
|
@@ -6002,6 +6317,18 @@ var MessagingCoordinator = class {
|
|
|
6002
6317
|
const r = await this.resolve();
|
|
6003
6318
|
return r.groups.sendEdit(group, args);
|
|
6004
6319
|
}
|
|
6320
|
+
async sendDelete(group, args) {
|
|
6321
|
+
const r = await this.resolve();
|
|
6322
|
+
return r.groups.sendDelete(group, args);
|
|
6323
|
+
}
|
|
6324
|
+
/** Load this chat's persisted delete-for-me suppression keys (durable-only). */
|
|
6325
|
+
loadSuppressed(group) {
|
|
6326
|
+
return this.suppressionStore.load(group.rfcGroupId);
|
|
6327
|
+
}
|
|
6328
|
+
/** Persist this chat's delete-for-me suppression keys (durable-only, no wire). */
|
|
6329
|
+
saveSuppressed(group, keys) {
|
|
6330
|
+
return this.suppressionStore.save(group.rfcGroupId, keys);
|
|
6331
|
+
}
|
|
6005
6332
|
async history(group, limit, before) {
|
|
6006
6333
|
const r = await this.resolve();
|
|
6007
6334
|
const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
|
|
@@ -6099,9 +6426,11 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6099
6426
|
});
|
|
6100
6427
|
}
|
|
6101
6428
|
const editFold = new EditFold();
|
|
6429
|
+
const deleteFold = new DeleteFold();
|
|
6102
6430
|
const authorByClientMsgId = /* @__PURE__ */ new Map();
|
|
6103
6431
|
for (const s of rows) {
|
|
6104
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6432
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6433
|
+
continue;
|
|
6105
6434
|
const cid = s.clientMsgId ?? "";
|
|
6106
6435
|
if (!cid) continue;
|
|
6107
6436
|
const author = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null);
|
|
@@ -6124,9 +6453,25 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6124
6453
|
);
|
|
6125
6454
|
}
|
|
6126
6455
|
editFold.reevaluateHeld(authorOfTarget);
|
|
6456
|
+
for (const s of rows) {
|
|
6457
|
+
if (s.envelopeType !== "delete" || !s.delete) continue;
|
|
6458
|
+
const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
|
|
6459
|
+
deleteFold.ingest(
|
|
6460
|
+
{
|
|
6461
|
+
targetClientMsgId: s.delete.targetClientMsgId,
|
|
6462
|
+
actorUserId: actor,
|
|
6463
|
+
epoch: s.epoch,
|
|
6464
|
+
serverSeq: s.serverSeq,
|
|
6465
|
+
eventClientMsgId: s.clientMsgId ?? `${s.id}`
|
|
6466
|
+
},
|
|
6467
|
+
authorOfTarget
|
|
6468
|
+
);
|
|
6469
|
+
}
|
|
6470
|
+
for (const [cid, author] of authorByClientMsgId) deleteFold.reevaluatePending(cid, author);
|
|
6127
6471
|
const lookup = /* @__PURE__ */ new Map();
|
|
6128
6472
|
for (const s of rows) {
|
|
6129
|
-
if (s.envelopeType === "reaction")
|
|
6473
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6474
|
+
continue;
|
|
6130
6475
|
const cid = s.clientMsgId ?? "";
|
|
6131
6476
|
if (cid && s.text !== null) {
|
|
6132
6477
|
const senderUserId = s.direction === "outgoing" ? selfUserId : "";
|
|
@@ -6135,8 +6480,27 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6135
6480
|
}
|
|
6136
6481
|
const out = [];
|
|
6137
6482
|
for (const s of rows) {
|
|
6138
|
-
if (s.envelopeType === "reaction" || s.envelopeType === "edit")
|
|
6483
|
+
if (s.envelopeType === "reaction" || s.envelopeType === "edit" || s.envelopeType === "delete")
|
|
6484
|
+
continue;
|
|
6139
6485
|
const clientMsgId = s.clientMsgId ?? "";
|
|
6486
|
+
const isDeleted = clientMsgId ? deleteFold.isTombstoned(clientMsgId) : false;
|
|
6487
|
+
if (isDeleted) {
|
|
6488
|
+
out.push({
|
|
6489
|
+
id: `${displayId}#${s.serverSeq}`,
|
|
6490
|
+
kind: "text",
|
|
6491
|
+
direction: s.direction,
|
|
6492
|
+
senderUserId: s.direction === "outgoing" ? selfUserId : null,
|
|
6493
|
+
text: DELETED_DESCRIPTOR,
|
|
6494
|
+
serverSeq: s.serverSeq,
|
|
6495
|
+
sentAt: new Date(s.at),
|
|
6496
|
+
clientMsgId,
|
|
6497
|
+
replyTo: null,
|
|
6498
|
+
reactions: {},
|
|
6499
|
+
edited: false,
|
|
6500
|
+
isDeleted: true
|
|
6501
|
+
});
|
|
6502
|
+
continue;
|
|
6503
|
+
}
|
|
6140
6504
|
let replyTo = null;
|
|
6141
6505
|
if (s.replyTo) {
|
|
6142
6506
|
const ref = {
|
|
@@ -6164,7 +6528,8 @@ function projectHistory(displayId, rows, selfUserId, resolveActor) {
|
|
|
6164
6528
|
clientMsgId,
|
|
6165
6529
|
replyTo,
|
|
6166
6530
|
reactions: clientMsgId ? fold.tally(clientMsgId) : {},
|
|
6167
|
-
edited
|
|
6531
|
+
edited,
|
|
6532
|
+
isDeleted: false
|
|
6168
6533
|
});
|
|
6169
6534
|
}
|
|
6170
6535
|
return out;
|
|
@@ -6902,7 +7267,7 @@ function defaultSessionStorage(key) {
|
|
|
6902
7267
|
}
|
|
6903
7268
|
|
|
6904
7269
|
// src/version.ts
|
|
6905
|
-
var VERSION = "1.
|
|
7270
|
+
var VERSION = "1.4.0";
|
|
6906
7271
|
|
|
6907
7272
|
// src/runtime.ts
|
|
6908
7273
|
function buildRuntime(config) {
|