@antzsoft/chat-core 1.0.7 → 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.
@@ -1993,6 +2153,7 @@ interface SendMessageAttachment {
1993
2153
  filename: string;
1994
2154
  mimeType: string;
1995
2155
  size: number;
2156
+ duration?: number; // seconds — required for audio/video so receivers can render a player UI
1996
2157
  }
1997
2158
  ```
1998
2159
 
@@ -2189,6 +2350,14 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
2189
2350
 
2190
2351
  ## Changelog
2191
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
+
2192
2361
  ### v1.0.7
2193
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 }> }`.
2194
2363
  - **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) => {
@@ -985,7 +1065,14 @@ var AntzChatClient = class {
985
1065
  disconnectSocket();
986
1066
  }
987
1067
  uploadFiles(files, conversationId) {
988
- 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
+ );
989
1076
  }
990
1077
  async uploadIcon(conversationId, file) {
991
1078
  const result = await this.uploadFiles([file], conversationId);
@@ -1005,6 +1092,7 @@ var AntzChatClient = class {
1005
1092
  disconnectSocket,
1006
1093
  getApiClient,
1007
1094
  getAuthStore,
1095
+ getCompressionStrategy,
1008
1096
  getSocket,
1009
1097
  getSocketStatus,
1010
1098
  initApiClient,