@droponair/sdk-js 0.32.0 → 0.33.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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Added
6
+
7
+ - **Edit and delete group messages.** `editGroupMessage(groupId, originalMessageId,
8
+ newText, memberUserIds)`, `editCleartextGroupMessage` and `deleteGroupMessage`
9
+ (scope `FOR_EVERYONE` by default, or `FOR_ME`). An E2EE edit is encrypted for each
10
+ member and device, as a group message is. Only the sender of a message can change it.
11
+ - **`onGroupMessageEdit` and `onGroupMessageDelete`.** Fired live, and again from
12
+ the offline fetch for a member who was away, after that member's messages, so
13
+ applying one twice must be harmless. A member who fetches after a delete for
14
+ everyone is not handed the message.
15
+
16
+ ## 0.32.1
17
+
18
+ ### Fixed
19
+
20
+ - **A sender reported an error for every message it sent.** A message is echoed
21
+ back to every device of the sender, including the one that sent it, and what it
22
+ carries is encrypted for the recipients. The sending device tried to decrypt it,
23
+ which cannot work, and reported the failure each time. Such an echo is now
24
+ recognised and skipped. Nothing was lost before; the error was always wrong,
25
+ and an error that is always wrong hides the ones that are not.
26
+
27
+ - **Reconnecting could stop for good.** A reconnect attempt that failed before a
28
+ socket existed, typically fetching a token with no network, emitted
29
+ `RECONNECT_FAILED` and scheduled nothing further, so the client stayed offline.
30
+ It now schedules the next attempt while reconnecting is still wanted.
31
+
3
32
  ## 0.32.0
4
33
 
5
34
  ### Added
package/README.md CHANGED
@@ -93,6 +93,11 @@ const client = await initialize(options);
93
93
  | `editCleartextMessage(originalMessageId, toUserId, newText)` | `Promise<{ editId }>` | Edit a previously sent cleartext message |
94
94
  | `onMessage(callback)` | `() => void` | Listen for incoming messages. Returns unsubscribe function |
95
95
  | `onMessageEdit(callback)` | `() => void` | Listen for inbound edits to 1:1 messages |
96
+ | `editGroupMessage(groupId, originalMessageId, newText, memberUserIds)` | `Promise<{ editId }>` | Edit a message you sent to a group, encrypted per member |
97
+ | `editCleartextGroupMessage(groupId, originalMessageId, newText)` | `Promise<{ editId }>` | Edit a cleartext message you sent to a group |
98
+ | `deleteGroupMessage(groupId, originalMessageId, scope?)` | `Promise<{ deleteId }>` | Delete a message you sent to a group. `scope` defaults to `FOR_EVERYONE` |
99
+ | `onGroupMessageEdit(callback)` | `() => void` | Listen for edits to group messages |
100
+ | `onGroupMessageDelete(callback)` | `() => void` | Listen for deletes of group messages |
96
101
  | `onMessageDelete(callback)` | `() => void` | Listen for inbound delete tombstones for 1:1 messages |
97
102
  | `onEvent(callback)` | `() => void` | Listen for system events (CONNECTED, DELIVERED, ERROR, etc.) |
98
103
  | `ack(messageId)` | `Promise<void>` | Manually acknowledge a message |
@@ -286,6 +291,19 @@ await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_EVERYONE
286
291
  // Delete only on the sender's own devices
287
292
  await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_ME');
288
293
 
294
+ // A group message: name the group, and pass the members you sent to
295
+ await client.editGroupMessage(groupId, messageId, 'Updated text', memberUserIds);
296
+ await client.deleteGroupMessage(groupId, messageId); // FOR_EVERYONE by default
297
+
298
+ client.onGroupMessageEdit((edit) => {
299
+ console.log('group edit', edit.groupId, edit.originalMessageId, edit.plaintext);
300
+ });
301
+ client.onGroupMessageDelete((del) => {
302
+ console.log('group delete', del.groupId, del.originalMessageId);
303
+ });
304
+ // Group edits and deletes also arrive after an absence, so applying one twice
305
+ // must be harmless, and a delete can name a message this device never received.
306
+
289
307
  client.onMessageEdit((edit) => {
290
308
  console.log('edited', edit.originalMessageId, edit.text);
291
309
  });
@@ -1,6 +1,6 @@
1
1
  import { CryptoService } from '../crypto/crypto-service';
2
2
  import { SessionManager } from './session-manager';
