@antzsoft/chat-core 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -1122,6 +1122,166 @@ result.failed.forEach((f) => console.error('Failed:', f.filename, f.error));
1122
1122
 
1123
1123
  ---
1124
1124
 
1125
+ ## File Compression
1126
+
1127
+ Compression is optional and fully backward compatible. It runs entirely client-side before the upload starts — the server receives the already-compressed file with the correct size and MIME type.
1128
+
1129
+ ### How it works
1130
+
1131
+ When `platformCompressFn` is provided and `compression.enabled` is `true` (the default when a compressor is supplied), `uploadBatch` compresses each file before requesting a presigned URL:
1132
+
1133
+ 1. Determine strategy per file (`image` → WebP/JPEG resize+encode, `gzip` → text/doc compression, `skip` → no-op)
1134
+ 2. Run `platformCompressFn(file, compressionConfig)` — returns a `CompressedFile`
1135
+ 3. If compressed result is **larger** than the original, the original is used instead (automatic fallback)
1136
+ 4. Request presigned URL with the compressed size and MIME type
1137
+ 5. Upload the compressed bytes
1138
+ 6. Store `metadata.compressed`, `metadata.originalSize`, `metadata.compressionAlgorithm` on the file record
1139
+
1140
+ ### Strategy by file type
1141
+
1142
+ | File type | Strategy | Algorithm | Notes |
1143
+ |-----------|----------|-----------|-------|
1144
+ | `image/jpeg`, `image/png`, `image/gif`, `image/bmp`, `image/tiff` | `image` | `webp` (web) / `jpeg` (RN) | Resize to `imageMaxDimension` first |
1145
+ | `image/webp` | `image` | `webp` | Re-encode at target quality |
1146
+ | `image/svg+xml` | `gzip` | `gzip` | SVG is XML text |
1147
+ | `text/plain`, `text/csv`, `text/markdown`, `application/json`, `text/xml`, `application/xml`, `text/yaml`, `application/rtf` | `gzip` | `gzip` | Only when `compressDocuments: true` |
1148
+ | `video/*`, `audio/*`, `application/pdf`, `application/zip`, Office formats (`.docx`, `.xlsx`, `.pptx`) | `skip` | `none` | Already compressed |
1149
+
1150
+ ### Configuration
1151
+
1152
+ Pass `compression` and `platformCompressFn` in the config:
1153
+
1154
+ ```typescript
1155
+ import { AntzChatClient } from '@antzsoft/chat-core';
1156
+
1157
+ const client = new AntzChatClient({
1158
+ apiUrl: 'https://api.yourapp.com/api/v1',
1159
+ authToken: 'your-token',
1160
+ platformUploadFn: myUploadFn,
1161
+ persistStorage: myStorage,
1162
+
1163
+ // Optional — omit to disable compression entirely
1164
+ platformCompressFn: myCompressFn,
1165
+ compression: {
1166
+ enabled: true, // default: true when platformCompressFn is provided
1167
+ imageQuality: 0.85, // 0–1, default: 0.85
1168
+ imageMaxDimension: 1920, // longest side cap in px, default: 1920
1169
+ compressDocuments: true, // gzip text/json/csv/xml/yaml, default: true
1170
+ },
1171
+ });
1172
+ ```
1173
+
1174
+ ### Opt out completely
1175
+
1176
+ ```typescript
1177
+ // Disable compression — files upload as-is
1178
+ const client = new AntzChatClient({
1179
+ ...
1180
+ compression: { enabled: false },
1181
+ });
1182
+ ```
1183
+
1184
+ ### Implementing `platformCompressFn` for Node.js
1185
+
1186
+ The web and RN SDKs provide their own compressors automatically. For Node.js / `AntzChatClient` direct usage, implement it with `sharp` and `zlib`:
1187
+
1188
+ ```typescript
1189
+ import sharp from 'sharp';
1190
+ import zlib from 'zlib';
1191
+ import { promisify } from 'util';
1192
+ import type { PlatformCompressFn } from '@antzsoft/chat-core';
1193
+ import { getCompressionStrategy } from '@antzsoft/chat-core';
1194
+ import * as fs from 'fs/promises';
1195
+ import * as path from 'path';
1196
+ import * as os from 'os';
1197
+
1198
+ const gzip = promisify(zlib.gzip);
1199
+
1200
+ export const nodeCompressFn: PlatformCompressFn = async (file, options) => {
1201
+ const strategy = getCompressionStrategy(file.type, options);
1202
+ const noop = { ...file, originalSize: file.size, compressed: false, compressionAlgorithm: 'none' as const };
1203
+
1204
+ if (strategy === 'image') {
1205
+ try {
1206
+ const buffer = await sharp(file.uri)
1207
+ .resize({ width: options.imageMaxDimension, height: options.imageMaxDimension, fit: 'inside', withoutEnlargement: true })
1208
+ .jpeg({ quality: Math.round(options.imageQuality * 100) })
1209
+ .toBuffer();
1210
+
1211
+ if (buffer.length >= file.size) return noop;
1212
+
1213
+ const tmpPath = path.join(os.tmpdir(), `${Date.now()}.jpg`);
1214
+ await fs.writeFile(tmpPath, buffer);
1215
+
1216
+ return {
1217
+ uri: tmpPath,
1218
+ name: file.name.replace(/\.[^.]+$/, '.jpg'),
1219
+ type: 'image/jpeg',
1220
+ size: buffer.length,
1221
+ originalSize: file.size,
1222
+ compressed: true,
1223
+ compressionAlgorithm: 'jpeg' as const,
1224
+ };
1225
+ } catch {
1226
+ return noop;
1227
+ }
1228
+ }
1229
+
1230
+ if (strategy === 'gzip') {
1231
+ try {
1232
+ const input = await fs.readFile(file.uri);
1233
+ const compressed = await gzip(input);
1234
+
1235
+ if (compressed.length >= file.size) return noop;
1236
+
1237
+ const tmpPath = path.join(os.tmpdir(), `${Date.now()}.gz`);
1238
+ await fs.writeFile(tmpPath, compressed);
1239
+
1240
+ return {
1241
+ uri: tmpPath,
1242
+ name: file.name + '.gz',
1243
+ type: file.type,
1244
+ size: compressed.length,
1245
+ originalSize: file.size,
1246
+ compressed: true,
1247
+ compressionAlgorithm: 'gzip' as const,
1248
+ };
1249
+ } catch {
1250
+ return noop;
1251
+ }
1252
+ }
1253
+
1254
+ return noop;
1255
+ };
1256
+ ```
1257
+
1258
+ Then pass it to the client:
1259
+
1260
+ ```typescript
1261
+ const client = new AntzChatClient({
1262
+ apiUrl: 'https://api.yourapp.com/api/v1',
1263
+ authToken: process.env.AUTH_TOKEN,
1264
+ platformUploadFn: myUploadFn,
1265
+ persistStorage: myStorage,
1266
+ platformCompressFn: nodeCompressFn,
1267
+ compression: { imageQuality: 0.85, imageMaxDimension: 1920 },
1268
+ });
1269
+ ```
1270
+
1271
+ ### `CompressedFile` type
1272
+
1273
+ ```typescript
1274
+ interface CompressedFile extends UploadableFile {
1275
+ originalSize: number;
1276
+ compressed: boolean;
1277
+ compressionAlgorithm: 'webp' | 'jpeg' | 'gzip' | 'none';
1278
+ }
1279
+ ```
1280
+
1281
+ The `CompressedFile` extends `UploadableFile` — it is a drop-in replacement everywhere `UploadableFile` is accepted.
1282
+
1283
+ ---
1284
+
1125
1285
  ### Devices API (`devicesApi`)
