@droponair/sdk-js 0.32.1 → 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 +13 -0
- package/README.md +18 -0
- package/dist/core/messaging-client.d.ts +38 -1
- package/dist/core/messaging-client.js +267 -1
- 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,18 @@
|
|
|
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
|
+
|
|
3
16
|
## 0.32.1
|
|
4
17
|
|
|
5
18
|
### 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
|
});
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -2011,7 +2162,67 @@ class MessagingClient {
|
|
|
2011
2162
|
});
|
|
2012
2163
|
});
|
|
2013
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
|
+
}
|
|
2014
2221
|
async handleIncomingMessageEdit(frame) {
|
|
2222
|
+
if (frame.groupId) {
|
|
2223
|
+
await this.handleIncomingGroupEdit(frame);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2015
2226
|
try {
|
|
2016
2227
|
this.log('incoming_message_edit_received', {
|
|
2017
2228
|
editId: frame.editId,
|
|
@@ -2083,6 +2294,20 @@ class MessagingClient {
|
|
|
2083
2294
|
}
|
|
2084
2295
|
}
|
|
2085
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
|
+
}
|
|
2086
2311
|
this.log('incoming_message_delete_received', {
|
|
2087
2312
|
deleteId: frame.deleteId,
|
|
2088
2313
|
originalMessageId: frame.originalMessageId,
|
|
@@ -2382,6 +2607,9 @@ class MessagingClient {
|
|
|
2382
2607
|
let page = 0;
|
|
2383
2608
|
let totalPages = 1;
|
|
2384
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 = [];
|
|
2385
2613
|
this.log('group_offline_fetch_started', { httpUrl: this.httpUrl, pageSize: 100 });
|
|
2386
2614
|
while (page < totalPages) {
|
|
2387
2615
|
const response = await this.fetchFn(`${this.httpUrl}/v1/groups/messages/offline?page=${page}&size=100`, {
|
|
@@ -2398,6 +2626,9 @@ class MessagingClient {
|
|
|
2398
2626
|
}
|
|
2399
2627
|
const body = await response.json();
|
|
2400
2628
|
totalPages = body.totalPages ?? 0;
|
|
2629
|
+
if (page === 0) {
|
|
2630
|
+
changes = body.changes ?? [];
|
|
2631
|
+
}
|
|
2401
2632
|
for (const offline of body.messages) {
|
|
2402
2633
|
const notification = {
|
|
2403
2634
|
messageId: offline.messageId,
|
|
@@ -2420,7 +2651,42 @@ class MessagingClient {
|
|
|
2420
2651
|
}
|
|
2421
2652
|
page += 1;
|
|
2422
2653
|
}
|
|
2423
|
-
|
|
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 });
|
|
2424
2690
|
}
|
|
2425
2691
|
async fetchAndProcessOfflineMessages() {
|
|
2426
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