3
- import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, CreateRoomOptions, GroupCallEventCallback, GroupInfo, GroupMessageCallback, Room, UpdateRoomOptions, SfuToken, SfuRecording, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback, TypingCallback, NotificationClearCallback, DraftSyncCallback, KeyCustody, PushPayload } from './types';
3
+ import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, CreateRoomOptions, GroupCallEventCallback, GroupInfo, GroupMessageCallback, GroupMessageDeleteCallback, GroupMessageEditCallback, Room, UpdateRoomOptions, SfuToken, SfuRecording, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback, TypingCallback, NotificationClearCallback, DraftSyncCallback, KeyCustody, PushPayload } from './types';
4
4
  import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from '../attachment/attachment-types';
5
5
  export declare class MessagingClient implements DropOnAirClient {
6
6
  private readonly options;
@@ -94,6 +94,8 @@ export declare class MessagingClient implements DropOnAirClient {
94
94
  private readonly notificationClearListeners;
95
95
  private readonly draftSyncListeners;
96
96
  private readonly messageDeleteListeners;
97
+ private readonly groupMessageEditListeners;
98
+ private readonly groupMessageDeleteListeners;
97
99
  /** ------------------------------------------------------------------
98
100
  * Lightweight structured logger. Only active when options.debug === true.
99
101
  * Fields are safe to log: no plaintext, no private keys, no full JWTs.
@@ -142,6 +144,16 @@ export declare class MessagingClient implements DropOnAirClient {
142
144
  onEvent(callback: EventCallback): () => void;
143
145
  onMessageEdit(callback: MessageEditCallback): () => void;
144
146
  onMessageDelete(callback: MessageDeleteCallback): () => void;
147
+ /**
148
+ * Listen for edits of group messages. An edit arrives live and again from the
149
+ * offline fetch, so applying one twice must be harmless.
150
+ */
151
+ onGroupMessageEdit(callback: GroupMessageEditCallback): () => void;
152
+ /**
153
+ * Listen for deletes of group messages. A delete arrives live and again from
154
+ * the offline fetch, and can name a message this device never received.
155
+ */
156
+ onGroupMessageDelete(callback: GroupMessageDeleteCallback): () => void;
145
157
  /**
146
158
  * Register a listener for read receipts that this user's OTHER devices
147
159
  * reported. Fires when another device of the same user calls markRead();
@@ -162,6 +174,26 @@ export declare class MessagingClient implements DropOnAirClient {
162
174
  deleteMessage(originalMessageId: string, toUserId: string, scope: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
163
175
  deleteId: string;
164
176
  }>;
177
+ /**
178
+ * Edit a message you sent to a group. Encrypted per member and per device with
179
+ * the edit id, exactly as {@link sendGroupMessage} encrypts with the message id,
180
+ * so pass the same members. Only the sender of a message can edit it.
181
+ */
182
+ editGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string, memberUserIds: string[]): Promise<{
183
+ editId: string;
184
+ }>;
185
+ /** Edit a cleartext message you sent to a group. */
186
+ editCleartextGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string): Promise<{
187
+ editId: string;
188
+ }>;
189
+ /**
190
+ * Delete a message you sent to a group. FOR_EVERYONE tells every member, and a
191
+ * member who fetches later is not handed the message. FOR_ME tells only your
192
+ * other devices. Only the sender of a message can delete it.
193
+ */
194
+ deleteGroupMessage(groupId: string, originalMessageId: string, scope?: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
195
+ deleteId: string;
196
+ }>;
165
197
  /**
166
198
  * Register this device's push notification token with the DropOnAir platform.
167
199
  * The token is opaque to the server; the platform fans out push notifications
@@ -378,6 +410,11 @@ export declare class MessagingClient implements DropOnAirClient {
378
410
  private emitGroupCallEvent;
379
411
  private resolveTransport;
380
412
  private connectWebSocket;
413
+ /**
414
+ * One group edit, live or from the offline fetch. Decrypted exactly as a group
415
+ * message is, with the edit id in place of the message id.
416
+ */
417
+ private handleIncomingGroupEdit;
381
418
  private handleIncomingMessageEdit;
382
419
  private handleIncomingMessageDelete;
383
420
  private handleIncomingEnvelope;