1126
1286
 
1127
1287
  Used for push notification token registration. The SDK does not call this automatically — the host app is responsible for obtaining the device token from the OS and registering it.
@@ -1559,7 +1719,7 @@ Subscribe using `client.socket.on(event, handler)` (headless) or directly on the
1559
1719
  | `read_receipt` | `ReadReceiptEvent` | A user read messages in a conversation. | **Chat detail screen** (to update tick marks) + **app root** (to clear your own unread count when read on another device). |
1560
1720
  | `unread_count_changed` | `{ conversationId: string; unreadCount: number; userId: string }` | Your unread count changed for a conversation (fired to your personal room on all devices). | **App root / conversation list screen** — keep alive as long as the list is rendered. |
1561
1721
  | `message_ack` | `MessageAckEvent` | Server confirmation for a message you sent via socket (maps tempId to the real messageId). | **Chat detail screen** — add on mount, remove on unmount. |
1562
- | `message_delivered` | `{ messageId: string; conversationId: string; deliveredAt: string }` | A single message you sent was delivered to all active recipients. | **Chat detail screen** — add on mount, remove on unmount. |
1722
+ | `message_delivered` | `MessageDeliveredEvent` | A single message you sent was delivered to all active recipients. | **Chat detail screen** — add on mount, remove on unmount. |
1563
1723
  | `messages_delivered` | `MessagesDeliveredEvent` | Batch delivery catch-up — fired when a recipient comes online and your pending messages are delivered. | **Chat detail screen** — add on mount, remove on unmount. |
