@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.
@@ -118,6 +118,29 @@ li{margin-bottom:4px;color:#c8d0e0}
118
118
  .badge.purple{color:var(--accent2);border-color:rgba(167,139,250,.4);background:rgba(167,139,250,.1)}
119
119
  .badge.green{color:var(--green);border-color:rgba(52,211,153,.4);background:rgba(52,211,153,.1)}
120
120
  .badge.orange{color:var(--yellow);border-color:rgba(251,191,36,.4);background:rgba(251,191,36,.1)}
121
+
122
+ /* ── Section accordion ── */
123
+ section.sec>h2{cursor:pointer;user-select:none;display:flex;align-items:center;justify-content:space-between;gap:10px}
124
+ section.sec>h2 .sec-chevron{font-size:11px;color:var(--muted);transition:transform .22s;margin-left:auto;flex-shrink:0}
125
+ section.sec.collapsed>h2 .sec-chevron{transform:rotate(180deg)}
126
+ section.sec .sec-body{display:block}
127
+ section.sec.collapsed .sec-body{display:none}
128
+ section.sec>h2:hover{color:#fff}
129
+
130
+ /* ── H3 sub-accordion inside sections ── */
131
+ .sub-acc{border:1px solid var(--border);border-radius:var(--radius);margin:18px 0}
132
+ .sub-acc-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;cursor:pointer;user-select:none;background:var(--surface)}
133
+ .sub-acc-hdr:hover{background:var(--surface2)}
134
+ .sub-acc-hdr h3{margin:0;font-size:14px;font-weight:700;color:var(--accent2)}
135
+ .sub-acc-hdr .sub-chev{font-size:10px;color:var(--muted);transition:transform .2s}
136
+ .sub-acc.closed .sub-chev{transform:rotate(180deg)}
137
+ .sub-acc-body{padding:4px 16px 14px}
138
+ .sub-acc.closed .sub-acc-body{display:none}
139
+
140
+ /* ── Go-to-top FAB ── */
141
+ #gototop{position:fixed;bottom:28px;right:28px;width:42px;height:42px;border-radius:50%;background:var(--accent);color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 4px 18px rgba(108,140,255,.45);opacity:0;transform:translateY(12px);transition:opacity .22s,transform .22s;pointer-events:none;z-index:300}
142
+ #gototop.show{opacity:1;transform:translateY(0);pointer-events:auto}
143
+ #gototop:hover{background:var(--accent2);box-shadow:0 6px 24px rgba(167,139,250,.5)}
121
144
  </style>
122
145
  </head>
123
146
  <body>
@@ -132,7 +155,7 @@ li{margin-bottom:4px;color:#c8d0e0}
132
155
 
133
156
  <div class="section-label">What's New</div>
134
157
  <ul>
135
- <li><a href="#whats-new">v1.0.7 Release Notes</a></li>
158
+ <li><a href="#whats-new">v1.0.9 Release Notes</a></li>
136
159
  </ul>
137
160
 
138
161
  <div class="section-label">Getting Started</div>
@@ -177,6 +200,7 @@ li{margin-bottom:4px;color:#c8d0e0}
177
200
  <div class="section-label">Files &amp; Advanced</div>
178
201
  <ul>
179
202
  <li><a href="#step-upload">16. Upload Files</a></li>
203
+ <li><a href="#step-compress">16.5 File Compression</a></li>
180
204
  <li data-nav="rn-web"><a href="#step-push">17. Push Notifications</a></li>
181
205
  <li><a href="#step-prefs">17.5 Notification Preferences</a></li>
182
206
  <li><a href="#step-example">18. Full Example</a></li>
@@ -218,12 +242,199 @@ li{margin-bottom:4px;color:#c8d0e0}
218
242
  <h2>What's New</h2>
219
243
  <p style="color:var(--muted);font-size:13px;margin-bottom:20px">Version history and release notes. Click a version to expand.</p>
220
244
 
221
- <!-- ── v1.0.7 (current) ── -->
245
+ <!-- ── v1.0.9 (current) ── -->
246
+ <div class="wn-version open" id="wn-109">
247
+ <div class="wn-header" onclick="toggleVersion('wn-109')">
248
+ <div class="wn-title">
249
+ <span class="wn-ver">v1.0.9</span>
250
+ <span class="wn-badge current">Current</span>
251
+ <span class="wn-date">May 2026</span>
252
+ </div>
253
+ <span class="wn-chevron">▲</span>
254
+ </div>
255
+ <div class="wn-body">
256
+
257
+ <div class="wn-item" id="wn-109-1">
258
+ <div class="wn-item-header" onclick="toggleItem('wn-109-1')">
259
+ <span class="wn-tag fix">Fix</span>
260
+ <span class="wn-item-title">Socket reconnect resilience for <code>sendMessage</code> on Android</span>
261
+ <span class="wn-chevron-sm">▾</span>
262
+ </div>
263
+ <div class="wn-item-body">
264
+ <p>On Android, the OS suspends idle WebSocket connections during long operations such as audio recording. When <code>sendMessage</code> was called immediately after a file upload completed, the socket was still reconnecting and the call failed immediately with "Socket not connected".</p>
265
+ <p><code>sendMessage</code> now waits up to <strong>15 seconds</strong> for Socket.IO to auto-reconnect before attempting to send. Once the connection is restored the message goes through normally — no error, no retry logic needed in the app.</p>
266
+ <p><strong>Action required:</strong> None. Behavior change is inside the SDK. No API or type changes.</p>
267
+ </div>
268
+ </div>
269
+
270
+ <div class="wn-item" id="wn-109-2">
271
+ <div class="wn-item-header" onclick="toggleItem('wn-109-2')">
272
+ <span class="wn-tag new">New</span>
273
+ <span class="wn-item-title"><code>settings</code> and <code>participantCount</code> now returned in conversation responses</span>
274
+ <span class="wn-chevron-sm">▾</span>
275
+ </div>
276
+ <div class="wn-item-body">
277
+ <p>All conversation API responses and real-time socket events (<code>conversation_created</code>, and all update/participant operations) now include two previously server-only fields:</p>
278
+ <ul>
279
+ <li><code>settings.onlyAdminsCanMessage</code> — whether only admins can send messages</li>
280
+ <li><code>settings.onlyAdminsCanAddMembers</code> — whether only admins can add participants</li>
281
+ <li><code>settings.messageConfig.editWindowSeconds</code> — seconds after send within which a message can be edited</li>
282
+ <li><code>settings.messageConfig.deleteWindowSeconds</code> — seconds after send within which a message can be deleted for everyone</li>
283
+ <li><code>participantCount</code> — total number of participants (including inactive)</li>
284
+ </ul>
285
+ <p>Both fields were already defined as optional in the <code>Conversation</code> type — they just always came back <code>undefined</code> before. No type changes needed.</p>
286
+ <p><strong>Action required:</strong> None. Fields were optional before and remain optional. Existing code is unaffected.</p>
287
+ </div>
288
+ </div>
289
+
290
+ <div class="wn-item" id="wn-109-3">
291
+ <div class="wn-item-header" onclick="toggleItem('wn-109-3')">
292
+ <span class="wn-tag fix">Fix</span>
293
+ <span class="wn-item-title">500 error when leaving a group</span>
294
+ <span class="wn-chevron-sm">▾</span>
295
+ </div>
296
+ <div class="wn-item-body">
297
+ <p>Leaving a group conversation returned a 500 internal server error for groups that had no <code>messageId</code> in their stored <code>lastMessage</code> — even though the leave itself succeeded. The response builder now safely skips <code>lastMessage</code> when its <code>messageId</code> is missing.</p>
298
+ <p><strong>Action required:</strong> None. Server-side fix only.</p>
299
+ </div>
300
+ </div>
301
+
302
+ <div class="wn-item" id="wn-109-4">
303
+ <div class="wn-item-header" onclick="toggleItem('wn-109-4')">
304
+ <span class="wn-tag fix">Fix</span>
305
+ <span class="wn-item-title">Empty <code>text: ""</code> stored on attachment-only messages</span>
306
+ <span class="wn-chevron-sm">▾</span>
307
+ </div>
308
+ <div class="wn-item-body">
309
+ <p>When sending a message with an attachment and <code>text: ""</code>, the empty string was being stored in the database and returned as-is instead of being treated as absent. This caused the conversation-list preview for attachment-only messages to show blank instead of falling back to the filename or "Attachment".</p>
310
+ <p>Both the HTTP API and socket paths now trim the text input and treat empty strings as absent before saving to the database. The preview now correctly shows the filename or "Attachment" for attachment-only messages.</p>
311
+ <p><strong>Action required:</strong> None. Server-side fix only. No SDK or type changes.</p>
312
+ </div>
313
+ </div>
314
+
315
+ <div class="wn-item" id="wn-109-5">
316
+ <div class="wn-item-header" onclick="toggleItem('wn-109-5')">
317
+ <span class="wn-tag fix">Fix</span>
318
+ <span class="wn-item-title"><code>participant_left</code> not received by users outside the open conversation</span>
319
+ <span class="wn-chevron-sm">▾</span>
320
+ </div>
321
+ <div class="wn-item-body">
322
+ <p>The <code>participant_left</code> socket event was only broadcast to the <code>conversation:&lt;id&gt;</code> room. Users only join that room when they actively open that specific conversation — so anyone on the conversation-list screen (or any other screen) never received the event and saw a stale participant count.</p>
323
+ <p>The server now also emits directly to each remaining participant's personal <code>user:&lt;tenantId&gt;:&lt;userId&gt;</code> room, which every connected user joins on connect. To prevent users who have the conversation open from receiving the event twice (and double-decrementing the count), the server checks whether any of the user's sockets are already in the conversation room before sending the personal-room emit.</p>
324
+ <p><strong>Action required:</strong> None. Server-side fix only. No SDK or type changes.</p>
325
+ </div>
326
+ </div>
327
+
328
+ </div>
329
+ </div>
330
+
331
+ <!-- ── v1.0.8 ── -->
332
+ <div class="wn-version open" id="wn-108">
333
+ <div class="wn-header" onclick="toggleVersion('wn-108')">
334
+ <div class="wn-title">
335
+ <span class="wn-ver">v1.0.8</span>
336
+ <span class="wn-date">May 2026</span>
337
+ </div>
338
+ <span class="wn-chevron">▲</span>
339
+ </div>
340
+ <div class="wn-body">
341
+
342
+ <div class="wn-item" id="wn-108-1">
343
+ <div class="wn-item-header" onclick="toggleItem('wn-108-1')">
344
+ <span class="wn-tag new">New</span>
345
+ <span class="wn-item-title">File compression — client-side, before upload</span>
346
+ <span class="wn-chevron-sm">▾</span>
347
+ </div>
348
+ <div class="wn-item-body">
349
+ <p>Files are now compressed on the client before the presigned URL is requested. Images are resized and re-encoded (WebP on web, JPEG on RN). Text-based documents (JSON, CSV, XML, YAML, plain text, SVG) are gzip-compressed on web. Audio, video, PDF, ZIP, and Office formats are skipped — they are already compressed.</p>
350
+ <p>If the compressed result is larger than the original, the original is used automatically. Compression metadata (<code>compressed</code>, <code>originalSize</code>, <code>compressionAlgorithm</code>) is stored on the file record.</p>
351
+ <table>
352
+ <tr><th>Platform</th><th>Images</th><th>Text docs</th><th>Default state</th></tr>
353
+ <tr><td>Web</td><td>WebP via <code>canvas</code></td><td>gzip via <code>CompressionStream</code></td><td>On (no deps)</td></tr>
354
+ <tr><td>React Native</td><td>JPEG via <code>expo-image-manipulator</code></td><td>Skip</td><td>On when peer dep installed</td></tr>
355
+ <tr><td>Node.js</td><td>JPEG via <code>sharp</code> (optional)</td><td>gzip via <code>zlib</code></td><td>Off — supply <code>platformCompressFn</code> to enable</td></tr>
356
+ </table>
357
+ <p><strong>Action required:</strong> None for web or RN — compression works automatically. Node.js users who want compression must supply a <code>platformCompressFn</code> — see <a href="#step-compress">Step 16.5</a>. To disable: pass <code>compression: { enabled: false }</code>.</p>
358
+ <p class="wn-ref">→ <a href="#step-compress">Step 16.5 — File Compression</a></p>
359
+ </div>
360
+ </div>
361
+
362
+ <div class="wn-item" id="wn-108-2">
363
+ <div class="wn-item-header" onclick="toggleItem('wn-108-2')">
364
+ <span class="wn-tag fix">Fix</span>
365
+ <span class="wn-item-title">Removed members keep read-only access to conversation</span>
366
+ <span class="wn-chevron-sm">▾</span>
367
+ </div>
368
+ <div class="wn-item-body">
369
+ <p>Previously, removing a member from a conversation caused it to disappear from their conversation list entirely. They could no longer read history or see who was in the conversation.</p>
370
+ <p>Now, removed members:</p>
371
+ <ul>
372
+ <li>Still see the conversation in their list</li>
373
+ <li>Can read message history up to the point of removal</li>
374
+ <li>Are kicked from the socket room immediately — they receive no new real-time events</li>
375
+ <li>Cannot send, react, type, edit, or delete — <code>validateWriteAccess</code> blocks all write operations</li>
376
+ </ul>
377
+ <p>This is implemented via a new <code>isHidden</code> field on the <code>Participant</code> subdocument (schema change, server only). The conversation list filter now shows conversations where the user is not hidden, regardless of <code>isActive</code>.</p>
378
+ <p><strong>Action required:</strong> None. Behavior change is entirely server-side. SDK types and API calls are unchanged.</p>
379
+ </div>
380
+ </div>
381
+
382
+ <div class="wn-item" id="wn-108-3">
383
+ <div class="wn-item-header" onclick="toggleItem('wn-108-3')">
384
+ <span class="wn-tag fix">Fix</span>
385
+ <span class="wn-item-title">Admin delete is now hide-only — other participants unaffected</span>
386
+ <span class="wn-chevron-sm">▾</span>
387
+ </div>
388
+ <div class="wn-item-body">
389
+ <p>Previously, when an admin called <code>deleteConversation</code> it set <code>conversation.isActive = false</code>, making it disappear for <em>all</em> participants. This was wrong — only the admin who deleted it should lose access.</p>
390
+ <p>Now, <code>deleteConversation</code> sets <code>participant.isHidden = true</code> for the requesting user only. All other participants continue to see the conversation normally.</p>
391
+ <p><strong>Action required:</strong> None. Server-side change only.</p>
392
+ </div>
393
+ </div>
394
+
395
+ <div class="wn-item" id="wn-108-4">
396
+ <div class="wn-item-header" onclick="toggleItem('wn-108-4')">
397
+ <span class="wn-tag fix">Fix</span>
398
+ <span class="wn-item-title"><code>lastMessage.status</code> stuck as <code>deleted</code> after new messages</span>
399
+ <span class="wn-chevron-sm">▾</span>
400
+ </div>
401
+ <div class="wn-item-body">
402
+ <p>When a message was deleted, <code>lastMessage.status</code> on the conversation was set to <code>'deleted'</code>. Sending a new message afterwards never reset it — so the conversation list kept showing <code>deleted</code> as the last message state indefinitely.</p>
403
+ <p>Root cause: two separate code paths update <code>lastMessage</code> (REST and WebSocket) and neither was explicitly setting <code>status: 'active'</code> when writing a new message. Fixed in both paths.</p>
404
+ <p><strong>Action required:</strong> None. Server-side fix only.</p>
405
+ </div>
406
+ </div>
407
+
408
+ <div class="wn-item" id="wn-108-5">
409
+ <div class="wn-item-header" onclick="toggleItem('wn-108-5')">
410
+ <span class="wn-tag new">New</span>
411
+ <span class="wn-item-title"><code>duration</code> field in <code>SendMessageAttachment</code> for audio/video</span>
412
+ <span class="wn-chevron-sm">▾</span>
413
+ </div>
414
+ <div class="wn-item-body">
415
+ <p><code>SendMessageAttachment</code> now accepts an optional <code>duration</code> field (integer, seconds). Pass it when sending audio or video so receivers can render a player UI — e.g. show <code>0:30</code> before the file is loaded. The server stores and echoes this value in <code>new_message</code> and all message list responses.</p>
416
+ <pre><code><span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({
417
+ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(),
418
+ <span class="at">attachments</span>: [{
419
+ <span class="at">fileId</span>: f.id, <span class="at">type</span>: <span class="str">'audio'</span>, <span class="at">url</span>: f.url,
420
+ <span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size,
421
+ <span class="at">duration</span>: recordingSeconds, <span class="cm">// ← pass this</span>
422
+ }],
423
+ });</code></pre>
424
+ <p><strong>Action required (React Native):</strong> Always pass <code>duration</code> when sending audio or video. Omitting it on RN causes receivers to get <code>undefined</code> duration, which crashes native audio player libraries that require an explicit value to initialize. Web is unaffected — browsers read duration natively from the audio stream.</p>
425
+ <p><strong>Action required (Web):</strong> None — recommended to pass for better UX (shows duration before file loads) but not required.</p>
426
+ <p class="wn-ref">→ <a href="#step-send">Step 9 — Sending Messages</a></p>
427
+ </div>
428
+ </div>
429
+
430
+ </div>
431
+ </div>
432
+
433
+ <!-- ── v1.0.7 ── -->
222
434
  <div class="wn-version open" id="wn-107">
223
435
  <div class="wn-header" onclick="toggleVersion('wn-107')">
224
436
  <div class="wn-title">
225
437
  <span class="wn-ver">v1.0.7</span>
226
- <span class="wn-badge current">Current</span>
227
438
  <span class="wn-date">May 2026</span>
228
439
  </div>
229
440
  <span class="wn-chevron">▲</span>
@@ -1403,7 +1614,13 @@ client.socket.<span class="fn">on</span>(<span class="str">'conversation_created
1403
1614
  <span class="kw">const</span> attachments = successful.<span class="fn">map</span>(f => ({
1404
1615
  <span class="at">fileId</span>: f.id, <span class="at">type</span>: f.type, <span class="at">url</span>: f.url, <span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size,
1405
1616
  }));
1406
- <span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(), attachments });</code></pre>
1617
+ <span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(), attachments });
1618
+
1619
+ <span class="cm">// Audio/video — always pass duration (seconds) so receivers can render a player UI</span>
1620
+ <span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({
1621
+ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(),
1622
+ <span class="at">attachments</span>: [{ <span class="at">fileId</span>: f.id, <span class="at">type</span>: <span class="str">'audio'</span>, <span class="at">url</span>: f.url, <span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size, <span class="at">duration</span>: recordingSeconds }],
1623
+ });</code></pre>
1407
1624
  </section>
1408
1625
 
1409
1626
  <!-- ─── STEP 10: REAL-TIME ─────────────────────────────────────────────── -->
@@ -1864,6 +2081,8 @@ socket?.<span class="fn">on</span>(<span class="str">'read_receipt'</span>, ({ c
1864
2081
  <pre><code><span class="kw">const</span> attachments = successful.<span class="fn">map</span>(f => ({
1865
2082
  <span class="at">fileId</span>: f.id, <span class="at">type</span>: f.type, <span class="at">url</span>: f.url,
1866
2083
  <span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size,
2084
+ <span class="cm">// For audio/video include duration (seconds) — required for receiver player UI</span>
2085
+ ...(f.type === <span class="str">'audio'</span> || f.type === <span class="str">'video'</span> ? { <span class="at">duration</span>: knownDurationSeconds } : {}),
1867
2086
  }));
1868
2087
  <span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(), attachments });</code></pre>
1869
2088
 
@@ -1880,6 +2099,156 @@ socket?.<span class="fn">on</span>(<span class="str">'read_receipt'</span>, ({ c
1880
2099
  });</code></pre>
1881
2100
  </section>
1882
2101
 
2102
+ <!-- ─── STEP 16.5: COMPRESSION ───────────────────────────────────────── -->
2103
+ <section id="step-compress">
2104
+ <h2><span class="step">STEP 16.5</span> File Compression</h2>
2105
+ <p>Compression runs <strong>client-side before upload</strong> — the server receives the compressed file with the correct size and MIME type. It is optional and fully backward compatible. Omit <code>platformCompressFn</code> to keep the original behavior.</p>
2106
+
2107
+ <!-- ── REACT NATIVE ── -->
2108
+ <div data-p="rn">
2109
+ <div class="callout tip"><strong>Zero config for Expo</strong> Install <code>expo-image-manipulator</code> and compression works automatically. No other changes needed.</div>
2110
+
2111
+ <pre><code>npx expo install expo-image-manipulator</code></pre>
2112
+
2113
+ <p>Without <code>expo-image-manipulator</code> the SDK skips compression silently — files upload as-is.</p>
2114
+
2115
+ <h3>Default behavior</h3>
2116
+ <pre><code><span class="cm">// AntzChatNavigator / AntzChatProvider — compression on by default, nothing to add</span>
2117
+ &lt;AntzChatNavigator config={{ <span class="at">apiUrl</span>: <span class="str">'...'</span> }} /&gt;
2118
+
2119
+ <span class="cm">// AntzChatClient (headless) — pass rnCompressFn explicitly</span>
2120
+ <span class="kw">import</span> { AntzChatClient, rnUploadFn, rnPersistStorage, rnCompressFn } <span class="kw">from</span> <span class="str">'@antzsoft/chat-rn-sdk'</span>;
2121
+
2122
+ <span class="kw">const</span> client = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
2123
+ <span class="at">apiUrl</span>: <span class="str">'https://api.example.com/api/v1'</span>,
2124
+ <span class="at">authToken</span>: token,
2125
+ <span class="at">platformUploadFn</span>: rnUploadFn,
2126
+ <span class="at">persistStorage</span>: rnPersistStorage,
2127
+ <span class="at">platformCompressFn</span>: rnCompressFn, <span class="cm">// add this for headless usage</span>
2128
+ });</code></pre>
2129
+
2130
+ <h3>Strategy by file type</h3>
2131
+ <table>
2132
+ <tr><th>File type</th><th>Strategy</th><th>Algorithm</th><th>Requires</th></tr>
2133
+ <tr><td>JPEG, PNG, GIF, BMP, TIFF, WebP</td><td>Resize + re-encode</td><td>JPEG</td><td><code>expo-image-manipulator</code></td></tr>
2134
+ <tr><td>Video, audio, PDF, ZIP, Office</td><td>Skip</td><td>—</td><td>—</td></tr>
2135
+ <tr><td>Text, JSON, CSV, XML, YAML</td><td>Skip on RN</td><td>—</td><td>No <code>CompressionStream</code> in RN JS engine</td></tr>
2136
+ </table>
2137
+
2138
+ <h3>Tune or disable</h3>
2139
+ <pre><code><span class="cm">// Tune</span>
2140
+ &lt;AntzChatNavigator config={{ ..., <span class="at">compression</span>: { <span class="at">imageQuality</span>: <span class="num">0.75</span>, <span class="at">imageMaxDimension</span>: <span class="num">1280</span> } }} /&gt;
2141
+
2142
+ <span class="cm">// Disable entirely</span>
2143
+ &lt;AntzChatNavigator config={{ ..., <span class="at">compression</span>: { <span class="at">enabled</span>: <span class="kw">false</span> } }} /&gt;</code></pre>
2144
+ </div>
2145
+
2146
+ <!-- ── WEB ── -->
2147
+ <div data-p="web">
2148
+ <div class="callout tip"><strong>Zero config on Web</strong> <code>webCompressFn</code> is pre-wired automatically in <code>AntzChatProvider</code> / <code>AntzChat</code>. No extra packages — uses browser-native <code>canvas</code> and <code>CompressionStream</code>.</div>
2149
+
2150
+ <h3>Default behavior</h3>
2151
+ <pre><code><span class="cm">// AntzChat / AntzChatProvider — compression on by default, nothing to add</span>
2152
+ &lt;AntzChat config={{ <span class="at">apiUrl</span>: <span class="str">'...'</span>, <span class="at">authToken</span>: <span class="str">'...'</span> }} /&gt;
2153
+
2154
+ <span class="cm">// AntzChatClient (headless) — pass webCompressFn explicitly</span>
2155
+ <span class="kw">import</span> { AntzChatClient } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
2156
+ <span class="kw">import</span> { webUploadFn, webPersistStorage, webCompressFn } <span class="kw">from</span> <span class="str">'@antzsoft/chat-web-sdk'</span>;
2157
+
2158
+ <span class="kw">const</span> client = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
2159
+ <span class="at">apiUrl</span>: <span class="str">'https://api.example.com/api/v1'</span>,
2160
+ <span class="at">authToken</span>: token,
2161
+ <span class="at">platformUploadFn</span>: webUploadFn,
2162
+ <span class="at">persistStorage</span>: webPersistStorage,
2163
+ <span class="at">platformCompressFn</span>: webCompressFn, <span class="cm">// add this for headless usage</span>
2164
+ });</code></pre>
2165
+
2166
+ <h3>Strategy by file type</h3>
2167
+ <table>
2168
+ <tr><th>File type</th><th>Strategy</th><th>Algorithm</th><th>API used</th></tr>
2169
+ <tr><td>JPEG, PNG, GIF, BMP, TIFF, WebP</td><td>Resize + re-encode</td><td>WebP</td><td><code>canvas.toBlob('image/webp')</code></td></tr>
2170
+ <tr><td>SVG, JSON, CSV, plain text, XML, YAML, RTF, Markdown</td><td>Compress bytes</td><td>gzip</td><td><code>CompressionStream('gzip')</code></td></tr>
2171
+ <tr><td>Video, audio, PDF, ZIP, Office</td><td>Skip</td><td>—</td><td>Already compressed</td></tr>
2172
+ </table>
2173
+
2174
+ <h3>Tune or disable</h3>
2175
+ <pre><code><span class="cm">// Tune</span>
2176
+ &lt;AntzChat config={{ ..., <span class="at">compression</span>: { <span class="at">imageQuality</span>: <span class="num">0.75</span>, <span class="at">imageMaxDimension</span>: <span class="num">1280</span>, <span class="at">compressDocuments</span>: <span class="kw">false</span> } }} /&gt;
2177
+
2178
+ <span class="cm">// Disable entirely</span>
2179
+ &lt;AntzChat config={{ ..., <span class="at">compression</span>: { <span class="at">enabled</span>: <span class="kw">false</span> } }} /&gt;</code></pre>
2180
+ </div>
2181
+
2182
+ <!-- ── NODE.JS ── -->
2183
+ <div data-p="node">
2184
+ <div class="callout info"><strong>Node.js</strong> No compressor is provided by default. Supply your own using <code>sharp</code> (images) and Node's built-in <code>zlib</code> (text/docs). Omit <code>platformCompressFn</code> to skip compression entirely.</div>
2185
+
2186
+ <h3>Install optional dep</h3>
2187
+ <pre><code>npm install sharp <span class="cm"># optional — only needed for image compression</span></code></pre>
2188
+
2189
+ <h3>Implementation</h3>
2190
+ <pre><code><span class="kw">import</span> sharp <span class="kw">from</span> <span class="str">'sharp'</span>;
2191
+ <span class="kw">import</span> zlib <span class="kw">from</span> <span class="str">'zlib'</span>;
2192
+ <span class="kw">import</span> { promisify } <span class="kw">from</span> <span class="str">'util'</span>;
2193
+ <span class="kw">import</span> { writeFile, readFile } <span class="kw">from</span> <span class="str">'fs/promises'</span>;
2194
+ <span class="kw">import</span> { join } <span class="kw">from</span> <span class="str">'path'</span>;
2195
+ <span class="kw">import</span> { tmpdir } <span class="kw">from</span> <span class="str">'os'</span>;
2196
+ <span class="kw">import</span> { <span class="tp">PlatformCompressFn</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
2197
+ <span class="kw">import</span> { getCompressionStrategy } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
2198
+
2199
+ <span class="kw">const</span> gzip = <span class="fn">promisify</span>(zlib.gzip);
2200
+
2201
+ <span class="kw">export const</span> nodeCompressFn: <span class="tp">PlatformCompressFn</span> = <span class="kw">async</span> (file, options) => {
2202
+ <span class="kw">const</span> noop = { ...file, <span class="at">originalSize</span>: file.size, <span class="at">compressed</span>: <span class="kw">false</span>, <span class="at">compressionAlgorithm</span>: <span class="str">'none'</span> <span class="kw">as const</span> };
2203
+ <span class="kw">const</span> strategy = <span class="fn">getCompressionStrategy</span>(file.type, options);
2204
+
2205
+ <span class="kw">if</span> (strategy === <span class="str">'image'</span>) {
2206
+ <span class="kw">const</span> buf = <span class="kw">await</span> sharp(file.uri)
2207
+ .<span class="fn">resize</span>({ <span class="at">width</span>: options.imageMaxDimension, <span class="at">height</span>: options.imageMaxDimension, <span class="at">fit</span>: <span class="str">'inside'</span>, <span class="at">withoutEnlargement</span>: <span class="kw">true</span> })
2208
+ .<span class="fn">jpeg</span>({ <span class="at">quality</span>: <span class="fn">Math.round</span>(options.imageQuality * <span class="num">100</span>) })
2209
+ .<span class="fn">toBuffer</span>();
2210
+ <span class="kw">if</span> (buf.length >= file.size) <span class="kw">return</span> noop;
2211
+ <span class="kw">const</span> tmp = <span class="fn">join</span>(<span class="fn">tmpdir</span>(), <span class="str">`</span><span class="num">${Date.now()}</span><span class="str">.jpg`</span>);
2212
+ <span class="kw">await</span> <span class="fn">writeFile</span>(tmp, buf);
2213
+ <span class="kw">return</span> { <span class="at">uri</span>: tmp, <span class="at">name</span>: file.name.<span class="fn">replace</span>(<span class="str">/\.[^.]+$/</span>, <span class="str">'.jpg'</span>), <span class="at">type</span>: <span class="str">'image/jpeg'</span>, <span class="at">size</span>: buf.length, <span class="at">originalSize</span>: file.size, <span class="at">compressed</span>: <span class="kw">true</span>, <span class="at">compressionAlgorithm</span>: <span class="str">'jpeg'</span> <span class="kw">as const</span> };
2214
+ }
2215
+
2216
+ <span class="kw">if</span> (strategy === <span class="str">'gzip'</span>) {
2217
+ <span class="kw">const</span> input = <span class="kw">await</span> <span class="fn">readFile</span>(file.uri);
2218
+ <span class="kw">const</span> compressed = <span class="kw">await</span> <span class="fn">gzip</span>(input);
2219
+ <span class="kw">if</span> (compressed.length >= file.size) <span class="kw">return</span> noop;
2220
+ <span class="kw">const</span> tmp = <span class="fn">join</span>(<span class="fn">tmpdir</span>(), <span class="str">`</span><span class="num">${Date.now()}</span><span class="str">.gz`</span>);
2221
+ <span class="kw">await</span> <span class="fn">writeFile</span>(tmp, compressed);
2222
+ <span class="kw">return</span> { <span class="at">uri</span>: tmp, <span class="at">name</span>: file.name + <span class="str">'.gz'</span>, <span class="at">type</span>: file.type, <span class="at">size</span>: compressed.length, <span class="at">originalSize</span>: file.size, <span class="at">compressed</span>: <span class="kw">true</span>, <span class="at">compressionAlgorithm</span>: <span class="str">'gzip'</span> <span class="kw">as const</span> };
2223
+ }
2224
+
2225
+ <span class="kw">return</span> noop;
2226
+ };</code></pre>
2227
+
2228
+ <h3>Wire it in</h3>
2229
+ <pre><code><span class="kw">const</span> client = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
2230
+ <span class="at">apiUrl</span>: <span class="str">'https://api.example.com/api/v1'</span>,
2231
+ <span class="at">authToken</span>: process.env.<span class="at">AUTH_TOKEN</span>,
2232
+ <span class="at">platformUploadFn</span>: myNodeUploadFn,
2233
+ <span class="at">persistStorage</span>: myStorage,
2234
+ <span class="at">platformCompressFn</span>: nodeCompressFn,
2235
+ <span class="at">compression</span>: { <span class="at">imageQuality</span>: <span class="num">0.85</span>, <span class="at">imageMaxDimension</span>: <span class="num">1920</span> },
2236
+ });</code></pre>
2237
+ </div>
2238
+
2239
+ <!-- ── SHARED: config reference ── -->
2240
+ <h3>CompressionConfig reference</h3>
2241
+ <table>
2242
+ <tr><th>Field</th><th>Type</th><th>Default</th><th>Description</th></tr>
2243
+ <tr><td><code>enabled</code></td><td><code>boolean</code></td><td><code>true</code> when <code>platformCompressFn</code> provided</td><td>Master switch</td></tr>
2244
+ <tr><td><code>imageQuality</code></td><td><code>number</code> (0–1)</td><td><code>0.85</code></td><td>Encode quality for images</td></tr>
2245
+ <tr><td><code>imageMaxDimension</code></td><td><code>number</code></td><td><code>1920</code></td><td>Longest side cap in px before encoding</td></tr>
2246
+ <tr><td><code>compressDocuments</code></td><td><code>boolean</code></td><td><code>true</code></td><td>gzip text/JSON/CSV/XML/YAML/SVG/RTF (web only)</td></tr>
2247
+ </table>
2248
+
2249
+ <div class="callout info"><strong>Automatic fallback</strong> If the compressed result is larger than the original, the SDK uploads the original automatically — no extra code needed.</div>
2250
+ </section>
2251
+
1883
2252
  <!-- ─── STEP 17: PUSH (RN + WEB only) ────────────────────────────────── -->
