@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.
@@ -1,7 +1,7 @@
1
- import { u as PalbeConfig, K as PalbeRuntime } from './analytics-facade-B8UATgS4.cjs';
2
- export { L as buildRuntime } from './analytics-facade-B8UATgS4.cjs';
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-DivIiUGR.cjs';
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';
@@ -1,7 +1,7 @@
1
- import { u as PalbeConfig, K as PalbeRuntime } from './analytics-facade-Cq7R-VH_.js';
2
- export { L as buildRuntime } from './analytics-facade-Cq7R-VH_.js';
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-DSJiH1uV.js';
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
@@ -6,7 +6,7 @@ import {
6
6
  createBoundClient,
7
7
  getRuntime,
8
8
  onConfigured
9
- } from "./chunk-UX43AB4W.js";
9
+ } from "./chunk-3EVGYJ5F.js";
10
10
  export {
11
11
  __configure,
12
12
  __registerNamespaces,
@@ -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.messageList;
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.messageList.at(-1) ?? null;
3401
+ return this.surfaced().at(-1) ?? null;
3257
3402
  }
3258
3403
  get unreadCount() {
3259
- return this.messageList.filter(
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") continue;
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") continue;
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") continue;
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.3.0";
7270
+ var VERSION = "1.4.0";
6906
7271
 
6907
7272
  // src/runtime.ts
6908
7273
  function buildRuntime(config) {