@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 +29 -0
- package/README.md +18 -0
- package/dist/core/messaging-client.d.ts +38 -1
- package/dist/core/messaging-client.js +284 -2
- package/dist/core/types.d.ts +51 -0
- package/dist/index.d.ts +1 -1
- package/dist/transport/protobuf-codec.d.ts +6 -0
- package/dist/transport/protobuf-codec.js +13 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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);
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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