@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.
@@ -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.8 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,114 @@ 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.8 (current) ── -->
246
+ <div class="wn-version open" id="wn-108">
247
+ <div class="wn-header" onclick="toggleVersion('wn-108')">
248
+ <div class="wn-title">
249
+ <span class="wn-ver">v1.0.8</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-108-1">
258
+ <div class="wn-item-header" onclick="toggleItem('wn-108-1')">
259
+ <span class="wn-tag new">New</span>
260
+ <span class="wn-item-title">File compression — client-side, before upload</span>
261
+ <span class="wn-chevron-sm">▾</span>
262
+ </div>
263
+ <div class="wn-item-body">
264
+ <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>
265
+ <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>
266
+ <table>
267
+ <tr><th>Platform</th><th>Images</th><th>Text docs</th><th>Default state</th></tr>
268
+ <tr><td>Web</td><td>WebP via <code>canvas</code></td><td>gzip via <code>CompressionStream</code></td><td>On (no deps)</td></tr>
269
+ <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>
270
+ <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>
271
+ </table>
272
+ <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>
273
+ <p class="wn-ref">→ <a href="#step-compress">Step 16.5 — File Compression</a></p>
274
+ </div>
275
+ </div>
276
+
277
+ <div class="wn-item" id="wn-108-2">
278
+ <div class="wn-item-header" onclick="toggleItem('wn-108-2')">
279
+ <span class="wn-tag fix">Fix</span>
280
+ <span class="wn-item-title">Removed members keep read-only access to conversation</span>
281
+ <span class="wn-chevron-sm">▾</span>
282
+ </div>
283
+ <div class="wn-item-body">
284
+ <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>
285
+ <p>Now, removed members:</p>
286
+ <ul>
287
+ <li>Still see the conversation in their list</li>
288
+ <li>Can read message history up to the point of removal</li>
289
+ <li>Are kicked from the socket room immediately — they receive no new real-time events</li>
290
+ <li>Cannot send, react, type, edit, or delete — <code>validateWriteAccess</code> blocks all write operations</li>
291
+ </ul>
292
+ <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>
293
+ <p><strong>Action required:</strong> None. Behavior change is entirely server-side. SDK types and API calls are unchanged.</p>
294
+ </div>
295
+ </div>
296
+
297
+ <div class="wn-item" id="wn-108-3">
298
+ <div class="wn-item-header" onclick="toggleItem('wn-108-3')">
299
+ <span class="wn-tag fix">Fix</span>
300
+ <span class="wn-item-title">Admin delete is now hide-only — other participants unaffected</span>
301
+ <span class="wn-chevron-sm">▾</span>
302
+ </div>
303
+ <div class="wn-item-body">
304
+ <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>
305
+ <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>
306
+ <p><strong>Action required:</strong> None. Server-side change only.</p>
307
+ </div>
308
+ </div>
309
+
310
+ <div class="wn-item" id="wn-108-4">
311
+ <div class="wn-item-header" onclick="toggleItem('wn-108-4')">
312
+ <span class="wn-tag fix">Fix</span>
313
+ <span class="wn-item-title"><code>lastMessage.status</code> stuck as <code>deleted</code> after new messages</span>
314
+ <span class="wn-chevron-sm">▾</span>
315
+ </div>
316
+ <div class="wn-item-body">
317
+ <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>
318
+ <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>
319
+ <p><strong>Action required:</strong> None. Server-side fix only.</p>
320
+ </div>
321
+ </div>
322
+
323
+ <div class="wn-item" id="wn-108-5">
324
+ <div class="wn-item-header" onclick="toggleItem('wn-108-5')">
325
+ <span class="wn-tag new">New</span>
326
+ <span class="wn-item-title"><code>duration</code> field in <code>SendMessageAttachment</code> for audio/video</span>
327
+ <span class="wn-chevron-sm">▾</span>
328
+ </div>
329
+ <div class="wn-item-body">
330
+ <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>
331
+ <pre><code><span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({
332
+ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(),
333
+ <span class="at">attachments</span>: [{
334
+ <span class="at">fileId</span>: f.id, <span class="at">type</span>: <span class="str">'audio'</span>, <span class="at">url</span>: f.url,
335
+ <span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size,
336
+ <span class="at">duration</span>: recordingSeconds, <span class="cm">// ← pass this</span>
337
+ }],
338
+ });</code></pre>
339
+ <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>
340
+ <p><strong>Action required (Web):</strong> None — recommended to pass for better UX (shows duration before file loads) but not required.</p>
341
+ <p class="wn-ref">→ <a href="#step-send">Step 9 — Sending Messages</a></p>
342
+ </div>
343
+ </div>
344
+
345
+ </div>
346
+ </div>
347
+
348
+ <!-- ── v1.0.7 ── -->
222
349
  <div class="wn-version open" id="wn-107">
