@droponair/sdk-js 0.32.1 → 0.34.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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0
4
+
5
+ ### Added
6
+
7
+ - **Files in group messages.** `sendGroupMessage(groupId, text, memberUserIds, { attachments })`
8
+ now carries attachment references, and a received `DecryptedGroupMessage` carries
9
+ `attachments`. Each member is handed the pointer and only the file keys wrapped for
10
+ their own devices. A file sent while a member was away arrives with the message when
11
+ they catch up, which previously carried nothing.
12
+ - **A lifetime per file.** `retentionSeconds` on an upload session asks for one file to
13
+ live a set time. Never longer than the app's own retention.
14
+ - **`deleteWhenEveryoneHasIt`.** The platform deletes the stored bytes as soon as every
15
+ recipient has fetched them, and answers a later request with "no longer available"
16
+ rather than a broken link.
17
+
18
+ ## 0.33.0
19
+
20
+ ### Added
21
+
22
+ - **Edit and delete group messages.** `editGroupMessage(groupId, originalMessageId,
23
+ newText, memberUserIds)`, `editCleartextGroupMessage` and `deleteGroupMessage`
24
+ (scope `FOR_EVERYONE` by default, or `FOR_ME`). An E2EE edit is encrypted for each
25
+ member and device, as a group message is. Only the sender of a message can change it.
26
+ - **`onGroupMessageEdit` and `onGroupMessageDelete`.** Fired live, and again from
27
+ the offline fetch for a member who was away, after that member's messages, so
28
+ applying one twice must be harmless. A member who fetches after a delete for
29
+ everyone is not handed the message.
30
+
3
31
  ## 0.32.1
4
32
 
5
33
  ### Fixed
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
  });