1884
2253
  <section id="step-push" data-p="rn web">
1885
2254
  <h2><span class="step">STEP 17</span> Push Notifications</h2>
@@ -2629,6 +2998,9 @@ console.<span class="fn">log</span>(<span class="str">`${summary.totalUnread} un
2629
2998
 
2630
2999
  </main>
2631
3000
 
3001
+ <!-- ── Go-to-top FAB ── -->
3002
+ <button id="gototop" onclick="window.scrollTo({top:0,behavior:'smooth'})" title="Back to top">↑</button>
3003
+
2632
3004
  <script>
2633
3005
  // ── State ────────────────────────────────────────────────────────────────
2634
3006
  let _platform = 'rn';
@@ -2715,10 +3087,87 @@ function toggleItem(id) {
2715
3087
  document.getElementById(id).classList.toggle('open');
2716
3088
  }
2717
3089
 
2718
- // ── Progress bar ──────────────────────────────────────────────────────────
3090
+ // ── Progress bar + go-to-top FAB ─────────────────────────────────────────
3091
+ const _fab = document.getElementById('gototop');
2719
3092
  window.addEventListener('scroll', () => {
2720
- const el = document.getElementById('prog');
2721
- el.style.width = (window.scrollY / (document.body.scrollHeight - window.innerHeight) * 100) + '%';
3093
+ const ratio = window.scrollY / (document.body.scrollHeight - window.innerHeight);
3094
+ document.getElementById('prog').style.width = (ratio * 100) + '%';
3095
+ _fab.classList.toggle('show', window.scrollY > 300);
3096
+ });
3097
+
3098
+ // ── Section accordion ─────────────────────────────────────────────────────
3099
+ // Make every <section> (except #whats-new which has its own accordion) collapsible.
3100
+ // Wrap content below h2 in a .sec-body div and add a chevron to h2.
3101
+ document.querySelectorAll('section[id]').forEach(sec => {
3102
+ if (sec.id === 'whats-new') return; // already has its own accordion
3103
+ sec.classList.add('sec');
3104
+
3105
+ const h2 = sec.querySelector(':scope > h2');
3106
+ if (!h2) return;
3107
+
3108
+ // Add chevron to h2
3109
+ const chev = document.createElement('span');
3110
+ chev.className = 'sec-chevron';
3111
+ chev.textContent = '▲';
3112
+ h2.appendChild(chev);
3113
+
3114
+ // Wrap all siblings after h2 into .sec-body
3115
+ const body = document.createElement('div');
3116
+ body.className = 'sec-body';
3117
+ const siblings = [];
3118
+ let node = h2.nextSibling;
3119
+ while (node) { siblings.push(node); node = node.nextSibling; }
3120
+ siblings.forEach(n => body.appendChild(n));
3121
+ sec.appendChild(body);
3122
+
3123
+ // Toggle on h2 click
3124
+ h2.addEventListener('click', () => sec.classList.toggle('collapsed'));
3125
+ });
3126
+
3127
+ // ── H3 sub-accordion ─────────────────────────────────────────────────────
3128
+ // Wrap each h3 + its following siblings (until next h3/h2/section end) into
3129
+ // a collapsible .sub-acc block. Only applied inside .sec-body.
3130
+ document.querySelectorAll('.sec-body').forEach(body => {
3131
+ const h3s = Array.from(body.querySelectorAll(':scope > h3'));
3132
+ h3s.forEach(h3 => {
3133
+ // Collect nodes that belong to this h3 (until next h3 or end)
3134
+ const nodes = [];
3135
+ let n = h3.nextSibling;
3136
+ while (n && !(n.nodeType === 1 && (n.tagName === 'H3' || n.tagName === 'H2'))) {
3137
+ nodes.push(n);
3138
+ n = n.nextSibling;
3139
+ }
3140
+ if (!nodes.length) return; // h3 with no content — skip
3141
+
3142
+ // Build accordion shell
3143
+ const acc = document.createElement('div');
3144
+ acc.className = 'sub-acc';
3145
+
3146
+ const hdr = document.createElement('div');
3147
+ hdr.className = 'sub-acc-hdr';
3148
+
3149
+ const title = document.createElement('h3');
3150
+ title.innerHTML = h3.innerHTML;
3151
+
3152
+ const chev = document.createElement('span');
3153
+ chev.className = 'sub-chev';
3154
+ chev.textContent = '▲';
3155
+
3156
+ hdr.appendChild(title);
3157
+ hdr.appendChild(chev);
3158
+
3159
+ const accBody = document.createElement('div');
3160
+ accBody.className = 'sub-acc-body';
3161
+ nodes.forEach(nd => accBody.appendChild(nd));
3162
+
3163
+ acc.appendChild(hdr);
3164
+ acc.appendChild(accBody);
3165
+
3166
+ hdr.addEventListener('click', () => acc.classList.toggle('closed'));
3167
+
3168
+ // Replace original h3 with the accordion
3169
+ h3.replaceWith(acc);
3170
+ });
2722
3171
  });
2723
3172
 
2724
3173
  // ── Sidebar active link ───────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antzsoft/chat-core",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Platform-agnostic core for Antz Chat — API, socket, stores, types. Works in browser, React Native (Expo or bare), and Node.js.",
5
5
  "author": "Antz",
6
6
  "license": "MIT",