@droponair/sdk-js 0.10.0 → 0.11.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
@@ -6,6 +6,21 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.11.0], 2026-05-20
10
+
11
+ ### Added
12
+
13
+ - **Cross-device read receipts.** New `client.markRead(messageId, conversationId?)` reports a message as read; the receipt is relayed to the user's *other* devices (never to the message's sender). New `client.onReadReceipt(callback)` listens for receipts reported by the user's other devices — use it to clear unread state your app maintains.
14
+ - New exported `ReadReceiptEvent` type and `ReadReceiptCallback`.
15
+ - **PROTOCOL_VERSION bumped to 6.** Additive only: new `SyncFrame` wire type. Existing 0.10.x clients keep working against the new server.
16
+
17
+ ### Notes
18
+
19
+ - The platform is deliberately unopinionated: **your app decides when `markRead()` is called** — the server never infers read state, and unread counts stay client-side. The app owner can disable read receipts entirely from the dashboard, in which case the server silently drops the frame.
20
+ - This release is own-device sync only: the *sender* of a message is not told when a recipient read it. Sender-side read notifications are a separate future capability.
21
+
22
+ ---
23
+
9
24
  ## [0.10.0], 2026-05-20
10
25
 
11
26
  ### Added
package/README.md CHANGED
@@ -100,6 +100,22 @@ const client = await initialize(options);
100
100
  | `unregisterPushToken({ platform })` | `Promise<void>` | Unregister this device for push notifications (e.g. on logout). |
101
101
  | `listMyDevices()` | `Promise<DeviceInfo[]>` | List the current user's registered devices. |
102
102
  | `revokeMyDevice(deviceId)` | `Promise<DeviceInfo>` | Revoke one of the current user's devices. Permanent. |
103
+ | `markRead(messageId, conversationId?)` | `void` | Mark a message as read; relays a receipt to the user's other devices. |
104
+ | `onReadReceipt(callback)` | `() => void` | Listen for read receipts from the user's other devices. |
105
+
106
+ ### Cross-device read receipts
107
+
108
+ Available since SDK `0.11.0`. **Your app decides when a message is read** — the platform never infers it. Call `markRead()` at that moment; the receipt syncs to the user's *other* devices so they can clear their unread UI. It is not sent to the message's sender. The app owner can switch read receipts off entirely from the dashboard.
109
+
110
+ ```typescript
111
+ // When your UI decides the message has been read
112
+ client.markRead(messageId, peerUserId);
113
+
114
+ // On the user's other devices
115
+ client.onReadReceipt(e => {
116
+ // e.messageId was read elsewhere — clear your unread state for it
117
+ });
118
+ ```
103
119
 
104
120
  ### Device trust
105
121
 