1564
1724
  | `conversation_created` | `Conversation` | A new conversation was created (or you were added to one). | **App root** — call `joinRoom` here for the new conversation. Keep for full session. |
1565
1725
  | `conversation_updated` | `Conversation` | A conversation's last message or metadata changed — use this to update the conversation list. | **App root** — keep for full session. The server emits this for every message across all conversations; a global listener keeps the in-memory conversation list and unread badge always in sync. |
@@ -1854,7 +2014,8 @@ interface Message {
1854
2014
  sentAt: string;
1855
2015
  createdAt: string;
1856
2016
  sender?: User;
1857
- readBy?: string[];
2017
+ readBy?: Array<{ userId: string; readAt: string }>;
2018
+ deliveredTo?: Array<{ userId: string; deliveredAt: string }>;
1858
2019
  isEncrypted?: boolean;
1859
2020
  encryptionMode?: 'none' | 'server' | 'e2ee';
1860
2021
  encryptedContent?: EncryptedContent;
@@ -1992,6 +2153,7 @@ interface SendMessageAttachment {
1992
2153
  filename: string;
1993
2154
  mimeType: string;
1994
2155
  size: number;
2156
+ duration?: number; // seconds — required for audio/video so receivers can render a player UI
1995
2157
  }
1996
2158
  ```
1997
2159
 
@@ -2053,10 +2215,16 @@ interface MessageAckEvent {
2053
2215
  status: MessageStatus;
2054
2216
  }
2055
2217
 
2218
+ interface MessageDeliveredEvent {
2219
+ messageId: string;
2220
+ conversationId: string;
2221
+ deliveredTo: Array<{ userId: string; deliveredAt: string }>;
2222
+ }
2223
+
2056
2224
  interface MessagesDeliveredEvent {
2057
2225
  conversationId: string;
2058
2226
  messageIds: string[];
2059
- deliveredTo: string;
2227
+ deliveredTo: string; // single userId (batch catch-up per recipient)
2060
2228
  deliveredAt: string;
2061
2229
  }
2062
2230
  ```
@@ -2180,6 +2348,35 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
2180
2348
 
2181
2349
  ---
2182
2350
 
2351
+ ## Changelog
2352
+
2353
+ ### v1.0.8
2354
+ - **`duration` field in `SendMessageAttachment`** — Pass `duration` (seconds) when sending audio or video. Server now stores and returns it in `new_message` and message list responses. **Action required (RN):** Omitting it on React Native can crash native audio player libraries on the receiver side; always pass it for audio/video. Web is unaffected.
2355
+ - **File compression** — `uploadBatch` now accepts optional `platformCompressFn` + `compressionConfig` args. Fully backward compatible — existing callers unchanged. Web/RN SDKs wire this in automatically; Node.js users can supply `nodeCompressFn` manually. No action required unless opting in on Node.
2356
+ - **Removed members keep read-only access** — Server behavior change. Removed participants stay in their conversation list and can read history but cannot write. Socket room membership ends immediately on removal. **No SDK change required.**
2357
+ - **Admin delete is hide-only** — Server behavior change. Deleting a conversation sets `isHidden` for the requester only; other participants are unaffected. **No SDK change required.**
2358
+ - **Fix: `lastMessage.status` stuck as `deleted`** — Server now explicitly resets `status: 'active'` on both REST and WebSocket paths when a new message is sent. **No client action required.**
2359
+ - **Fix: `duration` stored from sender payload** — `SendMessageAttachment.duration` is now persisted and echoed back. The `SendMessageAttachment` interface in this package is unchanged (field was already present as optional).
2360
+
2361
+ ### v1.0.7
2362
+ - **`deliveredTo` per-user delivery timestamps** — All message responses now include `deliveredTo: Array<{ userId, deliveredAt }>` alongside `readBy`. The `Message` type updated: `readBy` changed from `string[]` to `Array<{ userId, readAt }>`. The `message_delivered` socket event payload changed from `{ deliveredTo: string[], deliveredAt }` to `{ deliveredTo: Array<{ userId, deliveredAt }> }`.
2363
+ - **Audio support for `.m4a` / `audio/x-m4a`** — `audio/m4a` and `audio/x-m4a` MIME types are now accepted for audio attachments.
2364
+ - **Participant filter on `getMembers`** — `getMembers(conversationId, { filter: 'active' | 'deleted' | 'all' })` defaults to `'active'`. Use `'all'` to include removed participants, `'deleted'` to list only removed ones.
2365
+ - **Message character limit: 10,000** — Text messages are capped at 10,000 characters. Sending beyond this returns a `400` validation error.
2366
+
2367
+ ### v1.0.6
2368
+ - `externalId` on all user responses
2369
+ - Single unified User List API (`usersApi.list()`)
2370
+ - Single unified Conversation List API (`conversationsApi.list()`)
2371
+ - Fix: attachment last-message content preview
2372
+ - Fix: avatar upload via `AntzChatClient`
2373
+ - Fix: `client.connect()` on React Native
2374
+ - Group icon — create & update
2375
+ - Scroll to first unread message
2376
+ - Push notifications — device token registration (RN & Web)
2377
+
2378
+ ---
2379
+
2183
2380
  ## License
2184
2381
 
2185
2382
  MIT
package/dist/index.cjs CHANGED
@@ -102,6 +102,7 @@ __export(src_exports, {
102
102
  disconnectSocket: () => disconnectSocket,
103
103
  getApiClient: () => getApiClient,
104
104
  getAuthStore: () => getAuthStore,
105
+ getCompressionStrategy: () => getCompressionStrategy,
105
106
  getSocket: () => getSocket,
106
107
  getSocketStatus: () => getSocketStatus,
107
108
  initApiClient: () => initApiClient,
@@ -168,6 +169,13 @@ function resolveConfig(config) {
168
169
  onProgress: config.upload?.onProgress
169
170
  },
170
171
  platformUploadFn: config.platformUploadFn,
172
+ platformCompressFn: config.platformCompressFn,
173
+ compression: {
174
+ enabled: config.compression?.enabled ?? config.platformCompressFn != null,
175
+ imageQuality: config.compression?.imageQuality ?? 0.85,
176
+ imageMaxDimension: config.compression?.imageMaxDimension ?? 1920,
177
+ compressDocuments: config.compression?.compressDocuments ?? true
178
+ },
171
179
  persistStorage: config.persistStorage,
172
180
  messagePageSize: config.messagePageSize ?? 40,
173
181
  starredMessagePageSize: config.starredMessagePageSize ?? 30,
@@ -175,6 +183,68 @@ function resolveConfig(config) {
175
183
  };
176
184
  }
177
185
 
186
+ // src/compression/compress.ts
187
+ var GZIP_MIME_TYPES = /* @__PURE__ */ new Set([
188
+ "text/plain",
189
+ "text/csv",
190
+ "text/markdown",
191
+ "text/x-markdown",
192
+ "text/xml",
193
+ "application/xml",
194
+ "text/yaml",
195
+ "text/x-yaml",
196
+ "application/x-yaml",
197
+ "application/rtf",
198
+ "text/rtf",
199
+ "application/json",
200
+ "image/svg+xml"
201
+ ]);
202
+ var IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
203
+ "image/jpeg",
204
+ "image/png",
205
+ "image/gif",
206
+ "image/webp",
207
+ "image/bmp",
208
+ "image/tiff"
209
+ ]);
210
+ var SKIP_MIME_TYPES = /* @__PURE__ */ new Set([
211
+ "video/mp4",
212
+ "video/webm",
213
+ "video/quicktime",
214
+ "audio/mpeg",
215
+ "audio/wav",
216
+ "audio/ogg",
217
+ "audio/webm",
218
+ "audio/mp4",
219
+ "application/zip",
220
+ "application/pdf",
221
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
222
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
223
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation"
224
+ ]);
225
+ function getCompressionStrategy(mimeType, config) {
226
+ if (SKIP_MIME_TYPES.has(mimeType)) return "skip";
227
+ if (IMAGE_MIME_TYPES.has(mimeType)) return "image";
228
+ if (config.compressDocuments && GZIP_MIME_TYPES.has(mimeType)) return "gzip";
229
+ return "skip";
230
+ }
231
+ async function compressFile(file, platformCompressFn, config) {
232
+ const noop = {
233
+ ...file,
234
+ originalSize: file.size,
235
+ compressed: false,
236
+ compressionAlgorithm: "none"
237
+ };
238
+ if (!config.enabled || !platformCompressFn) return noop;
239
+ const strategy = getCompressionStrategy(file.type, config);
240
+ if (strategy === "skip") return noop;
241
+ try {
242
+ return await platformCompressFn(file, config);
243
+ } catch {
244
+ return noop;
245
+ }
246
+ }
247
+
178
248
  // src/api/client.ts