223
350
  <div class="wn-header" onclick="toggleVersion('wn-107')">
224
351
  <div class="wn-title">
225
352
  <span class="wn-ver">v1.0.7</span>
226
- <span class="wn-badge current">Current</span>
227
353
  <span class="wn-date">May 2026</span>
228
354
  </div>
229
355
  <span class="wn-chevron">▲</span>
@@ -1403,7 +1529,13 @@ client.socket.<span class="fn">on</span>(<span class="str">'conversation_created
1403
1529
  <span class="kw">const</span> attachments = successful.<span class="fn">map</span>(f => ({
1404
1530
  <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
1531
  }));
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>
1532
+ <span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(), attachments });
1533
+
1534
+ <span class="cm">// Audio/video — always pass duration (seconds) so receivers can render a player UI</span>
1535
+ <span class="kw">await</span> socketEmit.<span class="fn">sendMessage</span>({
1536
+ conversationId, <span class="at">tempId</span>: <span class="fn">uuid</span>(),
1537
+ <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 }],
1538
+ });</code></pre>
1407
1539
  </section>
1408
1540
 
1409
1541
  <!-- ─── STEP 10: REAL-TIME ─────────────────────────────────────────────── -->
@@ -1864,6 +1996,8 @@ socket?.<span class="fn">on</span>(<span class="str">'read_receipt'</span>, ({ c
1864
1996
  <pre><code><span class="kw">const</span> attachments = successful.<span class="fn">map</span>(f => ({
1865
1997
  <span class="at">fileId</span>: f.id, <span class="at">type</span>: f.type, <span class="at">url</span>: f.url,
1866
1998
  <span class="at">filename</span>: f.filename, <span class="at">mimeType</span>: f.mimeType, <span class="at">size</span>: f.size,
1999
+ <span class="cm">// For audio/video include duration (seconds) — required for receiver player UI</span>
2000
+ ...(f.type === <span class="str">'audio'</span> || f.type === <span class="str">'video'</span> ? { <span class="at">duration</span>: knownDurationSeconds } : {}),
1867
2001
  }));
1868
2002
  <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
2003
 
@@ -1880,6 +2014,156 @@ socket?.<span class="fn">on</span>(<span class="str">'read_receipt'</span>, ({ c
1880
2014
  });</code></pre>
1881
2015
  </section>
1882
2016
 
2017
+ <!-- ─── STEP 16.5: COMPRESSION ───────────────────────────────────────── -->
2018
+ <section id="step-compress">
2019
+ <h2><span class="step">STEP 16.5</span> File Compression</h2>
2020
+ <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>
2021
+
2022
+ <!-- ── REACT NATIVE ── -->
2023
+ <div data-p="rn">
2024
+ <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>
2025
+
2026
+ <pre><code>npx expo install expo-image-manipulator</code></pre>
2027
+
2028
+ <p>Without <code>expo-image-manipulator</code> the SDK skips compression silently — files upload as-is.</p>
2029
+
2030
+ <h3>Default behavior</h3>
2031
+ <pre><code><span class="cm">// AntzChatNavigator / AntzChatProvider — compression on by default, nothing to add</span>
2032
+ &lt;AntzChatNavigator config={{ <span class="at">apiUrl</span>: <span class="str">'...'</span> }} /&gt;
2033
+
2034
+ <span class="cm">// AntzChatClient (headless) — pass rnCompressFn explicitly</span>
2035
+ <span class="kw">import</span> { AntzChatClient, rnUploadFn, rnPersistStorage, rnCompressFn } <span class="kw">from</span> <span class="str">'@antzsoft/chat-rn-sdk'</span>;
2036
+
2037
+ <span class="kw">const</span> client = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
2038
+ <span class="at">apiUrl</span>: <span class="str">'https://api.example.com/api/v1'</span>,
2039
+ <span class="at">authToken</span>: token,
2040
+ <span class="at">platformUploadFn</span>: rnUploadFn,
2041
+ <span class="at">persistStorage</span>: rnPersistStorage,
2042
+ <span class="at">platformCompressFn</span>: rnCompressFn, <span class="cm">// add this for headless usage</span>
2043
+ });</code></pre>
2044
+
2045
+ <h3>Strategy by file type</h3>
2046
+ <table>
2047
+ <tr><th>File type</th><th>Strategy</th><th>Algorithm</th><th>Requires</th></tr>
2048
+ <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>
2049
+ <tr><td>Video, audio, PDF, ZIP, Office</td><td>Skip</td><td>—</td><td>—</td></tr>
2050
+ <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>
2051
+ </table>
2052
+
2053
+ <h3>Tune or disable</h3>
2054
+ <pre><code><span class="cm">// Tune</span>
2055
+ &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;
2056
+
2057
+ <span class="cm">// Disable entirely</span>
2058
+ &lt;AntzChatNavigator config={{ ..., <span class="at">compression</span>: { <span class="at">enabled</span>: <span class="kw">false</span> } }} /&gt;</code></pre>
2059
+ </div>
2060
+
2061
+ <!-- ── WEB ── -->
2062
+ <div data-p="web">
2063
+ <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>
2064
+
2065
+ <h3>Default behavior</h3>
2066
+ <pre><code><span class="cm">// AntzChat / AntzChatProvider — compression on by default, nothing to add</span>
2067
+ &lt;AntzChat config={{ <span class="at">apiUrl</span>: <span class="str">'...'</span>, <span class="at">authToken</span>: <span class="str">'...'</span> }} /&gt;
2068
+
2069
+ <span class="cm">// AntzChatClient (headless) — pass webCompressFn explicitly</span>
2070
+ <span class="kw">import</span> { AntzChatClient } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
2071
+ <span class="kw">import</span> { webUploadFn, webPersistStorage, webCompressFn } <span class="kw">from</span> <span class="str">'@antzsoft/chat-web-sdk'</span>;
2072
+
2073
+ <span class="kw">const</span> client = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
2074
+ <span class="at">apiUrl</span>: <span class="str">'https://api.example.com/api/v1'</span>,
2075
+ <span class="at">authToken</span>: token,
2076
+ <span class="at">platformUploadFn</span>: webUploadFn,
2077
+ <span class="at">persistStorage</span>: webPersistStorage,
2078
+ <span class="at">platformCompressFn</span>: webCompressFn, <span class="cm">// add this for headless usage</span>
2079
+ });</code></pre>
2080
+
2081
+ <h3>Strategy by file type</h3>
2082
+ <table>
2083
+ <tr><th>File type</th><th>Strategy</th><th>Algorithm</th><th>API used</th></tr>
2084
+ <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>
2085
+ <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>
2086
+ <tr><td>Video, audio, PDF, ZIP, Office</td><td>Skip</td><td>—</td><td>Already compressed</td></tr>
2087
+ </table>
2088
+
2089
+ <h3>Tune or disable</h3>
2090
+ <pre><code><span class="cm">// Tune</span>
2091
+ &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;
2092
+
2093
+ <span class="cm">// Disable entirely</span>
2094
+ &lt;AntzChat config={{ ..., <span class="at">compression</span>: { <span class="at">enabled</span>: <span class="kw">false</span> } }} /&gt;</code></pre>
2095
+ </div>
2096
+
2097
+ <!-- ── NODE.JS ── -->
2098
+ <div data-p="node">
2099
+ <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>
2100
+
2101
+ <h3>Install optional dep</h3>
2102
+ <pre><code>npm install sharp <span class="cm"># optional — only needed for image compression</span></code></pre>
2103
+
2104
+ <h3>Implementation</h3>
2105
+ <pre><code><span class="kw">import</span> sharp <span class="kw">from</span> <span class="str">'sharp'</span>;
2106
+ <span class="kw">import</span> zlib <span class="kw">from</span> <span class="str">'zlib'</span>;
2107
+ <span class="kw">import</span> { promisify } <span class="kw">from</span> <span class="str">'util'</span>;
2108
+ <span class="kw">import</span> { writeFile, readFile } <span class="kw">from</span> <span class="str">'fs/promises'</span>;
2109
+ <span class="kw">import</span> { join } <span class="kw">from</span> <span class="str">'path'</span>;
2110
+ <span class="kw">import</span> { tmpdir } <span class="kw">from</span> <span class="str">'os'</span>;
2111
+ <span class="kw">import</span> { <span class="tp">PlatformCompressFn</span> } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
2112
+ <span class="kw">import</span> { getCompressionStrategy } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
2113
+
2114
+ <span class="kw">const</span> gzip = <span class="fn">promisify</span>(zlib.gzip);
2115
+
2116
+ <span class="kw">export const</span> nodeCompressFn: <span class="tp">PlatformCompressFn</span> = <span class="kw">async</span> (file, options) => {
2117
+ <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> };
2118
+ <span class="kw">const</span> strategy = <span class="fn">getCompressionStrategy</span>(file.type, options);
2119
+
2120
+ <span class="kw">if</span> (strategy === <span class="str">'image'</span>) {
2121
+ <span class="kw">const</span> buf = <span class="kw">await</span> sharp(file.uri)
2122
+ .<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> })
2123
+ .<span class="fn">jpeg</span>({ <span class="at">quality</span>: <span class="fn">Math.round</span>(options.imageQuality * <span class="num">100</span>) })
2124
+ .<span class="fn">toBuffer</span>();
2125
+ <span class="kw">if</span> (buf.length >= file.size) <span class="kw">return</span> noop;
2126
+ <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>);
2127
+ <span class="kw">await</span> <span class="fn">writeFile</span>(tmp, buf);
2128
+ <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> };
2129
+ }
2130
+
2131
+ <span class="kw">if</span> (strategy === <span class="str">'gzip'</span>) {
2132
+ <span class="kw">const</span> input = <span class="kw">await</span> <span class="fn">readFile</span>(file.uri);
2133
+ <span class="kw">const</span> compressed = <span class="kw">await</span> <span class="fn">gzip</span>(input);
2134
+ <span class="kw">if</span> (compressed.length >= file.size) <span class="kw">return</span> noop;
2135
+ <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>);
2136
+ <span class="kw">await</span> <span class="fn">writeFile</span>(tmp, compressed);
2137
+ <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> };
2138
+ }
2139
+
2140
+ <span class="kw">return</span> noop;
2141
+ };</code></pre>
2142
+
2143
+ <h3>Wire it in</h3>
2144
+ <pre><code><span class="kw">const</span> client = <span class="kw">new</span> <span class="fn">AntzChatClient</span>({
2145
+ <span class="at">apiUrl</span>: <span class="str">'https://api.example.com/api/v1'</span>,
2146
+ <span class="at">authToken</span>: process.env.<span class="at">AUTH_TOKEN</span>,
2147
+ <span class="at">platformUploadFn</span>: myNodeUploadFn,
2148
+ <span class="at">persistStorage</span>: myStorage,
2149
+ <span class="at">platformCompressFn</span>: nodeCompressFn,
2150
+ <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> },
2151
+ });</code></pre>
2152
+ </div>
2153
+
2154
+ <!-- ── SHARED: config reference ── -->
2155
+ <h3>CompressionConfig reference</h3>
2156
+ <table>
2157
+ <tr><th>Field</th><th>Type</th><th>Default</th><th>Description</th></tr>
2158
+ <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>
2159
+ <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>
2160
+ <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>
2161
+ <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>
2162
+ </table>
2163
+
2164
+ <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>
2165
+ </section>
2166
+
1883
2167
  <!-- ─── STEP 17: PUSH (RN + WEB only) ────────────────────────────────── -->
