@antzsoft/chat-core 1.0.7 → 1.0.9

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.
@@ -1926,6 +2086,12 @@ interface Conversation {
1926
2086
  interface ConversationSettings {
1927
2087
  onlyAdminsCanMessage?: boolean;
1928
2088
  onlyAdminsCanAddMembers?: boolean;
2089
+ messageConfig?: MessageConfig;
2090
+ }
2091
+
2092
+ interface MessageConfig {
2093
+ editWindowSeconds?: number;
2094
+ deleteWindowSeconds?: number;
1929
2095
  }
1930
2096
  ```
1931
2097
 
@@ -1993,6 +2159,7 @@ interface SendMessageAttachment {
1993
2159
  filename: string;
1994
2160
  mimeType: string;
1995
2161
  size: number;
2162
+ duration?: number; // seconds — required for audio/video so receivers can render a player UI
1996
2163
  }
1997
2164
  ```
1998
2165
 
@@ -2189,6 +2356,21 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
2189
2356
 
2190
2357
  ## Changelog
2191
2358
 
2359
+ ### v1.0.9
2360
+ - **Socket reconnect resilience for `sendMessage`** — On Android, the OS suspends idle WebSocket connections during long audio recordings. `sendMessage` now waits up to 15 seconds for Socket.IO to auto-reconnect before sending, instead of immediately failing with "Socket not connected". Audio messages go through without error after upload. **No API change.**
2361
+ - **`settings` and `participantCount` now returned in conversation responses** — All conversation API responses and socket events (`conversation_created`, `conversation_updated`, etc.) now include `settings` (`onlyAdminsCanMessage`, `onlyAdminsCanAddMembers`, `messageConfig.editWindowSeconds`, `messageConfig.deleteWindowSeconds`) and `participantCount`. Previously these were server-side only. **No SDK change required — fields were already in the `Conversation` type as optional.**
2362
+ - **Fix: 500 error when leaving a group** — Leaving a group conversation returned an internal server error (leave still succeeded) for groups with no `messageId` in their stored `lastMessage`. The response builder now safely skips `lastMessage` when its `messageId` is missing. **No SDK change required.**
2363
+ - **Fix: empty `text: ""` stored on attachment-only messages** — Sending a message with an attachment and `text: ""` was persisting an empty string instead of `undefined`, causing the conversation-list preview to show blank instead of the filename or "Attachment". Both the HTTP and socket paths now trim and treat empty strings as absent before saving. **No SDK change required.**
2364
+ - **Fix: `participant_left` not received by users outside the open conversation** — The `participant_left` socket event was only broadcast to the `conversation:<id>` room, which users only join when they open that specific conversation. Users on the conversation-list screen never received it and saw stale participant counts. The server now also emits directly to each remaining participant's personal `user:<tenantId>:<userId>` room (joined on connect), skipping users already in the conversation room to avoid double-delivery. **No SDK change required.**
2365
+
2366
+ ### v1.0.8
2367
+ - **`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.
2368
+ - **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.
2369
+ - **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.**
2370
+ - **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.**
2371
+ - **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.**
2372
+ - **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).
2373
+
2192
2374
  ### v1.0.7
2193
2375
  - **`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 }> }`.
2194
2376
  - **Audio support for `.m4a` / `audio/x-m4a`** — `audio/m4a` and `audio/x-m4a` MIME types are now accepted for audio attachments.
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;
@@ -571,12 +641,22 @@ var storageApi = {
571
641
  return data;
572
642
  }
573
643
  };
574
- async function uploadBatch(files, platformUploadFn, conversationId, onProgress) {
575
- 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) => ({
576
649
  filename: f.name,
577
650
  mimeType: f.type,
578
651
  size: f.size,
579
- conversationId
652
+ conversationId,
653
+ ...f.compressed && {
654
+ metadata: {
655
+ compressed: true,
656
+ originalSize: f.originalSize,
657
+ compressionAlgorithm: f.compressionAlgorithm
658
+ }
659
+ }
580
660
  }));
581
661
  const { urls, errors: requestErrors } = await storageApi.requestPresignedUrlBatch(requests);
582
662
  const progressMap = {};
@@ -590,7 +670,7 @@ async function uploadBatch(files, platformUploadFn, conversationId, onProgress)
590
670
  const failed = [...requestErrors];
591
671
  await Promise.all(
592
672
  urls.map(async (presigned, idx) => {
593
- const file = files[idx];
673
+ const file = compressedFiles[idx];
594
674
  progressMap[idx] = 0;
595
675
  try {
596
676
  await platformUploadFn(presigned, file, (pct) => {
@@ -785,8 +865,32 @@ function refreshSocketAuth() {
785
865
 
786
866
  // src/socket/emitters.ts
787
867
  var ACK_TIMEOUT = 5e3;
788
- function withAck(event, payload) {
789
- const socket = tryGetSocket();
868
+ var RECONNECT_WAIT_TIMEOUT = 15e3;
869
+ function waitForReconnect() {
870
+ return new Promise((resolve, reject) => {
871
+ const timer = setTimeout(() => {
872
+ unsubscribe();
873
+ reject(new Error("[AntzChat] Socket reconnect timeout"));
874
+ }, RECONNECT_WAIT_TIMEOUT);
875
+ const unsubscribe = onSocketStatus((status) => {
876
+ if (status === "connected") {
877
+ clearTimeout(timer);
878
+ unsubscribe();
879
+ resolve();
880
+ } else if (status === "error") {
881
+ clearTimeout(timer);
882
+ unsubscribe();
883
+ reject(new Error("[AntzChat] Socket reconnect failed"));
884
+ }
885
+ });
886
+ });
887
+ }
888
+ async function withAck(event, payload) {
889
+ let socket = tryGetSocket();
890
+ if (!socket) {
891
+ await waitForReconnect();
892
+ socket = tryGetSocket();
893
+ }
790
894
  if (!socket) return Promise.reject(new Error(`[AntzChat] Socket not connected (event: ${event})`));
791
895
  return new Promise((resolve, reject) => {
792
896
  const timer = setTimeout(() => reject(new Error(`Socket ack timeout: ${event}`)), ACK_TIMEOUT);
@@ -985,7 +1089,14 @@ var AntzChatClient = class {
985
1089
  disconnectSocket();
986
1090
  }
987
1091
  uploadFiles(files, conversationId) {
988
- return uploadBatch(files, this._config.platformUploadFn, conversationId, this._config.upload.onProgress);
1092
+ return uploadBatch(
1093
+ files,
1094
+ this._config.platformUploadFn,
1095
+ conversationId,
1096
+ this._config.upload.onProgress,
1097
+ this._config.platformCompressFn,
1098
+ this._config.compression
1099
+ );
989
1100
  }
990
1101
  async uploadIcon(conversationId, file) {
991
1102
  const result = await this.uploadFiles([file], conversationId);
@@ -1005,6 +1116,7 @@ var AntzChatClient = class {
1005
1116
  disconnectSocket,
1006
1117
  getApiClient,
1007
1118
  getAuthStore,
1119
+ getCompressionStrategy,
1008
1120
  getSocket,
1009
1121
  getSocketStatus,
1010
1122
  initApiClient,