@palbase/web 1.1.1 → 1.2.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.
@@ -2730,6 +2730,18 @@ async function listDevices(rt, userId) {
2730
2730
  }
2731
2731
 
2732
2732
  // src/messaging/group-messaging.ts
2733
+ function encodeReaction(args) {
2734
+ return encodeUtf8(
2735
+ JSON.stringify({
2736
+ v: 1,
2737
+ type: "reaction",
2738
+ client_msg_id: args.clientMsgId,
2739
+ target_client_msg_id: args.targetClientMsgId,
2740
+ emoji: args.emoji,
2741
+ op: args.op
2742
+ })
2743
+ );
2744
+ }
2733
2745
  function encodeEnvelope(args) {
2734
2746
  const env = {
2735
2747
  v: 1,
@@ -2744,18 +2756,32 @@ function decodeEnvelope(bytes) {
2744
2756
  const s = decodeUtf8(bytes);
2745
2757
  try {
2746
2758
  const o = JSON.parse(s);
2759
+ if (typeof o === "object" && o !== null && o.type === "reaction") {
2760
+ return {
2761
+ type: "reaction",
2762
+ text: null,
2763
+ clientMsgId: o.client_msg_id ?? "",
2764
+ replyTo: null,
2765
+ reaction: {
2766
+ targetClientMsgId: o.target_client_msg_id ?? "",
2767
+ emoji: o.emoji ?? "",
2768
+ op: o.op ?? "add"
2769
+ }
2770
+ };
2771
+ }
2747
2772
  if (typeof o === "object" && o !== null && o.type === "text" && typeof o.v === "number") {
2748
2773
  return {
2774
+ type: "text",
2749
2775
  text: o.text ?? null,
2750
2776
  clientMsgId: o.client_msg_id ?? "",
2751
2777
  replyTo: o.reply_to ?? null
2752
2778
  };
2753
2779
  }
2754
2780
  if (typeof o === "object" && o !== null && o.type === "text") {
2755
- return { text: o.text ?? null, clientMsgId: "", replyTo: null };
2781
+ return { type: "text", text: o.text ?? null, clientMsgId: "", replyTo: null };
2756
2782
  }
2757
2783
  if (typeof o === "object" && o !== null && o.type === "media")
2758
- return { text: null, clientMsgId: "", replyTo: null };
2784
+ return { type: "media", text: null, clientMsgId: "", replyTo: null };
2759
2785
  } catch {
2760
2786
  }
2761
2787
  return { text: s, clientMsgId: "", replyTo: null };
@@ -3007,6 +3033,56 @@ var GroupMessaging = class {
3007
3033
  }
3008
3034
  return { receipt: { serverSeq: wire.server_seq, epoch: wire.epoch }, clientMsgId };
3009
3035
  }
3036
+ /** Send a reaction (add/remove of an emoji on a target message). Encrypts a
3037
+ * `type:'reaction'` envelope at the current epoch and sends through the SAME
3038
+ * MLS application path as `sendText` (the server stays blind — a reaction is
3039
+ * just another application message). Persists the outgoing reaction row so it
3040
+ * re-folds onto its target after a reload (the own-send half of the reload
3041
+ * parity). NEVER rebases (epoch-bound like any application message). */
3042
+ async sendReaction(group, args) {
3043
+ const plaintext = encodeReaction({
3044
+ clientMsgId: args.clientMsgId,
3045
+ targetClientMsgId: args.targetClientMsgId,
3046
+ emoji: args.emoji,
3047
+ op: args.op
3048
+ });
3049
+ const ct = await this.engine.encryptApplication(fromBase64(group.rfcGroupId), plaintext);
3050
+ const body = {
3051
+ ciphertext_b64: toBase64(ct),
3052
+ client_idem_key: randomId()
3053
+ };
3054
+ const wire = await palbeRequest(
3055
+ this.rt,
3056
+ "POST",
3057
+ MessagingPaths.groupMessages(group.displayId),
3058
+ { body }
3059
+ );
3060
+ const stored = {
3061
+ id: `${group.rfcGroupId}#${wire.server_seq}`,
3062
+ direction: "outgoing",
3063
+ text: null,
3064
+ senderDeviceId: this.selfDeviceId,
3065
+ epoch: wire.epoch,
3066
+ serverSeq: wire.server_seq,
3067
+ at: Date.now(),
3068
+ clientMsgId: args.clientMsgId,
3069
+ replyTo: null,
3070
+ envelopeType: "reaction",
3071
+ reaction: {
3072
+ targetClientMsgId: args.targetClientMsgId,
3073
+ emoji: args.emoji,
3074
+ op: args.op
3075
+ }
3076
+ };
3077
+ try {
3078
+ await this.messageStore.append(group.rfcGroupId, stored);
3079
+ } catch {
3080
+ }
3081
+ return {
3082
+ receipt: { serverSeq: wire.server_seq, epoch: wire.epoch },
3083
+ clientMsgId: args.clientMsgId
3084
+ };
3085
+ }
3010
3086
  // ── The rebase loop ──
3011
3087
  async commitWithRebase(rfcGroupId, build) {
3012
3088
  const gidBytes = fromBase64(rfcGroupId);
@@ -3068,6 +3144,61 @@ var GroupMessaging = class {
3068
3144
  }
3069
3145
  };
3070
3146
 
3147
+ // src/messaging/reaction-fold.ts
3148
+ function orderLte(aEpoch, aSeq, bEpoch, bSeq) {
3149
+ if (aEpoch !== bEpoch) return aEpoch < bEpoch;
3150
+ return aSeq <= bSeq;
3151
+ }
3152
+ var ReactionFold = class {
3153
+ // Map<targetClientMsgId, Map<actorUserId, UserState>>
3154
+ states = /* @__PURE__ */ new Map();
3155
+ // Dedup set: eventClientMsgId values already applied.
3156
+ seenEvents = /* @__PURE__ */ new Set();
3157
+ ingest(e) {
3158
+ if (this.seenEvents.has(e.eventClientMsgId)) return;
3159
+ this.seenEvents.add(e.eventClientMsgId);
3160
+ let byUser = this.states.get(e.targetClientMsgId);
3161
+ if (byUser === void 0) {
3162
+ byUser = /* @__PURE__ */ new Map();
3163
+ this.states.set(e.targetClientMsgId, byUser);
3164
+ }
3165
+ const prev = byUser.get(e.actorUserId);
3166
+ if (prev !== void 0) {
3167
+ if (orderLte(e.epoch, e.serverSeq, prev.orderEpoch, prev.orderSeq)) return;
3168
+ }
3169
+ byUser.set(e.actorUserId, {
3170
+ orderEpoch: e.epoch,
3171
+ orderSeq: e.serverSeq,
3172
+ emoji: e.op === "add" ? e.emoji : null
3173
+ });
3174
+ }
3175
+ /**
3176
+ * Returns a tally of `{ emoji → sorted userIds[] }` for the given target
3177
+ * message. Users whose last event was a `remove` (emoji === null) are
3178
+ * excluded. userId arrays are sorted for deterministic output (render
3179
+ * stability + golden tests).
3180
+ */
3181
+ tally(targetClientMsgId) {
3182
+ const byUser = this.states.get(targetClientMsgId);
3183
+ if (byUser === void 0) return {};
3184
+ const out = /* @__PURE__ */ new Map();
3185
+ for (const [userId, st] of byUser) {
3186
+ if (st.emoji === null) continue;
3187
+ let users = out.get(st.emoji);
3188
+ if (users === void 0) {
3189
+ users = [];
3190
+ out.set(st.emoji, users);
3191
+ }
3192
+ users.push(userId);
3193
+ }
3194
+ const result = {};
3195
+ for (const [emoji, users] of out) {
3196
+ result[emoji] = users.sort();
3197
+ }
3198
+ return result;
3199
+ }
3200
+ };
3201
+
3071
3202
  // src/messaging/chat.ts
3072
3203
  var Chat = class {
3073
3204
  /** Stable, URL/log-safe id (the grp_ display id once active; a reserved local id while draft). */
@@ -3086,6 +3217,8 @@ var Chat = class {
3086
3217
  seenKeys = /* @__PURE__ */ new Set();
3087
3218
  /** Index: clientMsgId → { text, senderUserId } for resolveReply lookups. */
3088
3219
  byClientMsgId = /* @__PURE__ */ new Map();
3220
+ /** The single authoritative reaction fold for this chat (live + own-send + history). */
3221
+ reactionFold = new ReactionFold();
3089
3222
  loadedEarliestSeq = null;
3090
3223
  historyLoaded = false;
3091
3224
  wired = false;
@@ -3192,7 +3325,7 @@ var Chat = class {
3192
3325
  const key = this.internalKey(m.serverSeq);
3193
3326
  if (this.seenKeys.has(key)) continue;
3194
3327
  this.seenKeys.add(key);
3195
- this.messageList.push(m);
3328
+ this.messageList.push(this.applyReactionTally(m));
3196
3329
  changed = true;
3197
3330
  this.loadedEarliestSeq = Math.min(this.loadedEarliestSeq ?? m.serverSeq, m.serverSeq);
3198
3331
  }
@@ -3217,6 +3350,22 @@ var Chat = class {
3217
3350
  senderUser = await this.backend.userIdForDevice(this._group, incoming.senderDeviceId);
3218
3351
  }
3219
3352
  const direction = senderUser !== null && senderUser === this.backend.selfUserId ? "outgoing" : "incoming";
3353
+ if (incoming.envelopeType === "reaction" && incoming.reaction) {
3354
+ const actorUserId = direction === "outgoing" ? this.backend.selfUserId : senderUser;
3355
+ if (actorUserId !== null) {
3356
+ this.reactionFold.ingest({
3357
+ targetClientMsgId: incoming.reaction.targetClientMsgId,
3358
+ actorUserId,
3359
+ emoji: incoming.reaction.emoji,
3360
+ op: incoming.reaction.op,
3361
+ epoch: incoming.epoch,
3362
+ serverSeq: incoming.serverSeq,
3363
+ eventClientMsgId: incoming.clientMsgId
3364
+ });
3365
+ this.recomputeReactions(incoming.reaction.targetClientMsgId);
3366
+ }
3367
+ return;
3368
+ }
3220
3369
  const incomingClientMsgId = incoming.clientMsgId;
3221
3370
  const incomingReplyRef = incoming.replyRef;
3222
3371
  let resolvedReplyTo = null;
@@ -3232,7 +3381,10 @@ var Chat = class {
3232
3381
  serverSeq: incoming.serverSeq,
3233
3382
  sentAt: incoming.receivedAt,
3234
3383
  clientMsgId: incomingClientMsgId,
3235
- replyTo: resolvedReplyTo
3384
+ replyTo: resolvedReplyTo,
3385
+ // Attach any tally already folded for this message (a reaction that arrived
3386
+ // BEFORE its target — the dangling case — renders the moment the target lands).
3387
+ reactions: incomingClientMsgId ? this.reactionFold.tally(incomingClientMsgId) : {}
3236
3388
  };
3237
3389
  if (incomingClientMsgId && incoming.text !== null) {
3238
3390
  this.byClientMsgId.set(incomingClientMsgId, {
@@ -3248,6 +3400,35 @@ var Chat = class {
3248
3400
  );
3249
3401
  this.emit();
3250
3402
  }
3403
+ /**
3404
+ * Rebuild the target message's `reactions` from the authoritative fold and
3405
+ * re-emit. No-op when the target isn't present yet (its tally is attached the
3406
+ * moment it lands, via the append/merge paths) or when the tally is unchanged.
3407
+ */
3408
+ recomputeReactions(targetClientMsgId) {
3409
+ if (!targetClientMsgId) return;
3410
+ const tally = this.reactionFold.tally(targetClientMsgId);
3411
+ let changed = false;
3412
+ this.messageList = this.messageList.map((m) => {
3413
+ if (m.clientMsgId !== targetClientMsgId) return m;
3414
+ if (sameReactions(m.reactions, tally)) return m;
3415
+ changed = true;
3416
+ return { ...m, reactions: tally };
3417
+ });
3418
+ if (changed) this.emit();
3419
+ }
3420
+ /**
3421
+ * Overlay the authoritative fold's tally onto a message as it is appended/merged.
3422
+ * The Chat fold WINS when it has a non-empty tally; otherwise the tally already
3423
+ * attached upstream (the coordinator's page-local history fold) is preserved.
3424
+ */
3425
+ applyReactionTally(m) {
3426
+ if (!m.clientMsgId) return m;
3427
+ const tally = this.reactionFold.tally(m.clientMsgId);
3428
+ if (Object.keys(tally).length === 0) return m;
3429
+ if (sameReactions(m.reactions, tally)) return m;
3430
+ return { ...m, reactions: tally };
3431
+ }
3251
3432
  /** @internal — called by the backend's conv subscription. */
3252
3433
  applyConv(event, payload) {
3253
3434
  const userId = typeof payload.user_id === "string" ? payload.user_id : null;
@@ -3390,7 +3571,10 @@ var Chat = class {
3390
3571
  serverSeq: receipt.serverSeq,
3391
3572
  sentAt: /* @__PURE__ */ new Date(),
3392
3573
  clientMsgId,
3393
- replyTo: resolvedReplyTo
3574
+ replyTo: resolvedReplyTo,
3575
+ // Attach any tally already folded for this own-sent message (rare, but keeps
3576
+ // the dangling-target invariant uniform across every append path).
3577
+ reactions: clientMsgId ? this.reactionFold.tally(clientMsgId) : {}
3394
3578
  });
3395
3579
  this.messageList.sort((a, b) => a.serverSeq - b.serverSeq);
3396
3580
  this.emit();
@@ -3437,7 +3621,53 @@ var Chat = class {
3437
3621
  this.readWatermark = Math.max(this.readWatermark, message.serverSeq);
3438
3622
  this.emit();
3439
3623
  }
3624
+ // ── Reactions ──
3625
+ /** Add an emoji reaction to a message. No-op if the message isn't reactable
3626
+ * (empty clientMsgId — a legacy/system row). The reaction folds locally with
3627
+ * the server receipt's `(epoch, serverSeq)` so the target's tally updates
3628
+ * instantly; the durable echo on the next pump is a fold no-op (dedup on the
3629
+ * SAME wire clientMsgId). Never appends a bubble. */
3630
+ async react(message, emoji) {
3631
+ await this.sendReaction(message, emoji, "add");
3632
+ }
3633
+ /** Remove this user's emoji reaction from a message (op:'remove'). */
3634
+ async unreact(message, emoji) {
3635
+ await this.sendReaction(message, emoji, "remove");
3636
+ }
3637
+ async sendReaction(message, emoji, op) {
3638
+ if (!message.clientMsgId) return;
3639
+ const group = await this.materializeIfNeeded();
3640
+ const clientMsgId = mintClientMsgId();
3641
+ const { receipt } = await this.backend.sendReaction(group, {
3642
+ clientMsgId,
3643
+ targetClientMsgId: message.clientMsgId,
3644
+ emoji,
3645
+ op
3646
+ });
3647
+ this.reactionFold.ingest({
3648
+ targetClientMsgId: message.clientMsgId,
3649
+ actorUserId: this.backend.selfUserId,
3650
+ emoji,
3651
+ op,
3652
+ epoch: receipt.epoch,
3653
+ serverSeq: receipt.serverSeq,
3654
+ eventClientMsgId: clientMsgId
3655
+ });
3656
+ this.recomputeReactions(message.clientMsgId);
3657
+ }
3440
3658
  };
3659
+ function sameReactions(a, b) {
3660
+ const ak = Object.keys(a);
3661
+ const bk = Object.keys(b);
3662
+ if (ak.length !== bk.length) return false;
3663
+ for (const k of ak) {
3664
+ const av = a[k];
3665
+ const bv = b[k];
3666
+ if (!bv || av === void 0 || av.length !== bv.length) return false;
3667
+ for (let i = 0; i < av.length; i++) if (av[i] !== bv[i]) return false;
3668
+ }
3669
+ return true;
3670
+ }
3441
3671
 
3442
3672
  // src/messaging/delivery-source.ts
3443
3673
  var MessageHub = class {
@@ -3596,7 +3826,9 @@ var MessageDeliverySource = class {
3596
3826
  try {
3597
3827
  const received = await this.engine.processIncoming(fromBase64(group.rfcGroupId), blob);
3598
3828
  if (received.type === "application") {
3599
- const { text, clientMsgId, replyTo } = decodeEnvelope(received.data);
3829
+ const decoded = decodeEnvelope(received.data);
3830
+ const { text, clientMsgId, replyTo } = decoded;
3831
+ const isReaction = decoded.type === "reaction" && decoded.reaction != null;
3600
3832
  const stored = {
3601
3833
  id: `${group.rfcGroupId}#${row.server_seq}`,
3602
3834
  direction: "incoming",
@@ -3611,7 +3843,19 @@ var MessageDeliverySource = class {
3611
3843
  previewBody: replyTo.preview?.body ?? null,
3612
3844
  previewAuthorUserId: replyTo.preview?.author_user_id ?? null,
3613
3845
  previewKind: replyTo.preview?.kind ?? "text"
3614
- } : null
3846
+ } : null,
3847
+ // Thread the reaction discriminator + fields through the persisted row so
3848
+ // a reaction folded LIVE re-folds onto its target after a reload (the
3849
+ // reload-parity boundary — mirrors iOS T3). Omitted for non-reactions →
3850
+ // old rows hydrate as `'text'`/no-reaction (backward-compat).
3851
+ ...isReaction && decoded.reaction ? {
3852
+ envelopeType: "reaction",
3853
+ reaction: {
3854
+ targetClientMsgId: decoded.reaction.targetClientMsgId,
3855
+ emoji: decoded.reaction.emoji,
3856
+ op: decoded.reaction.op
3857
+ }
3858
+ } : {}
3615
3859
  };
3616
3860
  try {
3617
3861
  await this.messageStore.append(group.rfcGroupId, stored);
@@ -3627,7 +3871,9 @@ var MessageDeliverySource = class {
3627
3871
  serverSeq: row.server_seq,
3628
3872
  receivedAt: /* @__PURE__ */ new Date(),
3629
3873
  clientMsgId,
3630
- replyRef: replyTo
3874
+ replyRef: replyTo,
3875
+ envelopeType: decoded.type ?? "text",
3876
+ reaction: isReaction ? decoded.reaction : null
3631
3877
  });
3632
3878
  return true;
3633
3879
  }
@@ -5630,46 +5876,14 @@ var MessagingCoordinator = class {
5630
5876
  const r = await this.resolve();
5631
5877
  return r.groups.sendText(group, text, replyTo);
5632
5878
  }
5879
+ async sendReaction(group, args) {
5880
+ const r = await this.resolve();
5881
+ return r.groups.sendReaction(group, args);
5882
+ }
5633
5883
  async history(group, limit, before) {
5634
5884
  const r = await this.resolve();
5635
5885
  const rows = await r.messageStore.history(group.rfcGroupId, limit, before);
5636
- const lookup = /* @__PURE__ */ new Map();
5637
- for (const s of rows) {
5638
- const cid = s.clientMsgId ?? "";
5639
- if (cid && s.text !== null) {
5640
- const senderUserId = s.direction === "outgoing" ? this.selfUserId : "";
5641
- lookup.set(cid, { text: s.text, senderUserId });
5642
- }
5643
- }
5644
- return rows.map((s) => this.toChatMessage(group, s, (id) => lookup.get(id) ?? null));
5645
- }
5646
- toChatMessage(group, s, lookup) {
5647
- const clientMsgId = s.clientMsgId ?? "";
5648
- let replyTo = null;
5649
- if (s.replyTo && lookup) {
5650
- const ref = {
5651
- v: 1,
5652
- client_msg_id: s.replyTo.clientMsgId,
5653
- preview: {
5654
- kind: s.replyTo.previewKind,
5655
- author_user_id: s.replyTo.previewAuthorUserId ?? "",
5656
- body: s.replyTo.previewBody ?? void 0,
5657
- body_truncated: false
5658
- }
5659
- };
5660
- replyTo = resolveReply(ref, lookup);
5661
- }
5662
- return {
5663
- id: `${group.displayId}#${s.serverSeq}`,
5664
- kind: s.text != null ? "text" : "system",
5665
- direction: s.direction,
5666
- senderUserId: s.direction === "outgoing" ? this.selfUserId : null,
5667
- text: s.text,
5668
- serverSeq: s.serverSeq,
5669
- sentAt: new Date(s.at),
5670
- clientMsgId,
5671
- replyTo
5672
- };
5886
+ return projectHistory(group.displayId, rows, this.selfUserId);
5673
5887
  }
5674
5888
  async members(group) {
5675
5889
  const r = await this.resolve();
@@ -5746,6 +5960,64 @@ var MessagingCoordinator = class {
5746
5960
  return res.devices.map((d) => d.device_id);
5747
5961
  }
5748
5962
  };
5963
+ function projectHistory(displayId, rows, selfUserId, resolveActor) {
5964
+ const fold = new ReactionFold();
5965
+ for (const s of rows) {
5966
+ if (s.envelopeType !== "reaction" || !s.reaction) continue;
5967
+ const actor = s.direction === "outgoing" ? selfUserId : resolveActor?.(s.senderDeviceId ?? null) ?? null;
5968
+ if (actor === null) continue;
5969
+ fold.ingest({
5970
+ targetClientMsgId: s.reaction.targetClientMsgId,
5971
+ actorUserId: actor,
5972
+ emoji: s.reaction.emoji,
5973
+ op: s.reaction.op,
5974
+ epoch: s.epoch,
5975
+ serverSeq: s.serverSeq,
5976
+ eventClientMsgId: s.clientMsgId ?? `${s.id}`
5977
+ });
5978
+ }
5979
+ const lookup = /* @__PURE__ */ new Map();
5980
+ for (const s of rows) {
5981
+ if (s.envelopeType === "reaction") continue;
5982
+ const cid = s.clientMsgId ?? "";
5983
+ if (cid && s.text !== null) {
5984
+ const senderUserId = s.direction === "outgoing" ? selfUserId : "";
5985
+ lookup.set(cid, { text: s.text, senderUserId });
5986
+ }
5987
+ }
5988
+ const out = [];
5989
+ for (const s of rows) {
5990
+ if (s.envelopeType === "reaction") continue;
5991
+ const clientMsgId = s.clientMsgId ?? "";
5992
+ let replyTo = null;
5993
+ if (s.replyTo) {
5994
+ const ref = {
5995
+ v: 1,
5996
+ client_msg_id: s.replyTo.clientMsgId,
5997
+ preview: {
5998
+ kind: s.replyTo.previewKind,
5999
+ author_user_id: s.replyTo.previewAuthorUserId ?? "",
6000
+ body: s.replyTo.previewBody ?? void 0,
6001
+ body_truncated: false
6002
+ }
6003
+ };
6004
+ replyTo = resolveReply(ref, (id) => lookup.get(id) ?? null);
6005
+ }
6006
+ out.push({
6007
+ id: `${displayId}#${s.serverSeq}`,
6008
+ kind: s.text != null ? "text" : "system",
6009
+ direction: s.direction,
6010
+ senderUserId: s.direction === "outgoing" ? selfUserId : null,
6011
+ text: s.text,
6012
+ serverSeq: s.serverSeq,
6013
+ sentAt: new Date(s.at),
6014
+ clientMsgId,
6015
+ replyTo,
6016
+ reactions: clientMsgId ? fold.tally(clientMsgId) : {}
6017
+ });
6018
+ }
6019
+ return out;
6020
+ }
5749
6021
 
5750
6022
  // src/messaging/facade.ts
5751
6023
  var PalbeMessaging = class {
@@ -6479,7 +6751,7 @@ function defaultSessionStorage(key) {
6479
6751
  }
6480
6752
 
6481
6753
  // src/version.ts
6482
- var VERSION = "1.1.1";
6754
+ var VERSION = "1.2.0";
6483
6755
 
6484
6756
  // src/runtime.ts
6485
6757
  function buildRuntime(config) {
@@ -6839,4 +7111,4 @@ export {
6839
7111
  pb,
6840
7112
  createBoundClient
6841
7113
  };
6842
- //# sourceMappingURL=chunk-EMQGOKW6.js.map
7114
+ //# sourceMappingURL=chunk-OZLVL7G2.js.map