179
249
  var import_axios = __toESM(require("axios"), 1);
180
250
  var _tokenStore = null;
@@ -491,8 +561,11 @@ var conversationsApi = {
491
561
  async leave(conversationId) {
492
562
  await getApiClient().delete(`/conversations/${conversationId}/leave`);
493
563
  },
494
- async getMembers(conversationId) {
495
- const { data } = await getApiClient().get(`/conversations/${conversationId}/participants`);
564
+ async getMembers(conversationId, filter) {
565
+ const { data } = await getApiClient().get(
566
+ `/conversations/${conversationId}/participants`,
567
+ filter ? { params: { filter } } : void 0
568
+ );
496
569
  return data;
497
570
  },
498
571
  /**
@@ -568,12 +641,22 @@ var storageApi = {
568
641
  return data;
569
642
  }
570
643
  };
571
- async function uploadBatch(files, platformUploadFn, conversationId, onProgress) {
572
- const requests = files.map((f) => ({
644
+ async function uploadBatch(files, platformUploadFn, conversationId, onProgress, platformCompressFn, compressionConfig) {
645
+ const compressedFiles = await Promise.all(
646
+ files.map((f) => compressFile(f, platformCompressFn, compressionConfig ?? { enabled: false, imageQuality: 0.85, imageMaxDimension: 1920, compressDocuments: true }))
647
+ );
648
+ const requests = compressedFiles.map((f) => ({
573
649
  filename: f.name,
574
650
  mimeType: f.type,
575
651
  size: f.size,
576
- conversationId
652
+ conversationId,
653
+ ...f.compressed && {
654
+ metadata: {
655
+ compressed: true,
656
+ originalSize: f.originalSize,
657
+ compressionAlgorithm: f.compressionAlgorithm
658
+ }
659
+ }
577
660
  }));
578
661
  const { urls, errors: requestErrors } = await storageApi.requestPresignedUrlBatch(requests);
579
662
  const progressMap = {};
@@ -587,7 +670,7 @@ async function uploadBatch(files, platformUploadFn, conversationId, onProgress)
587
670
  const failed = [...requestErrors];
588
671
  await Promise.all(
589
672
  urls.map(async (presigned, idx) => {
590
- const file = files[idx];
673
+ const file = compressedFiles[idx];
591
674
  progressMap[idx] = 0;
592
675
  try {
593
676
  await platformUploadFn(presigned, file, (pct) => {
@@ -982,7 +1065,14 @@ var AntzChatClient = class {
982
1065
  disconnectSocket();
983
1066
  }
984
1067
  uploadFiles(files, conversationId) {
985
- return uploadBatch(files, this._config.platformUploadFn, conversationId, this._config.upload.onProgress);
1068
+ return uploadBatch(
1069
+ files,
1070
+ this._config.platformUploadFn,
1071
+ conversationId,
1072
+ this._config.upload.onProgress,
1073
+ this._config.platformCompressFn,
1074
+ this._config.compression
1075
+ );
986
1076
  }
987
1077
  async uploadIcon(conversationId, file) {
988
1078
  const result = await this.uploadFiles([file], conversationId);
@@ -1002,6 +1092,7 @@ var AntzChatClient = class {
1002
1092
  disconnectSocket,
1003
1093
  getApiClient,
1004
1094
  getAuthStore,
1095
+ getCompressionStrategy,
1005
1096
  getSocket,
1006
1097
  getSocketStatus,
1007
1098
  initApiClient,