@@ -56,6 +56,12 @@ class MessagingClient {
56
56
  this.reconnectTimer = null;
57
57
  this.connectWebSocket().catch(() => {
58
58
  this.emitEvent({ type: 'ERROR', reason: 'RECONNECT_FAILED' });
59
+ // An attempt that fails before a socket exists, typically the token exchange
60
+ // with no network, has no close event to schedule the next one. Without this
61
+ // the first such failure ended reconnecting for good.
62
+ if (this.shouldReconnect) {
63
+ this.scheduleReconnect();
64
+ }
59
65
  });
60
66
  }, delay);
61
67
  }
@@ -286,6 +292,8 @@ class MessagingClient {
286
292
  this.notificationClearListeners = new Set();
287
293
  this.draftSyncListeners = new Set();
288
294
  this.messageDeleteListeners = new Set();
295
+ this.groupMessageEditListeners = new Set();
296
+ this.groupMessageDeleteListeners = new Set();
289
297
  this.wsUrl = options.messagingWsUrl ?? 'wss://sdk.droponair.com/ws';
290
298
  this.httpUrl = options.messagingHttpUrl ?? 'https://sdk.droponair.com';
291
299
  this.tokenExchangeEndpoint = options.tokenExchangeEndpoint ?? '/api/messaging/token-exchange';
@@ -515,6 +523,22 @@ class MessagingClient {
515
523
  this.messageDeleteListeners.add(callback);
516
524
  return () => this.messageDeleteListeners.delete(callback);
517
525
  }
526
+ /**
527
+ * Listen for edits of group messages. An edit arrives live and again from the
528
+ * offline fetch, so applying one twice must be harmless.
529
+ */
530
+ onGroupMessageEdit(callback) {
531
+ this.groupMessageEditListeners.add(callback);
532
+ return () => this.groupMessageEditListeners.delete(callback);
533
+ }
534
+ /**
535
+ * Listen for deletes of group messages. A delete arrives live and again from
536
+ * the offline fetch, and can name a message this device never received.
537
+ */
538
+ onGroupMessageDelete(callback) {
539
+ this.groupMessageDeleteListeners.add(callback);
540
+ return () => this.groupMessageDeleteListeners.delete(callback);
541
+ }
518
542
  /**
519
543
  * Register a listener for read receipts that this user's OTHER devices
520
544
  * reported. Fires when another device of the same user calls markRead();
@@ -647,6 +671,139 @@ class MessagingClient {
647
671
  this.transport.send(this.codec.encodeMessageDeleteFrame(frame));
648
672
  return { deleteId };
649
673
  }
674
+ /**
675
+ * Edit a message you sent to a group. Encrypted per member and per device with
676
+ * the edit id, exactly as {@link sendGroupMessage} encrypts with the message id,
677
+ * so pass the same members. Only the sender of a message can edit it.
678
+ */
679
+ async editGroupMessage(groupId, originalMessageId, newPlaintext, memberUserIds) {
680
+ if (!this.transport?.isOpen()) {
681
+ throw new Error('DropOnAir websocket is not connected');
682
+ }
683
+ if (!this.currentUserId) {
684
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
685
+ }
686
+ if (this.rateLimited) {
687
+ throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
688
+ }
689
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
690
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
691
+ }
692
+ if (!memberUserIds || memberUserIds.length === 0) {
693
+ throw new Error('editGroupMessage requires at least one recipient userId in memberUserIds');
694
+ }
695
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
696
+ const editId = crypto.randomUUID();
697
+ const timestamp = Date.now();
698
+ const myIdentity = await this.cryptoService.getOrCreateIdentity();
699
+ const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
700
+ const memberPayloads = [];
701
+ for (const memberId of memberUserIds) {
702
+ if (memberId === this.currentUserId)
703
+ continue; // skip self, as sendGroupMessage does
704
+ const deviceKeys = await this.fetchDeviceKeys(memberId);
705
+ const devicePayloads = [];
706
+ if (deviceKeys.length > 0) {
707
+ for (const dk of deviceKeys) {
708
+ const sharedKey = await this.cryptoService.deriveSharedSecret(memberId, dk.publicKey, this.currentUserId);
709
+ const encrypted = await this.cryptoService.encrypt(newPlaintext, sharedKey, {
710
+ messageId: editId,
711
+ senderId: this.currentUserId,
712
+ recipientId: memberId,
713
+ timestamp,
714
+ });
715
+ devicePayloads.push({ deviceId: dk.deviceId, encryptedPayload: encrypted, senderPublicKey: myPublicKeyBytes });
716
+ }
717
+ }
718
+ else {
719
+ const sharedKey = await this.getPeerSharedKey(memberId);
720
+ const encrypted = await this.cryptoService.encrypt(newPlaintext, sharedKey, {
721
+ messageId: editId,
722
+ senderId: this.currentUserId,
723
+ recipientId: memberId,
724
+ timestamp,
725
+ });
726
+ devicePayloads.push({ deviceId: 'default', encryptedPayload: encrypted, senderPublicKey: myPublicKeyBytes });
727
+ }
728
+ memberPayloads.push({ userId: memberId, devicePayloads });
729
+ }
730
+ const frame = {
731
+ type: 'MESSAGE_EDIT',
732
+ editId,
733
+ originalMessageId,
734
+ appId: this.options.appId,
735
+ fromUserId: this.currentUserId,
736
+ toUserId: '',
737
+ groupId,
738
+ timestamp,
739
+ encryptionType: 0, // E2EE
740
+ memberPayloads,
741
+ senderDeviceId: myDeviceId,
742
+ clientEditId: editId,
743
+ };
744
+ this.transport.send(this.codec.encodeMessageEditFrame(frame));
745
+ return { editId };
746
+ }
747
+ /** Edit a cleartext message you sent to a group. */
748
+ async editCleartextGroupMessage(groupId, originalMessageId, newPlaintext) {
749
+ if (!this.transport?.isOpen()) {
750
+ throw new Error('DropOnAir websocket is not connected');
751
+ }
752
+ if (!this.currentUserId) {
753
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
754
+ }
755
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
756
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
757
+ }
758
+ const editId = crypto.randomUUID();
759
+ const frame = {
760
+ type: 'MESSAGE_EDIT',
761
+ editId,
762
+ originalMessageId,
763
+ appId: this.options.appId,
764
+ fromUserId: this.currentUserId,
765
+ toUserId: '',
766
+ groupId,
767
+ timestamp: Date.now(),
768
+ encryptionType: 1, // CLEARTEXT
769
+ plaintextPayload: newPlaintext,
770
+ senderDeviceId: this.deviceId ?? await this.getOrCreateDeviceId(),
771
+ clientEditId: editId,
772
+ };
773
+ this.transport.send(this.codec.encodeMessageEditFrame(frame));
774
+ return { editId };
775
+ }
776
+ /**
777
+ * Delete a message you sent to a group. FOR_EVERYONE tells every member, and a
778
+ * member who fetches later is not handed the message. FOR_ME tells only your
779
+ * other devices. Only the sender of a message can delete it.
780
+ */
781
+ async deleteGroupMessage(groupId, originalMessageId, scope = 'FOR_EVERYONE') {
782
+ if (!this.transport?.isOpen()) {
783
+ throw new Error('DropOnAir websocket is not connected');
784
+ }
785
+ if (!this.currentUserId) {
786
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
787
+ }
788
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
789
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
790
+ }
791
+ const deleteId = crypto.randomUUID();
792
+ const frame = {
793
+ type: 'MESSAGE_DELETE',
794
+ deleteId,
795
+ originalMessageId,
796
+ appId: this.options.appId,
797
+ fromUserId: this.currentUserId,
798
+ toUserId: '',
799
+ groupId,
800
+ timestamp: Date.now(),
801
+ scope,
802
+ clientDeleteId: deleteId,
803
+ };
804
+ this.transport.send(this.codec.encodeMessageDeleteFrame(frame));
805
+ return { deleteId };
806
+ }
650
807
  // ---------------------------------------------------------------------------
