@droponair/sdk-js 0.12.0 → 0.13.1

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,29 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.13.1], 2026-05-21
10
+
11
+ ### Changed
12
+
13
+ - **`revokeAttachment` accepts an `AttachmentRef`.** Calling `revokeAttachment(ref)` now also revokes the linked preview thumbnail (`ref.thumbnailAttachmentId`) in the same call. `revokeAttachment(attachmentId)` with a plain id string is unchanged and revokes only that one attachment. Additive — no breaking change.
14
+
15
+ ---
16
+
17
+ ## [0.13.0], 2026-05-21
18
+
19
+ ### Added
20
+
21
+ - **Group read receipts.** When a group has `readReceiptsVisibleToGroup` enabled, a member's `markRead(messageId, groupId)` is fanned to every other member, not just the reader's own devices. The inbound `ReadReceiptEvent` now carries `fromUserId` so you know which member read the message. New `client.updateGroup(groupId, { name?, readReceiptsVisibleToGroup? })` controls the per-group flag (it is a group-level choice; off by default). Broadcasts stay own-device only by design.
22
+ - **Attachment revoke.** New `client.revokeAttachment(attachmentId)` — the platform stops issuing download URLs for it and recipients with a live connection get an `ATTACHMENT_REVOKED` event via `onEvent` (metadata = attachmentId). Bytes already downloaded cannot be recalled.
23
+ - **Attachment preview thumbnails.** `prepareAttachmentAndUpload` accepts an optional `thumbnail` (and `thumbnailMimeType`). Entirely optional — if provided, the SDK uploads it as a separate attachment encrypted the same way and links it via `AttachmentRef.thumbnailAttachmentId`; download it like any attachment. If omitted, there is simply no thumbnail.
24
+
25
+ ### Notes
26
+
27
+ - Download authorization for GROUP attachments is now checked against *current* group membership: a user removed from a group can no longer download its attachments even if they were a recipient at upload time.
28
+ - No PROTOCOL_VERSION change — additive proto fields (`SyncFrame.fromUserId`, `AttachmentRef.thumbnailAttachmentId`) and an additive event type.
29
+
30
+ ---
31
+
9
32
  ## [0.12.0], 2026-05-20
10
33
 
11
34
  ### Added
package/README.md CHANGED
@@ -121,6 +121,16 @@ client.onReadReceipt(e => {
121
121
  });