@@ -328,6 +346,8 @@ client.onMessage(async (msg) => {
328
346
  | `prepareAttachmentAndUpload(bytes, options)` | `Promise<AttachmentRef>` | Encrypt (E2EE), upload, finalize, return ref |
329
347
  | `createUploadSession(options)` | `Promise<UploadSession>` | Low-level: presigned PUT URL only |
330
348
  | `finalizeAttachment(attachmentId, sha256)` | `Promise<void>` | Low-level: commit integrity hash |
349
+ | `retentionSeconds` (upload option) | | How long this one file should live. Never granted longer than your own retention. |
350
+ | `deleteWhenEveryoneHasIt` (upload option) | | Delete the stored bytes as soon as every recipient has fetched them. A later request is answered "no longer available". |
331
351
  | `downloadAttachment(ref)` | `Promise<DownloadedAttachment>` | Get presigned URL + download + decrypt |
332
352
  | `revokeAttachment(attachmentId \| ref)` | `Promise<void>` | Revoke an attachment you sent; recipients get `ATTACHMENT_REVOKED` |
333
353
  | `sendMessage(toUserId, text, { attachments })` | `Promise<{ messageId }>` | Send with attachments |
@@ -46,6 +46,10 @@ class AttachmentClient {
46
46
  conversationId: opts.conversationId ?? '',
47
47
  recipientUserIds: opts.recipientUserIds ?? [],
48
48
  };
49
+ if (opts.retentionSeconds !== undefined)
50
+ body.retentionSeconds = opts.retentionSeconds;
51
+ if (opts.deleteWhenEveryoneHasIt)
52
+ body.deleteWhenEveryoneHasIt = true;
49
53
  const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/upload-session`, {
50
54
  method: 'POST',
51
55
  headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
@@ -44,6 +44,17 @@ export interface CreateUploadSessionOptions {
44
44
  conversationId?: string;
45
45
  /** Recipient user IDs (used by the server for download authorization). */
46
46
  recipientUserIds?: string[];
47
+ /**
48
+ * How long this one file should live, in seconds. Optional, and never granted
49
+ * longer than the app's own retention. An app that works out a window per file
50
+ * sets it here instead of living with one number for everything.
51
+ */
52
+ retentionSeconds?: number;
53
+ /**
54
+ * Delete the stored bytes as soon as every recipient has fetched them. The
55
+ * file then exists only on the devices it was sent to.
56
+ */
57
+ deleteWhenEveryoneHasIt?: boolean;
47
58
  }
48
59
  /** Result of reserving an upload session. */
49
60
  export interface UploadSession {
@@ -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;
@@ -292,6 +292,8 @@ class MessagingClient {
292
292
  this.notificationClearListeners = new Set();
293
293
  this.draftSyncListeners = new Set();
294
294
  this.messageDeleteListeners = new Set();
295
+ this.groupMessageEditListeners = new Set();
296
+ this.groupMessageDeleteListeners = new Set();
295
297
  this.wsUrl = options.messagingWsUrl ?? 'wss://sdk.droponair.com/ws';
296
298
  this.httpUrl = options.messagingHttpUrl ?? 'https://sdk.droponair.com';
297
299
  this.tokenExchangeEndpoint = options.tokenExchangeEndpoint ?? '/api/messaging/token-exchange';
@@ -521,6 +523,22 @@ class MessagingClient {
521
523
  this.messageDeleteListeners.add(callback);
522
524
  return () => this.messageDeleteListeners.delete(callback);
523
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
+ }
524
542
  /**
525
543
  * Register a listener for read receipts that this user's OTHER devices
526
544
  * reported. Fires when another device of the same user calls markRead();
@@ -653,6 +671,139 @@ class MessagingClient {
653
671
  this.transport.send(this.codec.encodeMessageDeleteFrame(frame));
654
672
  return { deleteId };
655
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
+ }
656
807
  // ---------------------------------------------------------------------------
657
808
  // Push notification token registration (PROTOCOL_VERSION 5+)
658
809
  // ---------------------------------------------------------------------------
@@ -1712,6 +1863,7 @@ class MessagingClient {
1712
1863
  fromUserId: notif.fromUserId,
1713
1864
  timestamp: notif.timestamp,
1714
1865
  plaintext,
1866
+ attachments: (notif.attachments ?? []).map(a => attachment_client_1.AttachmentClient.fromWire(a)),
1715
1867
  };
1716
1868
  for (const listener of this.groupMessageListeners) {
1717
1869
  listener(msg);
@@ -2011,7 +2163,67 @@ class MessagingClient {
2011
2163
  });
2012
2164
  });
2013
2165
  }
2166
+ /**
2167
+ * One group edit, live or from the offline fetch. Decrypted exactly as a group
2168
+ * message is, with the edit id in place of the message id.
2169
+ */
2170
+ async handleIncomingGroupEdit(frame) {
2171
+ try {
2172
+ let plaintext;
2173
+ if (frame.encryptionType === 1) {
2174
+ plaintext = frame.plaintextPayload ?? '';
2175
+ }
2176
+ else if (frame.devicePayloads && frame.devicePayloads.length > 0) {
2177
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
2178
+ const myPayload = frame.devicePayloads.find(dp => dp.deviceId === myDeviceId);
2179
+ if (!myPayload) {
2180
+ this.log('incoming_group_edit_no_device_payload', {
2181
+ editId: frame.editId,
2182
+ myDeviceId,
2183
+ availableDeviceIds: frame.devicePayloads.map(dp => dp.deviceId),
2184
+ });
2185
+ return;
2186
+ }
2187
+ const peerUserIdForHkdf = frame.fromUserId === this.currentUserId ? this.currentUserId : frame.fromUserId;
2188
+ const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, (0, bytes_1.toBase64)(myPayload.senderPublicKey), this.currentUserId);
2189
+ plaintext = await this.cryptoService.decrypt(myPayload.encryptedPayload, sharedKey, {
2190
+ messageId: frame.editId,
2191
+ senderId: frame.fromUserId,
2192
+ recipientId: this.currentUserId,
2193
+ timestamp: frame.timestamp,
2194
+ });
2195
+ }
2196
+ else {
2197
+ this.log('incoming_group_edit_no_payload', { editId: frame.editId });
2198
+ return;
2199
+ }
2200
+ const event = {
2201
+ editId: frame.editId,
2202
+ originalMessageId: frame.originalMessageId,
2203
+ groupId: frame.groupId,
2204
+ fromUserId: frame.fromUserId,
2205
+ timestamp: frame.timestamp,
2206
+ plaintext,
2207
+ };
2208
+ for (const listener of this.groupMessageEditListeners) {
2209
+ listener(event);
2210
+ }
2211
+ }
2212
+ catch (error) {
2213
+ this.logError('incoming_group_edit_failed', {
2214
+ editId: frame.editId,
2215
+ originalMessageId: frame.originalMessageId,
2216
+ groupId: frame.groupId,
2217
+ error: String(error?.message ?? error),
2218
+ });
2219
+ this.emitEvent({ type: 'ERROR', reason: 'DECRYPT_FAILED', metadata: frame.editId });
2220
+ }
2221
+ }
2014
2222
  async handleIncomingMessageEdit(frame) {
2223
+ if (frame.groupId) {
2224
+ await this.handleIncomingGroupEdit(frame);
2225
+ return;
2226
+ }
2015
2227
  try {
2016
2228
  this.log('incoming_message_edit_received', {
2017
2229
  editId: frame.editId,
@@ -2083,6 +2295,20 @@ class MessagingClient {
2083
2295
  }
2084
2296
  }
2085
2297
  handleIncomingMessageDelete(frame) {
2298
+ if (frame.groupId) {
2299
+ const event = {
2300
+ deleteId: frame.deleteId,
2301
+ originalMessageId: frame.originalMessageId,
2302
+ groupId: frame.groupId,
2303
+ fromUserId: frame.fromUserId,
2304
+ timestamp: frame.timestamp,
2305
+ scope: frame.scope || 'FOR_EVERYONE',
2306
+ };
2307
+ for (const listener of this.groupMessageDeleteListeners) {
2308
+ listener(event);
2309
+ }
2310
+ return;
2311
+ }
2086
2312
  this.log('incoming_message_delete_received', {
2087
2313
  deleteId: frame.deleteId,
2088
2314
  originalMessageId: frame.originalMessageId,
@@ -2382,6 +2608,9 @@ class MessagingClient {
2382
2608
  let page = 0;
2383
2609
  let totalPages = 1;
2384
2610
  let totalProcessed = 0;
2611
+ // Edits and deletes come on the first page and are applied after every
2612
+ // message, because a change can be about a message on a later page.
2613
+ let changes = [];
2385
2614
  this.log('group_offline_fetch_started', { httpUrl: this.httpUrl, pageSize: 100 });
2386
2615
  while (page < totalPages) {
2387
2616
  const response = await this.fetchFn(`${this.httpUrl}/v1/groups/messages/offline?page=${page}&size=100`, {
@@ -2398,6 +2627,9 @@ class MessagingClient {
2398
2627
  }
2399
2628
  const body = await response.json();
2400
2629
  totalPages = body.totalPages ?? 0;
2630
+ if (page === 0) {
2631
+ changes = body.changes ?? [];
2632
+ }
2401
2633
  for (const offline of body.messages) {
2402
2634
  const notification = {
2403
2635
  messageId: offline.messageId,
@@ -2412,6 +2644,24 @@ class MessagingClient {
2412
2644
  encryptedPayload: (0, bytes_1.fromBase64)(dp.encryptedPayloadBase64),
2413
2645
  senderPublicKey: (0, bytes_1.fromBase64)(dp.senderPublicKeyBase64),
2414
2646
  })),
2647
+ // Files travel with a late message too. Without this, somebody who was
2648
+ // away came back to a message that mentioned nothing at all: the live
2649
+ // path carried the pointer and the catch-up did not.
2650
+ attachments: (offline.attachments ?? []).map(a => ({
2651
+ attachmentId: a.attachmentId,
2652
+ storageHint: a.storageHint ?? '',
2653
+ mimeType: a.mimeType ?? '',
2654
+ sizeBytes: a.sizeBytes ?? 0,
2655
+ sha256: a.sha256 ?? '',
2656
+ encryptionType: a.encryptionType === 'CLEARTEXT' ? 1 : 0,
2657
+ thumbnailAttachmentId: a.thumbnailAttachmentId ?? '',
2658
+ wrappedKeys: (a.wrappedKeys ?? []).map(k => ({
2659
+ deviceId: k.deviceId,
2660
+ wrappedKey: (0, bytes_1.fromBase64)(k.wrappedKeyBase64 ?? ''),
2661
+ senderPublicKey: (0, bytes_1.fromBase64)(k.senderPublicKeyBase64 ?? ''),
2662
+ nonce: (0, bytes_1.fromBase64)(k.nonceBase64 ?? ''),
2663
+ })),
2664
+ })),
2415
2665
  };
2416
2666
  // The same path a live message takes, so decryption, de-duplication and
2417
2667
  // the callback behave identically.
@@ -2420,7 +2670,42 @@ class MessagingClient {
2420
2670
  }
2421
2671
  page += 1;
2422
2672
  }
2423
- this.log('group_offline_fetch_done', { totalProcessed });
2673
+ for (const change of changes) {
2674
+ if (change.kind === 'EDIT') {
2675
+ await this.handleIncomingGroupEdit({
2676
+ type: 'MESSAGE_EDIT',
2677
+ editId: change.changeId,
2678
+ originalMessageId: change.originalMessageId,
2679
+ appId: this.options.appId,
2680
+ fromUserId: change.fromUserId,
2681
+ toUserId: '',
2682
+ groupId: change.groupId,
2683
+ timestamp: Number(change.timestamp),
2684
+ encryptionType: change.encryptionType === 'CLEARTEXT' ? 1 : 0,
2685
+ plaintextPayload: change.plaintextPayload ?? undefined,
2686
+ senderDeviceId: change.senderDeviceId ?? undefined,
2687
+ devicePayloads: (change.devicePayloads ?? []).map(dp => ({
2688
+ deviceId: dp.deviceId,
2689
+ encryptedPayload: (0, bytes_1.fromBase64)(dp.encryptedPayloadBase64),
2690
+ senderPublicKey: (0, bytes_1.fromBase64)(dp.senderPublicKeyBase64 ?? ''),
2691
+ })),
2692
+ });
2693
+ }
2694
+ else if (change.kind === 'DELETE') {
2695
+ this.handleIncomingMessageDelete({
2696
+ type: 'MESSAGE_DELETE',
2697
+ deleteId: change.changeId,
2698
+ originalMessageId: change.originalMessageId,
2699
+ appId: this.options.appId,
2700
+ fromUserId: change.fromUserId,
2701
+ toUserId: '',
2702
+ groupId: change.groupId,
2703
+ timestamp: Number(change.timestamp),
2704
+ scope: change.scope || 'FOR_EVERYONE',
2705
+ });
2706
+ }
2707
+ }
2708
+ this.log('group_offline_fetch_done', { totalProcessed, changes: changes.length });
2424
2709
  }
2425
2710
  async fetchAndProcessOfflineMessages() {
2426
2711
  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.
@@ -194,6 +222,12 @@ export interface DecryptedGroupMessage {
194
222
  fromUserId: string;
195
223
  timestamp: number;
196
224
  plaintext: string;
225
+ /**
226
+ * Files this message points at, if any. The bytes are fetched separately with
227
+ * `downloadAttachment`; what arrives here is the pointer and the file key
228
+ * wrapped for this device.
229
+ */
230
+ attachments?: import('../attachment/attachment-types').AttachmentRef[];
197
231
  }
198
232
  export type GroupMessageCallback = (message: DecryptedGroupMessage) => void;
199
233
  export type GroupCallEventType = 'GROUP_CALL_INVITE' | 'GROUP_CALL_RINGING' | 'GROUP_CALL_JOIN' | 'GROUP_CALL_LEAVE' | 'GROUP_CALL_END' | 'GROUP_CALL_ENDED' | 'GROUP_CALL_PARTICIPANT_JOINED' | 'GROUP_CALL_PARTICIPANT_LEFT' | 'GROUP_CALL_PARTICIPANT_REMOVED' | 'GROUP_CALL_ALREADY_ACTIVE' | 'GROUP_CALL_SDP_OFFER' | 'GROUP_CALL_SDP_ANSWER' | 'GROUP_CALL_ICE_CANDIDATE' | 'GROUP_CALL_SCREEN_SHARE_STARTED' | 'GROUP_CALL_SCREEN_SHARE_STOPPED' | 'GROUP_CALL_HOST_TRANSFER' | 'GROUP_CALL_COHOST_APPOINT' | 'GROUP_CALL_COHOST_REVOKE' | 'GROUP_CALL_ROLE_CHANGED' | 'GROUP_CALL_MUTE_PARTICIPANT' | 'GROUP_CALL_REMOVE_PARTICIPANT' | 'GROUP_CALL_WAITING_ROOM_REQUEST' | 'GROUP_CALL_WAITING_ROOM_JOINED' | 'GROUP_CALL_WAITING_ROOM_ADMIT' | 'GROUP_CALL_WAITING_ROOM_ADMITTED' | 'GROUP_CALL_WAITING_ROOM_REJECT' | 'GROUP_CALL_WAITING_ROOM_REJECTED' | 'GROUP_CALL_JOINED' | 'GROUP_CALL_WAITING_ROOM_PENDING' | 'GROUP_CALL_HOST_REQUIRED' | 'GROUP_CALL_ROOM_CLOSED' | 'GROUP_CALL_STAGE_HAND_RAISED' | 'GROUP_CALL_STAGE_HAND_LOWERED' | 'GROUP_CALL_STAGE_PROMOTE' | 'GROUP_CALL_STAGE_DEMOTE' | 'GROUP_CALL_STAGE_QUESTION' | 'GROUP_CALL_RECORDING_STARTED' | 'GROUP_CALL_RECORDING_STOPPED' | 'GROUP_CALL_RECORDING_AVAILABLE';
@@ -664,6 +698,29 @@ export interface DropOnAirClient {
664
698
  }>;
665
699
  /** Register a listener for incoming group messages. Returns an unsubscribe function. */
666
700
  onGroupMessage(callback: GroupMessageCallback): () => void;
701
+ /**
702
+ * Edit a message you sent to a group, encrypted per member as
703
+ * {@link sendGroupMessage} is. Pass the same members. Only the sender can edit.
704
+ */
705
+ editGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string, memberUserIds: string[]): Promise<{
706
+ editId: string;
707
+ }>;
708
+ /** Edit a cleartext message you sent to a group. */
709
+ editCleartextGroupMessage(groupId: string, originalMessageId: string, newPlaintext: string): Promise<{
710
+ editId: string;
711
+ }>;
712
+ /**
713
+ * Delete a message you sent to a group. FOR_EVERYONE tells every member and
714
+ * a member who fetches later is not handed the message; FOR_ME tells only
715
+ * your other devices. Only the sender can delete.
716
+ */
717
+ deleteGroupMessage(groupId: string, originalMessageId: string, scope?: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
718
+ deleteId: string;
719
+ }>;
720
+ /** Register a listener for edits of group messages. */
721
+ onGroupMessageEdit(callback: GroupMessageEditCallback): () => void;
722
+ /** Register a listener for deletes of group messages. */
723
+ onGroupMessageDelete(callback: GroupMessageDeleteCallback): () => void;
667
724
  /** Initiate a group call. Returns the callId. */
668
725
  startGroupCall(groupId: string): Promise<string>;
669
726
  /** 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.1";
10
+ export declare const SDK_VERSION = "0.34.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.1';
13
+ exports.SDK_VERSION = '0.34.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.1",
3
+ "version": "0.34.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",