651
808
  // Push notification token registration (PROTOCOL_VERSION 5+)
652
809
  // ---------------------------------------------------------------------------
@@ -2005,7 +2162,67 @@ class MessagingClient {
2005
2162
  });
2006
2163
  });
2007
2164
  }
2165
+ /**
2166
+ * One group edit, live or from the offline fetch. Decrypted exactly as a group
2167
+ * message is, with the edit id in place of the message id.
2168
+ */
2169
+ async handleIncomingGroupEdit(frame) {
2170
+ try {
2171
+ let plaintext;
2172
+ if (frame.encryptionType === 1) {
2173
+ plaintext = frame.plaintextPayload ?? '';
2174
+ }
2175
+ else if (frame.devicePayloads && frame.devicePayloads.length > 0) {
2176
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
2177
+ const myPayload = frame.devicePayloads.find(dp => dp.deviceId === myDeviceId);
2178
+ if (!myPayload) {
2179
+ this.log('incoming_group_edit_no_device_payload', {
2180
+ editId: frame.editId,
2181
+ myDeviceId,
2182
+ availableDeviceIds: frame.devicePayloads.map(dp => dp.deviceId),
2183
+ });
2184
+ return;
2185
+ }
2186
+ const peerUserIdForHkdf = frame.fromUserId === this.currentUserId ? this.currentUserId : frame.fromUserId;
2187
+ const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, (0, bytes_1.toBase64)(myPayload.senderPublicKey), this.currentUserId);
2188
+ plaintext = await this.cryptoService.decrypt(myPayload.encryptedPayload, sharedKey, {
2189
+ messageId: frame.editId,
2190
+ senderId: frame.fromUserId,
2191
+ recipientId: this.currentUserId,
2192
+ timestamp: frame.timestamp,
2193
+ });
2194
+ }
2195
+ else {
2196
+ this.log('incoming_group_edit_no_payload', { editId: frame.editId });
2197
+ return;
2198
+ }
2199
+ const event = {
2200
+ editId: frame.editId,
2201
+ originalMessageId: frame.originalMessageId,
2202
+ groupId: frame.groupId,
2203
+ fromUserId: frame.fromUserId,
2204
+ timestamp: frame.timestamp,
2205
+ plaintext,
2206
+ };
2207
+ for (const listener of this.groupMessageEditListeners) {
2208
+ listener(event);
2209
+ }
2210
+ }
2211
+ catch (error) {
2212
+ this.logError('incoming_group_edit_failed', {
2213
+ editId: frame.editId,
2214
+ originalMessageId: frame.originalMessageId,
2215
+ groupId: frame.groupId,
2216
+ error: String(error?.message ?? error),
2217
+ });
2218
+ this.emitEvent({ type: 'ERROR', reason: 'DECRYPT_FAILED', metadata: frame.editId });
2219
+ }
2220
+ }
2008
2221
  async handleIncomingMessageEdit(frame) {
2222
+ if (frame.groupId) {
2223
+ await this.handleIncomingGroupEdit(frame);
2224
+ return;
2225
+ }
2009
2226
  try {
2010
2227
  this.log('incoming_message_edit_received', {
2011
2228
  editId: frame.editId,
@@ -2077,6 +2294,20 @@ class MessagingClient {
2077
2294
  }
2078
2295
  }
2079
2296
  handleIncomingMessageDelete(frame) {
2297
+ if (frame.groupId) {
2298
+ const event = {
2299
+ deleteId: frame.deleteId,
2300
+ originalMessageId: frame.originalMessageId,
2301
+ groupId: frame.groupId,
2302
+ fromUserId: frame.fromUserId,
2303
+ timestamp: frame.timestamp,
2304
+ scope: frame.scope || 'FOR_EVERYONE',
2305
+ };
2306
+ for (const listener of this.groupMessageDeleteListeners) {
2307
+ listener(event);
2308
+ }
2309
+ return;
2310
+ }
2080
2311
  this.log('incoming_message_delete_received', {
2081
2312
  deleteId: frame.deleteId,
2082
2313
  originalMessageId: frame.originalMessageId,
@@ -2138,7 +2369,17 @@ class MessagingClient {
2138
2369
  });
2139
2370
  }
2140
2371
  else {
2141
- // Legacy single-payload path
2372
+ // Legacy single-payload path.
2373
+ //
2374
+ // Our own message, sent back so our other devices can show it. This client
2375
+ // is the one that sent it and already has it, and what it carries is
2376
+ // encrypted for the recipient, not for us. Trying anyway fails, and a
2377
+ // failure that happens on every single message teaches people to ignore
2378
+ // the ones that matter. A client with something to read here finds it in
2379
+ // the per-device payloads above.
2380
+ if (envelope.fromUserId === this.currentUserId) {
2381
+ return;
2382
+ }
2142
2383
  const sharedKey = await this.getPeerSharedKey(envelope.fromUserId);
2143
2384
  plaintext = await this.cryptoService.decrypt(envelope.encryptedPayload, sharedKey, {
2144
2385
  messageId: envelope.messageId,
@@ -2366,6 +2607,9 @@ class MessagingClient {
2366
2607
  let page = 0;
2367
2608
  let totalPages = 1;
2368
2609
  let totalProcessed = 0;
2610
+ // Edits and deletes come on the first page and are applied after every
2611
+ // message, because a change can be about a message on a later page.
2612
+ let changes = [];
2369
2613
  this.log('group_offline_fetch_started', { httpUrl: this.httpUrl, pageSize: 100 });
2370
2614
  while (page < totalPages) {
2371
2615
  const response = await this.fetchFn(`${this.httpUrl}/v1/groups/messages/offline?page=${page}&size=100`, {
@@ -2382,6 +2626,9 @@ class MessagingClient {
2382
2626
  }
2383
2627
  const body = await response.json();
2384
2628
  totalPages = body.totalPages ?? 0;
2629
+ if (page === 0) {
2630
+ changes = body.changes ?? [];
2631
+ }
2385
2632
  for (const offline of body.messages) {
2386
2633
  const notification = {
2387
2634
  messageId: offline.messageId,
@@ -2404,7 +2651,42 @@ class MessagingClient {
2404
2651
  }
2405
2652
  page += 1;
2406
2653
  }
2407
- this.log('group_offline_fetch_done', { totalProcessed });
2654
+ for (const change of changes) {
2655
+ if (change.kind === 'EDIT') {
2656
+ await this.handleIncomingGroupEdit({
2657
+ type: 'MESSAGE_EDIT',
2658
+ editId: change.changeId,
2659
+ originalMessageId: change.originalMessageId,
2660
+ appId: this.options.appId,
2661
+ fromUserId: change.fromUserId,
2662
+ toUserId: '',
2663
+ groupId: change.groupId,
2664
+ timestamp: Number(change.timestamp),
2665
+ encryptionType: change.encryptionType === 'CLEARTEXT' ? 1 : 0,
2666
+ plaintextPayload: change.plaintextPayload ?? undefined,
2667
+ senderDeviceId: change.senderDeviceId ?? undefined,
2668
+ devicePayloads: (change.devicePayloads ?? []).map(dp => ({
2669
+ deviceId: dp.deviceId,
2670
+ encryptedPayload: (0, bytes_1.fromBase64)(dp.encryptedPayloadBase64),
2671
+ senderPublicKey: (0, bytes_1.fromBase64)(dp.senderPublicKeyBase64 ?? ''),
2672
+ })),
2673
+ });
2674
+ }
2675
+ else if (change.kind === 'DELETE') {
2676
+ this.handleIncomingMessageDelete({
2677
+ type: 'MESSAGE_DELETE',
2678
+ deleteId: change.changeId,
2679
+ originalMessageId: change.originalMessageId,
2680
+ appId: this.options.appId,
2681
+ fromUserId: change.fromUserId,
2682
+ toUserId: '',
2683
+ groupId: change.groupId,
2684
+ timestamp: Number(change.timestamp),
2685
+ scope: change.scope || 'FOR_EVERYONE',
2686
+ });
2687
+ }
2688
+ }
2689
+ this.log('group_offline_fetch_done', { totalProcessed, changes: changes.length });
2408
2690
  }
2409
2691
  async fetchAndProcessOfflineMessages() {
2410
2692
  const jwt = await this.getValidDropOnAirJwt(false);
@@ -91,6 +91,34 @@ export interface MessageDeleteEvent {
91
91
  }
92
92
  export type MessageEditCallback = (event: MessageEditEvent) => void;
93
93
  export type MessageDeleteCallback = (event: MessageDeleteEvent) => void;
94
+ /**
95
+ * A message in a group, edited by its sender. Delivered live and again from the
96
+ * offline fetch, so applying the same edit twice must be harmless. Two edits of
97
+ * one message arrive oldest first.
98
+ */
99
+ export interface GroupMessageEditEvent {
100
+ editId: string;
101
+ originalMessageId: string;
102
+ groupId: string;
103
+ fromUserId: string;
104
+ timestamp: number;
105
+ /** New plaintext for both E2EE (after decryption) and CLEARTEXT messages. */
106
+ plaintext: string;
107
+ }
108
+ /**
109
+ * A message in a group, deleted by its sender. Delivered live and again from the
110
+ * offline fetch, and can name a message this device never received.
111
+ */
112
+ export interface GroupMessageDeleteEvent {
113
+ deleteId: string;
114
+ originalMessageId: string;
115
+ groupId: string;
116
+ fromUserId: string;
117
+ timestamp: number;
118
+ scope: 'FOR_EVERYONE' | 'FOR_ME' | string;
119
+ }
120
+ export type GroupMessageEditCallback = (event: GroupMessageEditEvent) => void;
121
+ export type GroupMessageDeleteCallback = (event: GroupMessageDeleteEvent) => void;
94
122
  /**
95
123
  * Delivered to the user's OTHER devices when one device calls markRead().
96
124
  * Use it to clear whatever unread state your app keeps for that message.
@@ -664,6 +692,29 @@ export interface DropOnAirClient {
664
692
  }>;
665
693
  /** Register a listener for incoming group messages. Returns an unsubscribe function. */
666
694
  onGroupMessage(callback: GroupMessageCallback): () => void;
695
+ /**
696
+ * Edit a message you sent to a group, encrypted per member as
697
+ * {@link sendGroupMessage} is. Pass the same members. Only the sender can edit.
698
+ */
699
+ editGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string, memberUserIds: string[]): Promise<{
700
+ editId: string;
701
+ }>;
702
+ /** Edit a cleartext message you sent to a group. */
703
+ editCleartextGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string): Promise<{
704
+ editId: string;
705
+ }>;
706
+ /**
707
+ * Delete a message you sent to a group. FOR_EVERYONE tells every member and
708
+ * a member who fetches later is not handed the message; FOR_ME tells only
709
+ * your other devices. Only the sender can delete.
710
+ */
711
+ deleteGroupMessage(groupId: string, originalMessageId: string, scope?: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
712
+ deleteId: string;
713
+ }>;
714
+ /** Register a listener for edits of group messages. */
715
+ onGroupMessageEdit(callback: GroupMessageEditCallback): () => void;
716
+ /** Register a listener for deletes of group messages. */
717
+ onGroupMessageDelete(callback: GroupMessageDeleteCallback): () => void;
667
718
  /** Initiate a group call. Returns the callId. */
668
719
  startGroupCall(groupId: string): Promise<string>;
669
720
  /** Join an active group call. */
package/dist/index.d.ts CHANGED
@@ -22,7 +22,7 @@ export { SDK_VERSION, PROTOCOL_VERSION, PAYLOAD_FORMAT_VERSION } from './version
22
22
  */
23
23
  export declare function createSecureIdentity(store?: IdentityRecordStore): Promise<WebCryptoIdentityProvider | null>;
24
24
  export declare function initialize(options: InitializeOptions): Promise<DropOnAirClient>;
25
- export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, Room, RoomPolicy, CreateRoomOptions, UpdateRoomOptions, SfuToken, SfuRecording, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, DeviceInfo, ReadReceiptEvent, ReadReceiptCallback, TypingEvent, TypingCallback, NotificationClearEvent, NotificationClearCallback, DraftSyncEvent, DraftSyncCallback, } from './core/types';
25
+ export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, Room, RoomPolicy, CreateRoomOptions, UpdateRoomOptions, SfuToken, SfuRecording, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, GroupMessageEditEvent, GroupMessageEditCallback, GroupMessageDeleteEvent, GroupMessageDeleteCallback, DeviceInfo, ReadReceiptEvent, ReadReceiptCallback, TypingEvent, TypingCallback, NotificationClearEvent, NotificationClearCallback, DraftSyncEvent, DraftSyncCallback, } from './core/types';
26
26
  export type { AttachmentEncryptionType, AttachmentConversationType, AttachmentRef, DeviceWrappedKey, CreateUploadSessionOptions, UploadSession, PrepareAttachmentOptions, DownloadedAttachment, } from './attachment/attachment-types';
27
27
  export { SseTransport, type SseTransportOptions, type SseFrameHandler, type SseStateHandler, } from './transport/sse-transport';
28
28
  export { WebTransportTransport, type WebTransportTransportOptions, type WTFrameHandler, type WTStateHandler, } from './transport/webtransport-transport';
@@ -168,6 +168,10 @@ export interface WireMessageEditFrame {
168
168
  senderDeviceId?: string;
169
169
  plaintextPayload?: string;
170
170
  clientEditId?: string;
171
+ /** Set when the edited message was sent to a group. */
172
+ groupId?: string;
173
+ /** Group E2EE edits sent to the relay: one payload set per member. */
174
+ memberPayloads?: WireGroupMemberPayload[];
171
175
  }
172
176
  /**
173
177
  * Push token registration / unregister. Client -> server only. The server
@@ -204,6 +208,8 @@ export interface WireMessageDeleteFrame {
204
208
  timestamp: number;
205
209
  scope: string;
206
210
  clientDeleteId?: string;
211
+ /** Set when the deleted message was sent to a group. */
212
+ groupId?: string;
207
213
  }
208
214
  export type InboundFrame = {
209
215
  kind: 'envelope';
@@ -217,9 +217,15 @@ const MessageEditDeviceType = new protobuf.Type('DeviceEncryptedPayload')
217
217
  .add(new protobuf.Field('encryptedPayload', 2, 'bytes'))
218
218
  .add(new protobuf.Field('senderPublicKey', 3, 'bytes'));
219
219
  const MessageEditEncryptionTypeEnum = new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 });
220
+ // Its own instance, never the group envelope's. A protobuf.Type belongs to one
221
+ // parent, and sharing one across two broke every message this SDK sent.
222
+ const MessageEditMemberPayloadType = new protobuf.Type('GroupMemberPayload')
223
+ .add(new protobuf.Field('userId', 1, 'string'))
224
+ .add(new protobuf.Field('devicePayloads', 2, 'DeviceEncryptedPayload', 'repeated'));
220
225
  const MessageEditFrameType = new protobuf.Type('MessageEditFrame')
221
226
  .add(MessageEditEncryptionTypeEnum)
222
227
  .add(MessageEditDeviceType)
228
+ .add(MessageEditMemberPayloadType)
223
229
  .add(new protobuf.Field('type', 1, 'string'))
224
230
  .add(new protobuf.Field('editId', 2, 'string'))
225
231
  .add(new protobuf.Field('originalMessageId', 3, 'string'))
@@ -232,7 +238,11 @@ const MessageEditFrameType = new protobuf.Type('MessageEditFrame')
232
238
  .add(new protobuf.Field('devicePayloads', 10, 'DeviceEncryptedPayload', 'repeated'))
233
239
  .add(new protobuf.Field('senderDeviceId', 11, 'string'))
234
240
  .add(new protobuf.Field('plaintextPayload', 12, 'string'))
235
- .add(new protobuf.Field('clientEditId', 13, 'string'));
241
+ .add(new protobuf.Field('clientEditId', 13, 'string'))
242
+ // Group edits: groupId names the group; an E2EE edit sent to the relay carries
243
+ // one payload set per member, and each member receives only their own.
244
+ .add(new protobuf.Field('groupId', 14, 'string'))
245
+ .add(new protobuf.Field('memberPayloads', 15, 'GroupMemberPayload', 'repeated'));
236
246
  const MessageDeleteFrameType = new protobuf.Type('MessageDeleteFrame')
237
247
  .add(new protobuf.Field('type', 1, 'string'))
238
248
  .add(new protobuf.Field('deleteId', 2, 'string'))
@@ -242,7 +252,8 @@ const MessageDeleteFrameType = new protobuf.Type('MessageDeleteFrame')
242
252
  .add(new protobuf.Field('toUserId', 6, 'string'))
243
253
  .add(new protobuf.Field('timestamp', 7, 'int64'))
244
254
  .add(new protobuf.Field('scope', 8, 'string'))
245
- .add(new protobuf.Field('clientDeleteId', 9, 'string'));
255
+ .add(new protobuf.Field('clientDeleteId', 9, 'string'))
256
+ .add(new protobuf.Field('groupId', 10, 'string'));
246
257
  // Push token registration frame (PROTOCOL_VERSION 5+). Client to server only;
247
258
  // the server emits a PUSH_REGISTERED / PUSH_UNREGISTERED Ack back. The token
248
259
  // is opaque to the server and stored against (appId, userId, deviceId, platform).
package/dist/version.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
8
8
  * PATCH, bug-fix / perf improvement with no wire or API change
9
9
  */
10
- export declare const SDK_VERSION = "0.32.0";
10
+ export declare const SDK_VERSION = "0.33.0";
11
11
  /**
12
12
  * Binary encrypted-payload format version.
13
13
  * Included as the first byte of every encrypted payload so receivers can
package/dist/version.js CHANGED
@@ -10,7 +10,7 @@ exports.PROTOCOL_VERSION = exports.PAYLOAD_FORMAT_VERSION = exports.SDK_VERSION
10
10
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
11
11
  * PATCH, bug-fix / perf improvement with no wire or API change
12
12
  */
13
- exports.SDK_VERSION = '0.32.0';
13
+ exports.SDK_VERSION = '0.33.0';
14
14
  /**
15
15
  * Binary encrypted-payload format version.
16
16
  * Included as the first byte of every encrypted payload so receivers can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "End-to-end encrypted messaging, voice and video calling SDK. The relay never sees your keys or message content.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",