@@ -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, GroupCallEventCallback, GroupInfo, GroupMessageCallback, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo } from './types';
3
+ import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, GroupCallEventCallback, GroupInfo, GroupMessageCallback, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback } 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;
@@ -71,6 +71,7 @@ export declare class MessagingClient implements DropOnAirClient {
71
71
  private readonly eventListeners;
72
72
  private readonly broadcastListeners;
73
73
  private readonly messageEditListeners;
74
+ private readonly readReceiptListeners;
74
75
  private readonly messageDeleteListeners;
75
76
  /** ------------------------------------------------------------------
76
77
  * Lightweight structured logger. Only active when options.debug === true.
@@ -102,6 +103,12 @@ export declare class MessagingClient implements DropOnAirClient {
102
103
  onEvent(callback: EventCallback): () => void;
103
104
  onMessageEdit(callback: MessageEditCallback): () => void;
104
105
  onMessageDelete(callback: MessageDeleteCallback): () => void;
106
+ /**
107
+ * Register a listener for read receipts that this user's OTHER devices
108
+ * reported. Fires when another device of the same user calls markRead();
109
+ * use it to clear the unread state your app maintains for that message.
110
+ */
111
+ onReadReceipt(callback: ReadReceiptCallback): () => void;
105
112
  editMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{
106
113
  editId: string;
107
114
  }>;
@@ -157,6 +164,21 @@ export declare class MessagingClient implements DropOnAirClient {
157
164
  * show a re-register prompt.
158
165
  */
159
166
  private handleDeviceRevoked;
167
+ /**
168
+ * Mark a message as read. Call this when YOUR app decides a message has
169
+ * been read - the platform never infers read state for you. The receipt
170
+ * is relayed to this user's other devices so they can clear their unread
171
+ * UI for the same message; it is not delivered to the message's sender.
172
+ *
173
+ * If the app owner has disabled read receipts in the dashboard, the server
174
+ * silently drops the frame.
175
+ *
176
+ * @param messageId the message that was read
177
+ * @param conversationId optional peer userId / group id, echoed back to
178
+ * other devices so they can bucket the receipt
179
+ */
180
+ markRead(messageId: string, conversationId?: string): void;
181
+ private handleIncomingSync;
160
182
  sendCleartextMessage(toUserId: string, plaintext: string): Promise<{
161
183
  messageId: string;
162
184
  }>;
@@ -220,6 +220,7 @@ class MessagingClient {
220
220
  this.eventListeners = new Set();
221
221
  this.broadcastListeners = new Set();
222
222
  this.messageEditListeners = new Set();
223
+ this.readReceiptListeners = new Set();
223
224
  this.messageDeleteListeners = new Set();
224
225
  this.wsUrl = options.messagingWsUrl ?? 'wss://sdk.droponair.com/ws';
225
226
  this.httpUrl = options.messagingHttpUrl ?? 'https://sdk.droponair.com';
@@ -410,6 +411,15 @@ class MessagingClient {
410
411
  this.messageDeleteListeners.add(callback);
411
412
  return () => this.messageDeleteListeners.delete(callback);
412
413
  }
414
+ /**
415
+ * Register a listener for read receipts that this user's OTHER devices
416
+ * reported. Fires when another device of the same user calls markRead();
417
+ * use it to clear the unread state your app maintains for that message.
418
+ */
419
+ onReadReceipt(callback) {
420
+ this.readReceiptListeners.add(callback);
421
+ return () => this.readReceiptListeners.delete(callback);
422
+ }
413
423
  // ---------------------------------------------------------------------------
414
424
  // Message edit and delete (PROTOCOL_VERSION 3+)
415
425
  // ---------------------------------------------------------------------------
@@ -630,6 +640,49 @@ class MessagingClient {
630
640
  }
631
641
  }
632
642
  // ---------------------------------------------------------------------------
643
+ // Cross-device read receipts (PROTOCOL_VERSION 6+ / Phase 2c)
644
+ // ---------------------------------------------------------------------------
645
+ /**
646
+ * Mark a message as read. Call this when YOUR app decides a message has
647
+ * been read - the platform never infers read state for you. The receipt
648
+ * is relayed to this user's other devices so they can clear their unread
649
+ * UI for the same message; it is not delivered to the message's sender.
650
+ *
651
+ * If the app owner has disabled read receipts in the dashboard, the server
652
+ * silently drops the frame.
653
+ *
654
+ * @param messageId the message that was read
655
+ * @param conversationId optional peer userId / group id, echoed back to
656
+ * other devices so they can bucket the receipt
657
+ */
658
+ markRead(messageId, conversationId) {
659
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
660
+ throw new Error('DropOnAir websocket is not connected');
661
+ }
662
+ const frame = {
663
+ type: 'SYNC_READ_RECEIPT',
664
+ messageId,
665
+ conversationId: conversationId ?? '',
666
+ timestamp: Date.now(),
667
+ };
668
+ this.ws.send(this.codec.encodeSyncFrame(frame));
669
+ }
670
+ handleIncomingSync(frame) {
671
+ if (frame.type === 'SYNC_READ_RECEIPT') {
672
+ const event = {
673
+ messageId: frame.messageId,
674
+ conversationId: frame.conversationId || undefined,
675
+ timestamp: frame.timestamp,
676
+ };
677
+ for (const listener of this.readReceiptListeners) {
678
+ try {
679
+ listener(event);
680
+ }
681
+ catch { /* listener errors must not break the socket */ }
682
+ }
683
+ }
684
+ }
685
+ // ---------------------------------------------------------------------------
633
686
  // Cleartext messaging (no E2EE key exchange required)
634
687
  // ---------------------------------------------------------------------------
635
688
  async sendCleartextMessage(toUserId, plaintext) {
@@ -1389,6 +1442,10 @@ class MessagingClient {
1389
1442
  this.handleIncomingMessageDelete(frame.data);
1390
1443
  return;
1391
1444
  }
1445
+ if (frame.kind === 'sync') {
1446
+ this.handleIncomingSync(frame.data);
1447
+ return;
1448
+ }
1392
1449
  if (frame.kind === 'envelope') {
1393
1450
  await this.handleIncomingEnvelope(frame.data);
1394
1451
  }
@@ -48,6 +48,18 @@ export interface MessageDeleteEvent {
48
48
  }
49
49
  export type MessageEditCallback = (event: MessageEditEvent) => void;
50
50
  export type MessageDeleteCallback = (event: MessageDeleteEvent) => void;
51
+ /**
52
+ * Delivered to the user's OTHER devices when one device calls markRead().
53
+ * Use it to clear whatever unread state your app keeps for that message.
54
+ * It is NOT delivered to the original sender of the message.
55
+ */
56
+ export interface ReadReceiptEvent {
57
+ messageId: string;
58
+ /** Optional peer userId / group id, echoed from markRead() for bucketing. */
59
+ conversationId?: string;
60
+ timestamp: number;
61
+ }
62
+ export type ReadReceiptCallback = (event: ReadReceiptEvent) => void;
51
63
  export interface BroadcastMessage {
52
64
  broadcastId: string;
53
65
  channelId: string;
@@ -183,6 +195,14 @@ export interface DropOnAirClient {
183
195
  onMessageEdit(callback: MessageEditCallback): () => void;
184
196
  /** Register a listener for incoming message-delete notifications. */
185
197
  onMessageDelete(callback: MessageDeleteCallback): () => void;
198
+ /**
199
+ * Mark a message as read. The integrating app decides when this is called;
200
+ * the platform never infers read state. The receipt is relayed to the
201
+ * user's other devices only, never to the message's sender.
202
+ */
203
+ markRead(messageId: string, conversationId?: string): void;
204
+ /** Register a listener for read receipts reported by this user's other devices. */
205
+ onReadReceipt(callback: ReadReceiptCallback): () => void;
186
206
  /**
187
207
  * Register this device's push notification token. The platform delivers a
188
208
  * push via APNs / FCM / Web Push when a sender attaches a pushPayload to
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { InitializeOptions, DropOnAirClient } from './core/types';
2
2
  export { SDK_VERSION, PROTOCOL_VERSION, PAYLOAD_FORMAT_VERSION } from './version';
3
3
  export declare function initialize(options: InitializeOptions): Promise<DropOnAirClient>;
4
- export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, DeviceInfo, } from './core/types';
4
+ export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, DeviceInfo, ReadReceiptEvent, ReadReceiptCallback, } from './core/types';
5
5
  export type { AttachmentEncryptionType, AttachmentConversationType, AttachmentRef, DeviceWrappedKey, CreateUploadSessionOptions, UploadSession, PrepareAttachmentOptions, DownloadedAttachment, } from './attachment/attachment-types';
6
6
  declare const _default: {
7
7
  initialize: typeof initialize;
@@ -148,6 +148,16 @@ export interface WirePushRegistrationFrame {
148
148
  voipToken?: string;
149
149
  deviceId: string;
150
150
  }
151
+ /**
152
+ * Cross-device state sync frame (PROTOCOL_VERSION 6+). Same shape sent
153
+ * client -> server and relayed server -> the user's other devices.
154
+ */
155
+ export interface WireSyncFrame {
156
+ type: string;
157
+ messageId: string;
158
+ conversationId?: string;
159
+ timestamp: number;
160
+ }
151
161
  /** Tombstone frame, scope FOR_EVERYONE or FOR_ME. */
152
162
  export interface WireMessageDeleteFrame {
153
163
  type: string;
@@ -190,6 +200,9 @@ export type InboundFrame = {
190
200
  } | {
191
201
  kind: 'messageDelete';
192
202
  data: WireMessageDeleteFrame;
203
+ } | {
204
+ kind: 'sync';
205
+ data: WireSyncFrame;
193
206
  };
194
207
  export declare class ProtobufCodec {
195
208
  encodeEnvelope(value: WireEnvelope): Uint8Array;
@@ -202,6 +215,7 @@ export declare class ProtobufCodec {
202
215
  encodeMessageEditFrame(value: WireMessageEditFrame): Uint8Array;
203
216
  encodeMessageDeleteFrame(value: WireMessageDeleteFrame): Uint8Array;
204
217
  encodePushRegistrationFrame(value: WirePushRegistrationFrame): Uint8Array;
218
+ encodeSyncFrame(value: WireSyncFrame): Uint8Array;
205
219
  decodeFrame(payload: Uint8Array): InboundFrame;
206
220
  private tryDecodeEnvelope;
207
221
  private tryDecodeAck;
@@ -211,6 +225,7 @@ export declare class ProtobufCodec {
211
225
  private tryDecodeGroupMessageNotification;
212
226
  private tryDecodeGroupAck;
213
227
  private tryDecodeGroupCallFrame;
228
+ private tryDecodeSyncFrame;
214
229
  private tryDecodeMessageEditFrame;
215
230
  private tryDecodeMessageDeleteFrame;
216
231
  }
@@ -217,6 +217,14 @@ const PushRegistrationFrameType = new protobuf.Type('PushRegistrationFrame')
217
217
  .add(new protobuf.Field('token', 3, 'string'))
218
218
  .add(new protobuf.Field('voipToken', 4, 'string'))
219
219
  .add(new protobuf.Field('deviceId', 5, 'string'));
220
+ // Cross-device state sync frame (PROTOCOL_VERSION 6+). Same shape both
221
+ // directions: client -> server (request) and server -> the user's other
222
+ // devices (notification). Discriminator is `type` at field 1 ("SYNC_").
223
+ const SyncFrameType = new protobuf.Type('SyncFrame')
224
+ .add(new protobuf.Field('type', 1, 'string'))
225
+ .add(new protobuf.Field('messageId', 2, 'string'))
226
+ .add(new protobuf.Field('conversationId', 3, 'string'))
227
+ .add(new protobuf.Field('timestamp', 4, 'int64'));
220
228
  class ProtobufCodec {
221
229
  encodeEnvelope(value) {
222
230
  return EnvelopeType.encode(value).finish();
@@ -248,6 +256,9 @@ class ProtobufCodec {
248
256
  encodePushRegistrationFrame(value) {
249
257
  return PushRegistrationFrameType.encode(value).finish();
250
258
  }
259
+ encodeSyncFrame(value) {
260
+ return SyncFrameType.encode(value).finish();
261
+ }
251
262
  decodeFrame(payload) {
252
263
  // Edit and delete frames must be probed BEFORE Envelope, the type
253
264
  // discriminator at field 1 ("MESSAGE_EDIT" / "MESSAGE_DELETE") would
@@ -260,6 +271,11 @@ class ProtobufCodec {
260
271
  if (asDelete) {
261
272
  return { kind: 'messageDelete', data: asDelete };
262
273
  }
274
+ // SyncFrame: discriminator "SYNC_" at field 1, probed before Envelope.
275
+ const asSync = this.tryDecodeSyncFrame(payload);
276
+ if (asSync) {
277
+ return { kind: 'sync', data: asSync };
278
+ }
263
279
  const asEnvelope = this.tryDecodeEnvelope(payload);
264
280
  if (asEnvelope) {
265
281
  return { kind: 'envelope', data: asEnvelope };
@@ -446,6 +462,18 @@ class ProtobufCodec {
446
462
  return null;
447
463
  }
448
464
  }
465
+ tryDecodeSyncFrame(payload) {
466
+ try {
467
+ const decoded = SyncFrameType.decode(payload);
468
+ if (!decoded.type || !decoded.type.startsWith('SYNC_')) {
469
+ return null;
470
+ }
471
+ return { ...decoded, timestamp: Number(decoded.timestamp) };
472
+ }
473
+ catch {
474
+ return null;
475
+ }
476
+ }
449
477
  tryDecodeMessageEditFrame(payload) {
450
478
  try {
451
479
  const decoded = MessageEditFrameType.decode(payload);
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.0";
10
+ export declare const SDK_VERSION = "0.11.0";
11
11
  /**
12
12
  * Binary encrypted-payload format version.
13
13
  * Included as the first byte of every encrypted payload so receivers can
@@ -19,4 +19,4 @@ export declare const PAYLOAD_FORMAT_VERSION = 1;
19
19
  * Increment when adding required proto fields or changing frame semantics.
20
20
  * The server advertises its supported range via GET /api/info.
21
21
  */
22
- export declare const PROTOCOL_VERSION = 5;
22
+ export declare const PROTOCOL_VERSION = 6;
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.10.0';
13
+ exports.SDK_VERSION = '0.11.0';
14
14
  /**
15
15
  * Binary encrypted-payload format version.
16
16
  * Included as the first byte of every encrypted payload so receivers can
@@ -22,4 +22,4 @@ exports.PAYLOAD_FORMAT_VERSION = 1;
22
22
  * Increment when adding required proto fields or changing frame semantics.
23
23
  * The server advertises its supported range via GET /api/info.
24
24
  */
25
- exports.PROTOCOL_VERSION = 5;
25
+ exports.PROTOCOL_VERSION = 6;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "DropOnAir SDK for end-to-end encrypted messaging",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",