122
122
  ```
123
123
 
124
+ **Group read receipts.** Since SDK `0.13.0`, a group can opt into sharing read receipts with every member. Call `updateGroup(groupId, { readReceiptsVisibleToGroup: true })` (owner/admin); after that, a member's `markRead()` on a group message is broadcast to every other member, and `onReadReceipt` fires with `e.fromUserId` set to the member who read it. It is **opt-in per group** — the platform never enables it for you, and it has no effect unless read receipts are also enabled for your app.
125
+
126
+ ```typescript
127
+ await client.updateGroup(groupId, { readReceiptsVisibleToGroup: true });
128
+
129
+ client.onReadReceipt(e => {
130
+ // e.fromUserId is the group member who read e.messageId
131
+ });
132
+ ```
133
+
124
134
  ### Notification clear & draft sync
125
135
 
126
136
  Available since SDK `0.12.0`, both own-device only. `clearNotification()` tells your user's other devices a conversation's notifications were dismissed — always available. `syncDraft()` pushes a draft so the user can keep typing on another device — **opt-in** (the app owner enables it in the dashboard) and the draft text crosses the relay in cleartext.
@@ -231,8 +241,12 @@ client.onMessage(async (msg) => {
231
241
  | `createUploadSession(options)` | `Promise<UploadSession>` | Low-level: presigned PUT URL only |
232
242
  | `finalizeAttachment(attachmentId, sha256)` | `Promise<void>` | Low-level: commit integrity hash |
233
243
  | `downloadAttachment(ref)` | `Promise<DownloadedAttachment>` | Get presigned URL + download + decrypt |
244
+ | `revokeAttachment(attachmentId \| ref)` | `Promise<void>` | Revoke an attachment you sent; recipients get `ATTACHMENT_REVOKED` |
234
245
  | `sendMessage(toUserId, text, { attachments })` | `Promise<{ messageId }>` | Send with attachments |
235
246
 
247
+ - **Preview thumbnails (optional).** Pass `thumbnail` (bytes) in `prepareAttachmentAndUpload` options and the SDK uploads it as a separate encrypted attachment, linked via `AttachmentRef.thumbnailAttachmentId`. Purely the developer's choice; omit it and there is no thumbnail. Download the thumbnail like any attachment.
248
+ - **Revoke.** `revokeAttachment()` stops the platform issuing new download URLs and notifies recipients. Bytes a recipient already downloaded cannot be recalled. For GROUP attachments, download is authorized against *current* group membership, so a removed member loses access. Pass an `AttachmentRef` instead of an id (`revokeAttachment(ref)`, since 0.13.1) to revoke the linked preview thumbnail in the same call; a thumbnail is a separate attachment, so revoking a bare id leaves its thumbnail untouched.
249
+
236
250
  - E2EE: a random AES-256-GCM file key encrypts the bytes; the file key is wrapped per recipient device using X25519 + HKDF (same model as message payloads). Server never sees the unwrapped file key.
237
251
  - Availability and per-file / per-month limits depend on your plan and are enforced server-side before the presigned URL is issued. See the [pricing page](https://www.droponair.com/pricing) and your dashboard Subscription page for what's enabled on your app.
238
252
  - Upload URL TTL = 15 min. Download URL TTL = 5 min. Download authorization checks that the requester is the original sender or in the captured recipient list.
@@ -255,6 +269,7 @@ client.onMessage(async (msg) => {
255
269
  | `getGroup(groupId)` | `Promise<GroupInfo>` | Get group details |
256
270
  | `addGroupMembers(groupId, userIds)` | `Promise<GroupInfo>` | Add members (owner/admin) |
257
271
  | `removeGroupMember(groupId, userId)` | `Promise<GroupInfo>` | Remove a member (owner/admin) |
272
+ | `updateGroup(groupId, { name?, readReceiptsVisibleToGroup? })` | `Promise<GroupInfo>` | Update group settings (owner/admin) |
258
273
  | `deleteGroup(groupId)` | `Promise<void>` | Delete a group (owner only) |
259
274
  | `sendGroupMessage(groupId, plaintext, memberUserIds, options?)` | `Promise<{ messageId }>` | Send an end-to-end encrypted group message (sender-side per-recipient fan-out). `options.attachments` for E2EE group attachments. |
260
275
  | `sendCleartextGroupMessage(groupId, plaintext)` | `Promise<{ messageId }>` | Send a plaintext (non-encrypted) group message; server fans out to every other member. |
@@ -43,6 +43,13 @@ export declare class AttachmentClient {
43
43
  * finalizes, and returns a fully-formed AttachmentRef for sendMessage.
44
44
  */
45
45
  prepareAttachmentAndUpload(input: Uint8Array, opts: PrepareAttachmentOptions): Promise<AttachmentRef>;
46
+ /**
47
+ * Revoke an attachment you sent (Phase 2f). After revoke the platform
48
+ * refuses to issue further download URLs and recipients with a live
49
+ * connection get an ATTACHMENT_REVOKED event. Bytes a recipient already
50
+ * downloaded cannot be recalled.
51
+ */
52
+ revokeAttachment(attachmentId: string): Promise<void>;
46
53
  getDownloadUrl(attachmentId: string): Promise<{
47
54
  url: string;
48
55
  method: string;
@@ -78,6 +78,20 @@ class AttachmentClient {
78
78
  if (!input || input.length === 0) {
79
79
  throw new Error('attachment bytes are empty');
80
80
  }
81
+ // Optional preview thumbnail: the developer's choice. If supplied, upload
82
+ // it as an independent attachment (same conversation + recipients +
83
+ // encryption) and link it from the main ref. The recursive call passes
84
+ // thumbnail: undefined so it terminates.
85
+ let thumbnailAttachmentId;
86
+ if (opts.thumbnail && opts.thumbnail.length > 0) {
87
+ const thumbRef = await this.prepareAttachmentAndUpload(opts.thumbnail, {
88
+ ...opts,
89
+ thumbnail: undefined,
90
+ mimeType: opts.thumbnailMimeType ?? 'image/jpeg',
91
+ onUploadProgress: undefined,
92
+ });
93
+ thumbnailAttachmentId = thumbRef.attachmentId;
94
+ }
81
95
  const encryptionType = opts.encryptionType ?? 'E2EE';
82
96
  const conversationType = opts.groupId ? 'GROUP' : 'ONE_TO_ONE';
83
97
  if (conversationType === 'ONE_TO_ONE' && !opts.toUserId) {
@@ -129,8 +143,23 @@ class AttachmentClient {
129
143
  sha256,
130
144
  encryptionType,
131
145
  wrappedKeys,
146
+ thumbnailAttachmentId,
132
147
  };
133
148
  }
149
+ /**
150
+ * Revoke an attachment you sent (Phase 2f). After revoke the platform
151
+ * refuses to issue further download URLs and recipients with a live
152
+ * connection get an ATTACHMENT_REVOKED event. Bytes a recipient already
153
+ * downloaded cannot be recalled.
154
+ */
155
+ async revokeAttachment(attachmentId) {
156
+ const jwt = await this.deps.getValidDropOnAirJwt();
157
+ const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/revoke`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
158
+ if (!resp.ok) {
159
+ const text = await resp.text().catch(() => '');
160
+ throw new Error(`Failed to revoke attachment (HTTP ${resp.status}): ${text}`);
161
+ }
162
+ }
134
163
  async getDownloadUrl(attachmentId) {
135
164
  const jwt = await this.deps.getValidDropOnAirJwt();
136
165
  const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/download-url`, {
@@ -204,6 +233,7 @@ class AttachmentClient {
204
233
  senderPublicKey: wk.senderPublicKey,
205
234
  nonce: wk.nonce,
206
235
  })),
236
+ thumbnailAttachmentId: ref.thumbnailAttachmentId ?? '',
207
237
  };
208
238
  }
209
239
  /** Convert a wire AttachmentRef (from a received message) into the public type. */
@@ -214,6 +244,7 @@ class AttachmentClient {
214
244
  mimeType: wire.mimeType,
215
245
  sizeBytes: Number(wire.sizeBytes),
216
246
  sha256: wire.sha256,
247
+ thumbnailAttachmentId: wire.thumbnailAttachmentId ? wire.thumbnailAttachmentId : undefined,
217
248
  encryptionType: wire.encryptionType === 1 ? 'CLEARTEXT' : 'E2EE',
218
249
  wrappedKeys: (wire.wrappedKeys ?? []).map((wk) => ({
219
250
  deviceId: wk.deviceId,
@@ -27,6 +27,12 @@ export interface AttachmentRef {
27
27
  encryptionType: AttachmentEncryptionType;
28
28
  /** Empty for CLEARTEXT. One entry per recipient device for E2EE. */
29
29
  wrappedKeys: DeviceWrappedKey[];
30
+ /**
31
+ * Optional. The attachmentId of a separate attachment holding a preview
32
+ * thumbnail. Present only when the sender chose to attach one. Download it
33
+ * like any attachment via {@link DropOnAirClient.downloadAttachment}.
34
+ */
35
+ thumbnailAttachmentId?: string;
30
36
  }
31
37
  /** Optional metadata for {@link DropOnAirClient.createUploadSession}. */
32
38
  export interface CreateUploadSessionOptions {
@@ -66,6 +72,15 @@ export interface PrepareAttachmentOptions {
66
72
  mimeType?: string;
67
73
  /** Progress callback called with bytes uploaded so far. */
68
74
  onUploadProgress?: (bytesUploaded: number, totalBytes: number) => void;
75
+ /**
76
+ * Optional preview thumbnail bytes. Entirely the developer's choice - if
77
+ * provided, the SDK uploads it as a separate attachment (encrypted with the
78
+ * same scheme as the main file) and links it via thumbnailAttachmentId. If
79
+ * omitted, the AttachmentRef simply has no thumbnail.
80
+ */
81
+ thumbnail?: Uint8Array;
82
+ /** Mime type of the thumbnail bytes (defaults to 'image/jpeg'). */
83
+ thumbnailMimeType?: string;
69
84
  }
70
85
  /** Bytes payload returned by downloadAttachment. */
71
86
  export interface DownloadedAttachment {
@@ -93,6 +93,7 @@ export declare class MessagingClient implements DropOnAirClient {
93
93
  createUploadSession(options: CreateUploadSessionOptions): Promise<UploadSession>;
94
94
  finalizeAttachment(attachmentId: string, sha256: string): Promise<void>;
95
95
  downloadAttachment(ref: AttachmentRef): Promise<DownloadedAttachment>;
96
+ revokeAttachment(attachment: string | AttachmentRef): Promise<void>;
96
97
  connect(): Promise<void>;
97
98
  disconnect(): void;
98
99
  sendMessage(toUserId: string, plaintextMessage: string, options?: {
@@ -213,6 +214,15 @@ export declare class MessagingClient implements DropOnAirClient {
213
214
  createGroup(name: string, memberUserIds?: string[]): Promise<GroupInfo>;
214
215
  listGroups(): Promise<GroupInfo[]>;
215
216
  getGroup(groupId: string): Promise<GroupInfo>;
217
+ /**
218
+ * Update a group. Any omitted field is left unchanged. `readReceiptsVisibleToGroup`
219
+ * (Phase 2e) controls whether members' group read receipts are visible to
220
+ * the whole group or stay own-device only.
221
+ */
222
+ updateGroup(groupId: string, update: {
223
+ name?: string;
224
+ readReceiptsVisibleToGroup?: boolean;
225
+ }): Promise<GroupInfo>;
216
226
  addGroupMembers(groupId: string, userIds: string[]): Promise<GroupInfo>;
217
227
  removeGroupMember(groupId: string, userId: string): Promise<GroupInfo>;
218
228
  deleteGroup(groupId: string): Promise<void>;
@@ -270,6 +270,17 @@ class MessagingClient {
270
270
  async downloadAttachment(ref) {
271
271
  return this.attachmentClient.downloadAttachment(ref);
272
272
  }
273
+ async revokeAttachment(attachment) {
274
+ if (typeof attachment === 'string') {
275
+ return this.attachmentClient.revokeAttachment(attachment);
276
+ }
277
+ // Revoke the main attachment first so the primary intent always lands,
278
+ // then cascade to the linked preview thumbnail if there is one.
279
+ await this.attachmentClient.revokeAttachment(attachment.attachmentId);
280
+ if (attachment.thumbnailAttachmentId) {
281
+ await this.attachmentClient.revokeAttachment(attachment.thumbnailAttachmentId);
282
+ }
283
+ }
273
284
  async connect() {
274
285
  this.log('connect_start');
275
286
  this.shouldReconnect = true;
@@ -723,6 +734,7 @@ class MessagingClient {
723
734
  messageId: frame.messageId,
724
735
  conversationId: frame.conversationId || undefined,
725
736
  timestamp: frame.timestamp,
737
+ fromUserId: frame.fromUserId || undefined,
726
738
  };
727
739
  for (const listener of this.readReceiptListeners) {
728
740
  try {
@@ -878,6 +890,22 @@ class MessagingClient {
878
890
  throw new Error(`getGroup failed (HTTP ${res.status})`);
879
891
  return res.json();
880
892
  }
893
+ /**
894
+ * Update a group. Any omitted field is left unchanged. `readReceiptsVisibleToGroup`
895
+ * (Phase 2e) controls whether members' group read receipts are visible to
896
+ * the whole group or stay own-device only.
897
+ */
898
+ async updateGroup(groupId, update) {
899
+ const jwt = await this.getValidDropOnAirJwt(false);
900
+ const res = await this.fetchFn(`${this.httpUrl}/api/groups/${encodeURIComponent(groupId)}`, {
901
+ method: 'PUT',
902
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
903
+ body: JSON.stringify(update),
904
+ });
905
+ if (!res.ok)
906
+ throw new Error(`updateGroup failed (HTTP ${res.status})`);
907
+ return res.json();
908
+ }
881
909
  async addGroupMembers(groupId, userIds) {
882
910
  const jwt = await this.getValidDropOnAirJwt(false);
883
911
  const res = await this.fetchFn(`${this.httpUrl}/api/groups/${encodeURIComponent(groupId)}/members`, {
@@ -58,6 +58,12 @@ export interface ReadReceiptEvent {
58
58
  /** Optional peer userId / group id, echoed from markRead() for bucketing. */
59
59
  conversationId?: string;
60
60
  timestamp: number;
61
+ /**
62
+ * The user who read the message. Set when a group read receipt is fanned
63
+ * to other members (so you know WHICH member read it). Undefined / your own
64
+ * userId for plain own-device sync.
65
+ */
66
+ fromUserId?: string;
61
67
  }
62
68
  export type ReadReceiptCallback = (event: ReadReceiptEvent) => void;
63
69
  /**
@@ -110,6 +116,11 @@ export interface GroupInfo {
110
116
  createdBy: string;
111
117
  members: GroupMemberInfo[];
112
118
  createdAt: number;
119
+ /**
120
+ * Phase 2e. When true, a member's group read receipt is fanned to all other
121
+ * members; when false it stays own-device only. Controlled via updateGroup().
122
+ */
123
+ readReceiptsVisibleToGroup?: boolean;
113
124
  }
114
125
  export interface GroupMemberInfo {
115
126
  userId: string;
@@ -274,6 +285,16 @@ export interface DropOnAirClient {
274
285
  finalizeAttachment(attachmentId: string, sha256: string): Promise<void>;
275
286
  /** Download (and for E2EE decrypt) an attachment referenced inside a received message. */
276
287
  downloadAttachment(ref: import('../attachment/attachment-types').AttachmentRef): Promise<import('../attachment/attachment-types').DownloadedAttachment>;
288
+ /**
289
+ * Revoke an attachment you sent. The platform stops issuing download URLs
290
+ * for it and notifies recipients; bytes already downloaded cannot be
291
+ * recalled.
292
+ *
293
+ * Pass an attachment id to revoke that single attachment. Pass an
294
+ * `AttachmentRef` to also revoke its linked preview thumbnail
295
+ * (`thumbnailAttachmentId`) in the same call, if it has one.
296
+ */
297
+ revokeAttachment(attachment: string | import('../attachment/attachment-types').AttachmentRef): Promise<void>;
277
298
  /** Send a cleartext message to a user (no encryption). */
278
299
  sendCleartextMessage(toUserId: string, plaintext: string): Promise<{
279
300
  messageId: string;
@@ -324,6 +345,14 @@ export interface DropOnAirClient {
324
345
  listGroups(): Promise<GroupInfo[]>;
325
346
  /** Get details of a specific group. */
326
347
  getGroup(groupId: string): Promise<GroupInfo>;
348
+ /**
349
+ * Update a group (requires OWNER/ADMIN role). Omitted fields are unchanged.
350
+ * `readReceiptsVisibleToGroup` toggles group-wide read-receipt visibility.
351
+ */
352
+ updateGroup(groupId: string, update: {
353
+ name?: string;
354
+ readReceiptsVisibleToGroup?: boolean;
355
+ }): Promise<GroupInfo>;
327
356
  /** Add members to a group (requires OWNER/ADMIN role). */
328
357
  addGroupMembers(groupId: string, userIds: string[]): Promise<GroupInfo>;
329
358
  /** Remove a member from a group (OWNER/ADMIN can remove others; anyone can leave). */
@@ -20,6 +20,7 @@ export interface WireAttachmentRef {
20
20
  sha256: string;
21
21
  encryptionType: number;
22
22
  wrappedKeys?: WireDeviceWrappedKey[];
23
+ thumbnailAttachmentId?: string;
23
24
  }
24
25
  export interface WireEnvelope {
25
26
  messageId: string;
@@ -158,6 +159,7 @@ export interface WireSyncFrame {
158
159
  conversationId?: string;
159
160
  timestamp: number;
160
161
  payload?: string;
162
+ fromUserId?: string;
161
163
  }
162
164
  /** Tombstone frame, scope FOR_EVERYONE or FOR_ME. */
163
165
  export interface WireMessageDeleteFrame {
@@ -59,7 +59,8 @@ const AttachmentRefType = new protobuf.Type('AttachmentRef')
59
59
  .add(new protobuf.Field('sizeBytes', 4, 'int64'))
60
60
  .add(new protobuf.Field('sha256', 5, 'string'))
61
61
  .add(new protobuf.Field('encryptionType', 6, 'EncryptionType'))
62
- .add(new protobuf.Field('wrappedKeys', 7, 'DeviceWrappedKey', 'repeated'));
62
+ .add(new protobuf.Field('wrappedKeys', 7, 'DeviceWrappedKey', 'repeated'))
63
+ .add(new protobuf.Field('thumbnailAttachmentId', 8, 'string'));
63
64
  const EnvelopeType = new protobuf.Type('Envelope')
64
65
  .add(EncryptionTypeEnum)
65
66
  .add(DeviceEncryptedPayloadType) // nested type must be added first
@@ -225,7 +226,8 @@ const SyncFrameType = new protobuf.Type('SyncFrame')
225
226
  .add(new protobuf.Field('messageId', 2, 'string'))
226
227
  .add(new protobuf.Field('conversationId', 3, 'string'))
227
228
  .add(new protobuf.Field('timestamp', 4, 'int64'))
228
- .add(new protobuf.Field('payload', 5, 'string'));
229
+ .add(new protobuf.Field('payload', 5, 'string'))
230
+ .add(new protobuf.Field('fromUserId', 6, 'string'));
229
231
  class ProtobufCodec {
230
232
  encodeEnvelope(value) {
231
233
  return EnvelopeType.encode(value).finish();
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.12.0";
10
+ export declare const SDK_VERSION = "0.13.1";
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.12.0';
13
+ exports.SDK_VERSION = '0.13.1';
14
14
  /**
15
15
  * Binary encrypted-payload format version.
16
16
  * Included as the first byte of every encrypted payload so receivers can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "DropOnAir SDK for end-to-end encrypted messaging",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",