1884
2168
  <section id="step-push" data-p="rn web">
1885
2169
  <h2><span class="step">STEP 17</span> Push Notifications</h2>
@@ -2629,6 +2913,9 @@ console.<span class="fn">log</span>(<span class="str">`${summary.totalUnread} un
2629
2913
 
2630
2914
  </main>
2631
2915
 
2916
+ <!-- ── Go-to-top FAB ── -->
2917
+ <button id="gototop" onclick="window.scrollTo({top:0,behavior:'smooth'})" title="Back to top">↑</button>
2918
+
2632
2919
  <script>
2633
2920
  // ── State ────────────────────────────────────────────────────────────────
2634
2921
  let _platform = 'rn';
@@ -2715,10 +3002,87 @@ function toggleItem(id) {
2715
3002
  document.getElementById(id).classList.toggle('open');
2716
3003
  }
2717
3004
 
2718
- // ── Progress bar ──────────────────────────────────────────────────────────
3005
+ // ── Progress bar + go-to-top FAB ─────────────────────────────────────────
3006
+ const _fab = document.getElementById('gototop');
2719
3007
  window.addEventListener('scroll', () => {
2720
- const el = document.getElementById('prog');
2721
- el.style.width = (window.scrollY / (document.body.scrollHeight - window.innerHeight) * 100) + '%';
3008
+ const ratio = window.scrollY / (document.body.scrollHeight - window.innerHeight);
3009
+ document.getElementById('prog').style.width = (ratio * 100) + '%';
3010
+ _fab.classList.toggle('show', window.scrollY > 300);
3011
+ });
3012
+
3013
+ // ── Section accordion ─────────────────────────────────────────────────────
3014
+ // Make every <section> (except #whats-new which has its own accordion) collapsible.
3015
+ // Wrap content below h2 in a .sec-body div and add a chevron to h2.
3016
+ document.querySelectorAll('section[id]').forEach(sec => {
3017
+ if (sec.id === 'whats-new') return; // already has its own accordion
3018
+ sec.classList.add('sec');
3019
+
3020
+ const h2 = sec.querySelector(':scope > h2');
3021
+ if (!h2) return;
3022
+
3023
+ // Add chevron to h2
3024
+ const chev = document.createElement('span');
3025
+ chev.className = 'sec-chevron';
3026
+ chev.textContent = '▲';
3027
+ h2.appendChild(chev);
3028
+
3029
+ // Wrap all siblings after h2 into .sec-body
3030
+ const body = document.createElement('div');
3031
+ body.className = 'sec-body';
3032
+ const siblings = [];
3033
+ let node = h2.nextSibling;
3034
+ while (node) { siblings.push(node); node = node.nextSibling; }
3035
+ siblings.forEach(n => body.appendChild(n));
3036
+ sec.appendChild(body);
3037
+
3038
+ // Toggle on h2 click
3039
+ h2.addEventListener('click', () => sec.classList.toggle('collapsed'));
3040
+ });
3041
+
3042
+ // ── H3 sub-accordion ─────────────────────────────────────────────────────
3043
+ // Wrap each h3 + its following siblings (until next h3/h2/section end) into
3044
+ // a collapsible .sub-acc block. Only applied inside .sec-body.
3045
+ document.querySelectorAll('.sec-body').forEach(body => {
3046
+ const h3s = Array.from(body.querySelectorAll(':scope > h3'));
3047
+ h3s.forEach(h3 => {
3048
+ // Collect nodes that belong to this h3 (until next h3 or end)
3049
+ const nodes = [];
3050
+ let n = h3.nextSibling;
3051
+ while (n && !(n.nodeType === 1 && (n.tagName === 'H3' || n.tagName === 'H2'))) {
3052
+ nodes.push(n);
3053
+ n = n.nextSibling;
3054
+ }
3055
+ if (!nodes.length) return; // h3 with no content — skip
3056
+
3057
+ // Build accordion shell
3058
+ const acc = document.createElement('div');
3059
+ acc.className = 'sub-acc';
3060
+
3061
+ const hdr = document.createElement('div');
3062
+ hdr.className = 'sub-acc-hdr';
3063
+
3064
+ const title = document.createElement('h3');
3065
+ title.innerHTML = h3.innerHTML;
3066
+
3067
+ const chev = document.createElement('span');
3068
+ chev.className = 'sub-chev';
3069
+ chev.textContent = '▲';
3070
+
3071
+ hdr.appendChild(title);
3072
+ hdr.appendChild(chev);
3073
+
3074
+ const accBody = document.createElement('div');
3075
+ accBody.className = 'sub-acc-body';
3076
+ nodes.forEach(nd => accBody.appendChild(nd));
3077
+
3078
+ acc.appendChild(hdr);
3079
+ acc.appendChild(accBody);
3080
+
3081
+ hdr.addEventListener('click', () => acc.classList.toggle('closed'));
3082
+
3083
+ // Replace original h3 with the accordion
3084
+ h3.replaceWith(acc);
3085
+ });
2722
3086
  });
2723
3087
 
2724
3088
  // ── 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.8",
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",