@antzsoft/chat-core 1.0.3 → 1.0.5

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.
@@ -130,6 +130,11 @@ li{margin-bottom:4px;color:#c8d0e0}
130
130
  <p id="nav-platform-label">Integration Guide</p>
131
131
  </div>
132
132
 
133
+ <div class="section-label">What's New</div>
134
+ <ul>
135
+ <li><a href="#whats-new">v1.0.5 Release Notes</a></li>
136
+ </ul>
137
+
133
138
  <div class="section-label">Getting Started</div>
134
139
  <ul>
135
140
  <li><a href="#overview">Overview</a></li>
@@ -159,7 +164,7 @@ li{margin-bottom:4px;color:#c8d0e0}
159
164
 
160
165
  <div class="section-label">Messaging</div>
161
166
  <ul>
162
- <li><a href="#step-load">8. Load Messages</a></li>
167
+ <li><a href="#step-load">8. Load Messages &amp; Jump to Unread</a></li>
163
168
  <li><a href="#step-send">9. Send a Message</a></li>
164
169
  <li><a href="#step-realtime">10. Real-time Events</a></li>
165
170
  <li><a href="#step-typing">11. Typing Indicators</a></li>
@@ -208,6 +213,190 @@ li{margin-bottom:4px;color:#c8d0e0}
208
213
  </button>
209
214
  </div>
210
215
 
216
+ <!-- ─── WHAT'S NEW ────────────────────────────────────────────────────── -->
217
+ <section id="whats-new">
218
+ <h2>What's New</h2>
219
+ <p style="color:var(--muted);font-size:13px;margin-bottom:20px">Version history and release notes. Click a version to expand.</p>
220
+
221
+ <!-- ── v1.0.5 (current) ── -->
222
+ <div class="wn-version" id="wn-105">
223
+ <div class="wn-header" onclick="toggleVersion('wn-105')">
224
+ <div class="wn-title">
225
+ <span class="wn-ver">v1.0.5</span>
226
+ <span class="wn-badge current">Current</span>
227
+ <span class="wn-date">May 2026</span>
228
+ </div>
229
+ <span class="wn-chevron">▲</span>
230
+ </div>
231
+ <div class="wn-body">
232
+
233
+ <div class="wn-item" id="wn-105-1">
234
+ <div class="wn-item-header" onclick="toggleItem('wn-105-1')">
235
+ <span class="wn-tag new">New</span>
236
+ <span class="wn-item-title"><code>externalId</code> on all user responses</span>
237
+ <span class="wn-chevron-sm">▾</span>
238
+ </div>
239
+ <div class="wn-item-body">
240
+ <p>All API methods that return a user object now include an <code>externalId</code> field — this is the Antz User ID. Maps chat users directly back to your own system without a separate lookup.</p>
241
+ <p><strong>Usage:</strong> <code>user.externalId</code> is available on every <code>User</code> object — from <code>usersApi</code>, conversation participants, and message senders. No code change needed.</p>
242
+ <p class="wn-ref">→ <a href="#step-convs">Step 6 — Conversations</a> (participant objects) · Users API reference</p>
243
+ </div>
244
+ </div>
245
+
246
+ <div class="wn-item" id="wn-105-2">
247
+ <div class="wn-item-header" onclick="toggleItem('wn-105-2')">
248
+ <span class="wn-tag new">New</span>
249
+ <span class="wn-item-title">Single unified User List API</span>
250
+ <span class="wn-chevron-sm">▾</span>
251
+ </div>
252
+ <div class="wn-item-body">
253
+ <p>One <code>usersApi.list()</code> call now handles listing, searching, and pagination. Previous separate endpoints are consolidated.</p>
254
+ <pre><code><span class="fn">usersApi</span>.<span class="fn">list</span>() <span class="cm">// all users</span>
255
+ <span class="fn">usersApi</span>.<span class="fn">list</span>({ <span class="at">query</span>: <span class="str">'john'</span> }) <span class="cm">// search by name / username / email</span>
256
+ <span class="fn">usersApi</span>.<span class="fn">list</span>({ <span class="at">page</span>: <span class="num">1</span>, <span class="at">limit</span>: <span class="num">20</span> }) <span class="cm">// paginated</span></code></pre>
257
+ <p class="wn-ref">→ <a href="#step-convs">Step 6 — Conversations</a> (user list examples) · Users API reference</p>
258
+ </div>
259
+ </div>
260
+
261
+ <div class="wn-item" id="wn-105-3">
262
+ <div class="wn-item-header" onclick="toggleItem('wn-105-3')">
263
+ <span class="wn-tag new">New</span>
264
+ <span class="wn-item-title">Single unified Conversation List API</span>
265
+ <span class="wn-chevron-sm">▾</span>
266
+ </div>
267
+ <div class="wn-item-body">
268
+ <p><code>conversationsApi.list()</code> now handles listing, filtering, searching, and pagination in one call. All filters run server-side before pagination.</p>
269
+ <pre><code><span class="fn">conversationsApi</span>.<span class="fn">list</span>() <span class="cm">// all</span>
270
+ <span class="fn">conversationsApi</span>.<span class="fn">list</span>({ <span class="at">type</span>: <span class="str">'group'</span>, <span class="at">hasUnread</span>: <span class="kw">true</span> }) <span class="cm">// filter</span>
271
+ <span class="fn">conversationsApi</span>.<span class="fn">list</span>({ <span class="at">search</span>: <span class="str">'design'</span>, <span class="at">page</span>: <span class="num">1</span>, <span class="at">limit</span>: <span class="num">20</span> }) <span class="cm">// search + page</span></code></pre>
272
+ <p class="wn-ref">→ <a href="#step-convs">Step 6 — Conversations</a> for the full filter reference</p>
273
+ </div>
274
+ </div>
275
+
276
+ <div class="wn-item" id="wn-105-4">
277
+ <div class="wn-item-header" onclick="toggleItem('wn-105-4')">
278
+ <span class="wn-tag fix">Fix</span>
279
+ <span class="wn-item-title">Attachment last-message content preview</span>
280
+ <span class="wn-chevron-sm">▾</span>
281
+ </div>
282
+ <div class="wn-item-body">
283
+ <p>When the last message in a conversation was an attachment, the conversation list showed no content preview. Fixed — the server now returns a formatted preview string (e.g. <em>"📎 photo.jpg"</em>).</p>
284
+ <p><strong>Action required:</strong> Display <code>conversation.lastMessage.content.text</code> directly. Remove any workarounds that were falling back to a placeholder for attachment messages.</p>
285
+ <p class="wn-ref">→ <a href="#step-convs">Step 6</a> (conversation object) · <a href="#step-realtime">Step 10 — Real-time Events</a> (<code>conversation_updated</code>)</p>
286
+ </div>
287
+ </div>
288
+
289
+ <div class="wn-item" id="wn-105-5">
290
+ <div class="wn-item-header" onclick="toggleItem('wn-105-5')">
291
+ <span class="wn-tag fix">Fix</span>
292
+ <span class="wn-item-title">Avatar upload via <code>AntzChatClient</code></span>
293
+ <span class="wn-chevron-sm">▾</span>
294
+ </div>
295
+ <div class="wn-item-body">
296
+ <p><code>client.auth.uploadAvatar()</code> and <code>client.auth.syncAvatar()</code> were not correctly storing the avatar in all storage configurations. Fixed.</p>
297
+ <p><strong>Action required:</strong> Remove any workarounds for failed avatar uploads. Both methods now work correctly.</p>
298
+ <p class="wn-ref">→ <a href="#step1">Step 1 — Avatar section</a> for usage of <code>uploadAvatar()</code> and <code>syncAvatar()</code></p>
299
+ </div>
300
+ </div>
301
+
302
+ <div class="wn-item" id="wn-105-6">
303
+ <div class="wn-item-header" onclick="toggleItem('wn-105-6')">
304
+ <span class="wn-tag fix">Fix</span>
305
+ <span class="wn-item-title"><code>client.connect()</code> on React Native</span>
306
+ <span class="wn-chevron-sm">▾</span>
307
+ </div>
308
+ <div class="wn-item-body">
309
+ <p><code>chatClient.connect()</code> now correctly initialises and establishes the Socket.IO connection on React Native (Expo and bare). Previously the socket could fail to connect silently in certain RN configurations.</p>
310
+ <p><strong>Action required:</strong> Verify <code>await chatClient.connect()</code> transitions <code>onSocketStatus</code> to <code>'connected'</code>. No API change — same call, now reliable.</p>
311
+ <p class="wn-ref">→ <a href="#step-socket">Step 3 — Connect Socket</a> for the full RN root provider setup</p>
312
+ </div>
313
+ </div>
314
+
315
+ <div class="wn-item" id="wn-105-7">
316
+ <div class="wn-item-header" onclick="toggleItem('wn-105-7')">
317
+ <span class="wn-tag new">New</span>
318
+ <span class="wn-item-title">Group icon — create &amp; update</span>
319
+ <span class="wn-chevron-sm">▾</span>
320
+ </div>
321
+ <div class="wn-item-body">
322
+ <p>Group conversations now support a custom icon. Admins can set or replace it at any time. The icon is stored server-side and a fresh signed URL is returned on every conversation response.</p>
323
+ <pre><code><span class="cm">// Upload icon — admin only</span>
324
+ <span class="kw">const</span> updated = <span class="kw">await</span> client.<span class="fn">uploadIcon</span>(groupId, {
325
+ <span class="at">uri</span>: file.uri, <span class="at">name</span>: <span class="str">'icon.jpg'</span>, <span class="at">type</span>: <span class="str">'image/jpeg'</span>, <span class="at">size</span>: file.size,
326
+ });
327
+ <span class="cm">// updated.iconUrl → fresh signed URL</span></code></pre>
328
+ <p>Non-admins receive <code>403 Forbidden</code>. The URL is never stored in the DB — regenerated from <code>iconMeta.storageKey</code> on every response. Replacing an icon automatically deletes the previous one.</p>
329
+ <p class="wn-ref">→ <a href="#step-convs">Step 6 — Conversations</a> (group icon section) for the full upload pipeline</p>
330
+ </div>
331
+ </div>
332
+
333
+ <div class="wn-item" id="wn-105-8">
334
+ <div class="wn-item-header" onclick="toggleItem('wn-105-8')">
335
+ <span class="wn-tag new">New</span>
336
+ <span class="wn-item-title">Scroll to first unread message</span>
337
+ <span class="wn-chevron-sm">▾</span>
338
+ </div>
339
+ <div class="wn-item-body">
340
+ <p>Open a conversation directly at the first unread message with an "↑ Unread messages" divider. Uses <code>getLastRead()</code> combined with <code>list()</code> <code>direction: 'after'</code>.</p>
341
+ <pre><code><span class="cm">// 1. Get last-read pointer</span>
342
+ <span class="kw">const</span> { lastReadMessageId, lastReadAt } = <span class="kw">await</span> messagesApi.<span class="fn">getLastRead</span>(conversationId);
343
+
344
+ <span class="cm">// 2. Fetch unread messages (everything after the last-read)</span>
345
+ <span class="kw">const</span> { data: unread, meta } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, {
346
+ <span class="at">cursor</span>: lastReadMessageId, <span class="at">direction</span>: <span class="str">'after'</span>, <span class="at">limit</span>: <span class="num">50</span>,
347
+ });
348
+
349
+ <span class="cm">// 3. Scroll to first unread</span>
350
+ <span class="kw">if</span> (unread.length) <span class="fn">scrollToMessage</span>(unread[<span class="num">0</span>].id);
351
+ <span class="cm">// meta.hasMore = true → more than 50 unread</span>
352
+
353
+ <span class="cm">// 4. Render divider above first unread message</span>
354
+ <span class="kw">const</span> isFirstUnread = lastRead && prevMessage?.id === lastRead.messageId;</code></pre>
355
+ <p class="wn-ref">→ <a href="#step-load">Step 8 — Jump to first unread</a> for full code · <a href="#step-read">Step 12 — Read Receipts &amp; Last Seen</a> for the complete last-read reference</p>
356
+ </div>
357
+ </div>
358
+
359
+ </div><!-- /.wn-body -->
360
+ </div><!-- /.wn-version -->
361
+
362
+ </section>
363
+
364
+ <style>
365
+ /* ── What's New ── */
366
+ .wn-version{border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
367
+ .wn-header{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;cursor:pointer;background:var(--surface);user-select:none}
368
+ .wn-header:hover{background:var(--surface2)}
369
+ .wn-title{display:flex;align-items:center;gap:10px}
370
+ .wn-ver{font-weight:800;font-size:15px;color:#fff}
371
+ .wn-badge{font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;letter-spacing:.4px}
372
+ .wn-badge.current{background:rgba(52,211,153,.15);color:var(--green);border:1px solid rgba(52,211,153,.3)}
373
+ .wn-date{font-size:12px;color:var(--muted)}
374
+ .wn-chevron{font-size:11px;color:var(--muted);transition:transform .2s}
375
+ .wn-version:not(.open) .wn-chevron{transform:rotate(180deg)}
376
+ .wn-body{display:none;padding:4px 0 8px}
377
+ .wn-version.open .wn-body{display:block}
378
+
379
+ .wn-item{border-top:1px solid var(--border)}
380
+ .wn-item-header{display:flex;align-items:center;gap:10px;padding:11px 18px;cursor:pointer;user-select:none}
381
+ .wn-item-header:hover{background:var(--surface2)}
382
+ .wn-item-title{flex:1;font-size:13.5px;color:var(--text)}
383
+ .wn-item-title code{font-family:var(--font-mono);font-size:12px;color:var(--accent2);background:rgba(167,139,250,.12);padding:1px 5px;border-radius:3px}
384
+ .wn-chevron-sm{font-size:10px;color:var(--muted);transition:transform .2s}
385
+ .wn-item.open .wn-chevron-sm{transform:rotate(180deg)}
386
+ .wn-item-body{display:none;padding:4px 18px 14px 18px}
387
+ .wn-item.open .wn-item-body{display:block}
388
+ .wn-item-body p{font-size:13px;color:#c8d0e0;margin-bottom:8px}
389
+ .wn-item-body pre{margin:8px 0 10px}
390
+ .wn-ref{font-size:12px !important;color:var(--muted) !important}
391
+ .wn-ref a{color:var(--accent);text-decoration:none}
392
+ .wn-ref a:hover{text-decoration:underline}
393
+
394
+ .wn-tag{font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;letter-spacing:.4px;white-space:nowrap}
395
+ .wn-tag.new{background:rgba(108,140,255,.15);color:var(--accent);border:1px solid rgba(108,140,255,.3)}
396
+ .wn-tag.fix{background:rgba(251,191,36,.12);color:var(--yellow);border:1px solid rgba(251,191,36,.25)}
397
+ .wn-tag.break{background:rgba(248,113,113,.12);color:var(--red);border:1px solid rgba(248,113,113,.25)}
398
+ </style>
399
+
211
400
  <!-- ─── OVERVIEW ──────────────────────────────────────────────────────── -->
212
401
  <section id="overview">
213
402
  <h2>Overview</h2>
@@ -842,6 +1031,39 @@ chatClient.<span class="fn">disconnect</span>();
842
1031
  <span class="at">name</span>: <span class="str">'Design Team'</span>, <span class="at">participantIds</span>: [<span class="str">'user-1'</span>, <span class="str">'user-2'</span>],
843
1032
  });
844
1033
 
1034
+ <span class="cm">// ── Group icon (admin only) ───────────────────────────────────────────────────</span>
1035
+ <span class="cm">// Always upload AFTER createGroup — the group must exist first.</span>
1036
+ <span class="cm">// Uses the SAME presigned URL pipeline as message attachments.</span>
1037
+ <span class="cm">// platformUploadFn is handled automatically — you never pass it explicitly.</span>
1038
+
1039
+ <span class="cm">// Headless (AntzChatClient) — one call, mirrors client.uploadFiles()</span>
1040
+ <span class="kw">const</span> updated = <span class="kw">await</span> client.<span class="fn">uploadIcon</span>(group.id, {
1041
+ <span class="at">uri</span>: <span class="str">'blob:http://...'</span>, <span class="cm">// URL.createObjectURL(file) on web, file URI on RN</span>
1042
+ <span class="at">name</span>: <span class="str">'icon.jpg'</span>,
1043
+ <span class="at">type</span>: <span class="str">'image/jpeg'</span>,
1044
+ <span class="at">size</span>: file.size,
1045
+ });
1046
+ <span class="cm">// updated.iconUrl → fresh signed URL, regenerated on every API response</span>
1047
+
1048
+ <span class="cm">// What client.uploadIcon() does internally:</span>
1049
+ <span class="cm">// Step 1 — client.uploadFiles([file], conversationId)</span>
1050
+ <span class="cm">// → POST /storage/presigned-url creates temp chat_files record</span>
1051
+ <span class="cm">// → platformUploadFn() client uploads binary direct to S3/Azure/local</span>
1052
+ <span class="cm">// → POST /storage/confirm/:fileId marks chat_files active, returns fileId</span>
1053
+ <span class="cm">// Step 2 — conversationsApi.uploadIcon(conversationId, fileId)</span>
1054
+ <span class="cm">// → PUT /conversations/:id/icon { fileId }</span>
1055
+ <span class="cm">// Server: validateAdmin() → verify file → copy storageKey into iconMeta</span>
1056
+ <span class="cm">// → delete chat_files record → return conversation with fresh iconUrl</span>
1057
+
1058
+ <span class="cm">// iconUrl behaviour:</span>
1059
+ <span class="cm">// - Never stored in DB — regenerated from iconMeta.storageKey on every response</span>
1060
+ <span class="cm">// - Previous icon deleted from storage automatically when replaced</span>
1061
+ <span class="cm">// - iconMeta = { storageKey, provider, bucket, mimeType, size } embedded in conversation doc</span>
1062
+ <span class="cm">// - Non-admins get 403 Forbidden at server level</span>
1063
+
1064
+ <span class="cm">// Web/RN SDK components (GroupInfoPanel, NewChatModal, ChatHeader) handle this automatically.</span>
1065
+ <span class="cm">// Camera button shown only to admins. No code needed in your app.</span>
1066
+
845
1067
  <span class="cm">// Participants</span>
846
1068
  <span class="kw">const</span> members = <span class="kw">await</span> conversationsApi.<span class="fn">getMembers</span>(conversationId);
847
1069
  <span class="kw">await</span> conversationsApi.<span class="fn">addParticipants</span>(conversationId, [<span class="str">'user-3'</span>]);
@@ -857,33 +1079,89 @@ chatClient.<span class="fn">disconnect</span>();
857
1079
 
858
1080
  <!-- ─── STEP 7: ROOMS ──────────────────────────────────────────────────── -->
859
1081
  <section id="step-rooms">
860
- <h2><span class="step">STEP 7</span> Join &amp; Leave a Room</h2>
861
- <p>Joining a room subscribes your socket to real-time events for that conversation. Both <code>joinRoom</code> and <code>leaveRoom</code> are fire-and-forget — they silently no-op if the socket isn't connected.</p>
1082
+ <h2><span class="step">STEP 7</span> Rooms Auto-join, <code>joinRoom</code>, and <code>new_message</code></h2>
1083
+
1084
+ <h4>Auto-join on connect</h4>
1085
+ <p>
1086
+ <strong>On every socket connection, the server automatically joins the user into all their existing conversation rooms.</strong>
1087
+ No client action is needed. The moment the socket connects, the user is already subscribed to real-time events
1088
+ (<code>new_message</code>, <code>typing_indicator</code>, <code>read_receipt</code>, etc.) for every conversation they belong to.
1089
+ </p>
1090
+ <div class="callout info">
1091
+ <strong>You do not need to call <code>joinRoom</code> when opening a chat screen.</strong>
1092
+ The user is already in the room. Calling <code>joinRoom</code> on an already-joined room is safe (idempotent)
1093
+ but triggers an unnecessary DB access check — avoid it in hot paths.
1094
+ </div>
1095
+
1096
+ <h4>When to call <code>joinRoom</code></h4>
1097
+ <p>
1098
+ There is only one real use case — when the <strong>user is added to a conversation while their socket is already connected</strong>
1099
+ (either someone created a new group and added them, or they were added to an existing group at runtime).
1100
+ The server does <strong>not</strong> auto-join the user's socket to the new room — it only emits <code>conversation_created</code>
1101
+ to the user's personal room. Without calling <code>joinRoom</code>, the socket will not receive any
1102
+ <code>new_message</code>, <code>typing_indicator</code>, or other room events for that conversation.
1103
+ </p>
1104
+
1105
+ <pre><code><span class="cm">// Fires when you're added to a new or existing conversation at runtime</span>
1106
+ client.socket.<span class="fn">on</span>(<span class="str">'conversation_created'</span>, (conv) => {
1107
+ client.socket.emit.<span class="fn">joinRoom</span>(conv.id);
1108
+ });</code></pre>
1109
+
1110
+ <p><code>leaveRoom</code> is only needed if you want to intentionally stop receiving events for a room the user is still a member of (e.g. archiving client-side). It is not needed when navigating away from a screen.</p>
1111
+
1112
+ <h4>Where to listen to <code>new_message</code></h4>
1113
+ <p>
1114
+ Because the user is auto-joined to all rooms, <code>new_message</code> fires for <em>any</em> conversation — not just the one currently open.
1115
+ Register <strong>one listener at app root level</strong>, right after <code>client.connect()</code>.
1116
+ Never add it inside a screen or component. Use <code>message.conversationId</code> to route the message to the right place.
1117
+ </p>
862
1118
 
863
1119
  <div data-p="rn web">
864
- <pre><code><span class="kw">import</span> { socketEmit, onSocketStatus } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
1120
+ <pre><code><span class="cm">// Register once at app root covers all conversations</span>
1121
+ client.socket.<span class="fn">on</span>(<span class="str">'new_message'</span>, (event: <span class="tp">NewMessageEvent</span>) => {
1122
+ <span class="kw">const</span> { message } = event;
865
1123
 
866
- <span class="fn">useEffect</span>(() => {
867
- socketEmit.<span class="fn">joinRoom</span>(conversationId);
1124
+ <span class="cm">// Update the open chat view if this conversation is active</span>
1125
+ <span class="kw">if</span> (message.conversationId === activeConversationId) {
1126
+ <span class="fn">appendMessageToView</span>(message);
1127
+ }
868
1128
 
869
- <span class="cm">// Re-join if socket reconnects while this screen is open (e.g. after foreground)</span>
870
- <span class="kw">const</span> unsub = <span class="fn">onSocketStatus</span>((s) => {
871
- <span class="kw">if</span> (s === <span class="str">'connected'</span>) socketEmit.<span class="fn">joinRoom</span>(conversationId);
1129
+ <span class="cm">// Always update conversation list (last message + unread badge)</span>
1130
+ <span class="fn">updateConversationList</span>(message.conversationId, {
1131
+ lastMessage: message,
1132
+ <span class="cm">// Don't increment unread if the user sent it or is currently viewing that chat</span>
1133
+ incrementUnread: message.conversationId !== activeConversationId
1134
+ &amp;&amp; message.senderId !== currentUserId,
872
1135
  });
1136
+ });
873
1137
 
874
- <span class="kw">return</span> () => { socketEmit.<span class="fn">leaveRoom</span>(conversationId); unsub(); };
875
- }, [conversationId]);</code></pre>
1138
+ <span class="cm">// Only case where joinRoom is needed</span>
1139
+ client.socket.<span class="fn">on</span>(<span class="str">'conversation_created'</span>, (conv) => {
1140
+ client.socket.emit.<span class="fn">joinRoom</span>(conv.id);
1141
+ });</code></pre>
876
1142
  </div>
877
1143
  <div data-p="node">
878
- <pre><code><span class="kw">import</span> { socketEmit } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
1144
+ <pre><code><span class="cm">// Bot / headless same pattern, one listener at startup</span>
1145
+ client.socket.<span class="fn">on</span>(<span class="str">'new_message'</span>, (event: <span class="tp">NewMessageEvent</span>) => {
1146
+ <span class="kw">const</span> { message } = event;
1147
+ console.<span class="fn">log</span>(<span class="str">`[</span>${message.conversationId}<span class="str">]`</span>, message.content.text);
1148
+ client.socket.emit.<span class="fn">markRead</span>(message.conversationId, message.id);
1149
+ });
879
1150
 
880
- socketEmit.<span class="fn">joinRoom</span>(conversationId);
881
- <span class="cm">// When done:</span>
882
- socketEmit.<span class="fn">leaveRoom</span>(conversationId);</code></pre>
1151
+ client.socket.<span class="fn">on</span>(<span class="str">'conversation_created'</span>, (conv) => {
1152
+ client.socket.emit.<span class="fn">joinRoom</span>(conv.id);
1153
+ });</code></pre>
883
1154
  </div>
1155
+
884
1156
  <div class="callout warn">
885
- <strong>Room subscriptions don't survive reconnects.</strong>
886
- The server forgets which rooms you were in if the socket disconnects. Always re-join after reconnect (the <code>onSocketStatus</code> pattern above handles this for React apps).
1157
+ <strong>Summary of rules:</strong>
1158
+ <ul>
1159
+ <li>All existing rooms are auto-joined on socket connect — no <code>joinRoom</code> needed on screen open.</li>
1160
+ <li>Call <code>joinRoom</code> only after a <code>conversation_created</code> event.</li>
1161
+ <li>Add the <code>new_message</code> listener once at app root, never per screen.</li>
1162
+ <li>Use <code>message.conversationId</code> to route to the right view or badge update.</li>
1163
+ <li>Track <code>activeConversationId</code> to decide whether to increment the unread count.</li>
1164
+ </ul>
887
1165
  </div>
888
1166
  </section>
889
1167
 
@@ -892,10 +1170,10 @@ socketEmit.<span class="fn">leaveRoom</span>(conversationId);</code></pre>
892
1170
  <!-- ─── STEP 8: LOAD MESSAGES ─────────────────────────────────────────── -->
893
1171
  <section id="step-load">
894
1172
  <h2><span class="step">STEP 8</span> Load Messages</h2>
895
- <p>Cursor-paginated. Load latest on open, fetch older as user scrolls up.</p>
1173
+ <p>Cursor-paginated. Load latest on open, fetch older as user scrolls up. The <code>direction</code> param controls which side of the cursor to fetch — <code>'before'</code> for older messages (scroll up), <code>'after'</code> for newer messages (scroll down or jump-to-unread).</p>
896
1174
  <pre><code><span class="kw">import</span> { messagesApi } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
897
1175
 
898
- <span class="cm">// Initial load</span>
1176
+ <span class="cm">// Initial load — latest messages (no cursor = most recent first)</span>
899
1177
  <span class="kw">const</span> { data: messages, meta } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, { <span class="at">limit</span>: <span class="num">30</span> });
900
1178
 
901
1179
  <span class="cm">// Load older (scroll up)</span>
@@ -903,6 +1181,79 @@ socketEmit.<span class="fn">leaveRoom</span>(conversationId);</code></pre>
903
1181
  <span class="at">cursor</span>: meta.nextCursor, <span class="at">direction</span>: <span class="str">'before'</span>, <span class="at">limit</span>: <span class="num">30</span>,
904
1182
  });
905
1183
  <span class="cm">// meta.hasMore — false when you've reached the beginning</span></code></pre>
1184
+
1185
+ <hr class="divider" style="margin:28px 0"/>
1186
+
1187
+ <h3>Jump to first unread message</h3>
1188
+ <p>When a conversation has unread messages, you can open the chat directly at the first unread message instead of scrolling to the bottom. This gives users an immediate visual anchor — a <strong>"N unread messages"</strong> divider — right above the first message they haven't read.</p>
1189
+
1190
+ <div class="callout info">
1191
+ <strong>How it works</strong>
1192
+ <code>getLastRead()</code> returns <code>lastReadMessageId</code> — the last message the user read.
1193
+ Fetching with <code>direction: 'after'</code> and that ID as the cursor returns all messages <em>after</em> it — those are the unread ones.
1194
+ The first item in that response is the message to scroll to. <code>meta.hasMore</code> tells you if there are more than <code>limit</code> unread messages.
1195
+ </div>
1196
+
1197
+ <pre><code><span class="kw">import</span> { messagesApi, useChatStore } <span class="kw">from</span> <span class="str">'@antzsoft/chat-core'</span>;
1198
+
1199
+ <span class="cm">// Step 1 — get the user's last-read pointer for this conversation</span>
1200
+ <span class="kw">const</span> { lastReadMessageId, lastReadAt } = <span class="kw">await</span> messagesApi.<span class="fn">getLastRead</span>(conversationId);
1201
+
1202
+ <span class="kw">if</span> (lastReadMessageId) {
1203
+ <span class="cm">// Seed the store so the unread divider renders correctly</span>
1204
+ useChatStore.<span class="fn">getState</span>().<span class="fn">setLastRead</span>(conversationId, lastReadMessageId, lastReadAt!);
1205
+
1206
+ <span class="cm">// Step 2 — fetch messages AFTER the last-read message (these are unread)</span>
1207
+ <span class="kw">const</span> { data: unreadMessages, meta } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, {
1208
+ <span class="at">cursor</span>: lastReadMessageId,
1209
+ <span class="at">direction</span>: <span class="str">'after'</span>,
1210
+ <span class="at">limit</span>: <span class="num">50</span>,
1211
+ });
1212
+
1213
+ <span class="cm">// unreadMessages[0] is the first unread — scroll to it</span>
1214
+ <span class="cm">// meta.hasMore = true means there are more than 50 unread messages</span>
1215
+
1216
+ <span class="kw">if</span> (unreadMessages.length > <span class="num">0</span>) {
1217
+ <span class="fn">scrollToMessage</span>(unreadMessages[<span class="num">0</span>].id); <span class="cm">// scroll FlatList / ScrollView to this item</span>
1218
+ }
1219
+ } <span class="kw">else</span> {
1220
+ <span class="cm">// No prior read state — load latest messages as normal</span>
1221
+ <span class="kw">const</span> { data: messages } = <span class="kw">await</span> messagesApi.<span class="fn">list</span>(conversationId, { <span class="at">limit</span>: <span class="num">30</span> });
1222
+ }</code></pre>
1223
+
1224
+ <div data-p="rn web">
1225
+ <h4>Rendering the "unread" divider</h4>
1226
+ <p>Use <code>useChatStore.lastRead[conversationId]</code> to insert a divider row above the first unread message in your list:</p>
1227
+ <pre><code><span class="kw">const</span> lastRead = <span class="fn">useChatStore</span>(s => s.lastRead[conversationId]);
1228
+
1229
+ <span class="kw">function</span> <span class="fn">MessageRow</span>({ message, prevMessage }) {
1230
+ <span class="cm">// Insert divider between lastRead message and the next one</span>
1231
+ <span class="kw">const</span> isFirstUnread = lastRead && prevMessage?.id === lastRead.messageId;
1232
+
1233
+ <span class="kw">return</span> (
1234
+ &lt;&gt;
1235
+ {isFirstUnread && (
1236
+ &lt;<span class="fn">View</span> style={styles.unreadDivider}&gt;
1237
+ &lt;<span class="fn">Text</span>&gt;↑ Unread messages&lt;/<span class="fn">Text</span>&gt;
1238
+ &lt;/<span class="fn">View</span>&gt;
1239
+ )}
1240
+ &lt;<span class="fn">MessageBubble</span> message={message} /&gt;
1241
+ &lt;/&gt;
1242
+ );
1243
+ }</code></pre>
1244
+ </div>
1245
+
1246
+ <h4>Full pattern summary</h4>
1247
+ <table>
1248
+ <thead><tr><th>Step</th><th>Call</th><th>Purpose</th></tr></thead>
1249
+ <tbody>
1250
+ <tr><td>1</td><td><code>messagesApi.getLastRead(conversationId)</code></td><td>Get the last-read message ID and seed the store</td></tr>
1251
+ <tr><td>2</td><td><code>messagesApi.list(conversationId, { cursor: lastReadMessageId, direction: 'after' })</code></td><td>Fetch all unread messages after that pointer</td></tr>
1252
+ <tr><td>3</td><td>Scroll to <code>unreadMessages[0].id</code></td><td>Jump the list to the first unread message</td></tr>
1253
+ <tr><td>4</td><td>Render divider when <code>prevMessage.id === lastRead.messageId</code></td><td>Show "↑ Unread messages" label above the first unread</td></tr>
1254
+ <tr><td>5</td><td><code>socketEmit.markRead(conversationId)</code> on screen focus</td><td>Tell server the user has read the conversation</td></tr>
1255
+ </tbody>
1256
+ </table>
906
1257
  </section>
907
1258
 
908
1259
  <!-- ─── STEP 9: SEND ───────────────────────────────────────────────────── -->
@@ -970,18 +1321,113 @@ socket.<span class="fn">on</span>(<span class="str">'message_deleted'</span>, (e
970
1321
  </div>
971
1322
  <h4>All socket events</h4>
972
1323
  <table>
973
- <thead><tr><th>Event</th><th>When fired</th></tr></thead>
1324
+ <thead><tr><th>Event</th><th>When fired</th><th>Where to listen / unlisten</th></tr></thead>
974
1325
  <tbody>
975
- <tr><td><code>'new_message'</code></td><td>New message in a joined room</td></tr>
976
- <tr><td><code>'message_updated'</code></td><td>A message was edited</td></tr>
977
- <tr><td><code>'message_deleted'</code></td><td>Deleted for everyone</td></tr>
978
- <tr><td><code>'message_deleted_for_me'</code></td><td>Deleted for current user only</td></tr>
979
- <tr><td><code>'reaction_updated'</code></td><td>Emoji reaction added/removed</td></tr>
980
- <tr><td><code>'typing'</code></td><td>User started/stopped typing</td></tr>
981
- <tr><td><code>'user_online'</code> / <code>'user_offline'</code></td><td>User went online/offline — auto-updates <code>useChatStore.lastSeen</code></td></tr>
982
- <tr><td><code>'read_receipt'</code></td><td>Messages marked as read — auto-updates <code>useChatStore.lastRead</code></td></tr>
983
- <tr><td><code>'message_ack'</code></td><td>Your sent message acknowledged (tempId real ID)</td></tr>
984
- <tr><td><code>'message_delivered'</code></td><td>Message delivered to recipient</td></tr>
1326
+ <tr>
1327
+ <td><code>'new_message'</code></td>
1328
+ <td>New message in any of the user's conversations — all rooms are auto-joined on connect</td>
1329
+ <td><strong>App root</strong> after <code>client.connect()</code>. Never remove this listener — it must stay alive for the full app session to keep the conversation list and unread badges up to date.</td>
1330
+ </tr>
1331
+ <tr>
1332
+ <td><code>'message_updated'</code></td>
1333
+ <td>A message was edited</td>
1334
+ <td><strong>Chat detail screen</strong> add on mount, remove on unmount (<code>socket.off</code>).</td>
1335
+ </tr>
1336
+ <tr>
1337
+ <td><code>'message_deleted'</code></td>
1338
+ <td>Deleted for everyone</td>
1339
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1340
+ </tr>
1341
+ <tr>
1342
+ <td><code>'message_deleted_for_me'</code></td>
1343
+ <td>Deleted for current user only (fired only to that user's socket)</td>
1344
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1345
+ </tr>
1346
+ <tr>
1347
+ <td><code>'reaction_updated'</code></td>
1348
+ <td>Emoji reaction added/removed</td>
1349
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1350
+ </tr>
1351
+ <tr>
1352
+ <td><code>'message_pin_updated'</code></td>
1353
+ <td>A message was pinned or unpinned</td>
1354
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1355
+ </tr>
1356
+ <tr>
1357
+ <td><code>'typing_indicator'</code></td>
1358
+ <td>User started/stopped typing (payload: <code>{ conversationId, userId, displayName, isTyping }</code>)</td>
1359
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1360
+ </tr>
1361
+ <tr>
1362
+ <td><code>'user_online'</code></td>
1363
+ <td>A participant came online</td>
1364
+ <td><strong>App root</strong> — drives online indicators everywhere. Keep for full session.</td>
1365
+ </tr>
1366
+ <tr>
1367
+ <td><code>'user_offline'</code></td>
1368
+ <td>A participant went offline</td>
1369
+ <td><strong>App root</strong> — drives online indicators and last-seen everywhere. Keep for full session.</td>
1370
+ </tr>
1371
+ <tr>
1372
+ <td><code>'user_status'</code></td>
1373
+ <td>Global presence broadcast (online/offline/away) to all connected clients</td>
1374
+ <td><strong>App root</strong> — keep for full session.</td>
1375
+ </tr>
1376
+ <tr>
1377
+ <td><code>'online_users'</code></td>
1378
+ <td>Full list of online user IDs — sent on initial room join</td>
1379
+ <td><strong>App root</strong> — keep for full session.</td>
1380
+ </tr>
1381
+ <tr>
1382
+ <td><code>'read_receipt'</code></td>
1383
+ <td>Messages marked as read by a participant</td>
1384
+ <td><strong>Chat detail screen</strong> (to update tick marks) + <strong>app root</strong> (to clear your own unread count when read on another device).</td>
1385
+ </tr>
1386
+ <tr>
1387
+ <td><code>'unread_count_changed'</code></td>
1388
+ <td>Your unread count changed — sent to all your devices simultaneously</td>
1389
+ <td><strong>App root / conversation list screen</strong> — keep alive as long as the list is rendered.</td>
1390
+ </tr>
1391
+ <tr>
1392
+ <td><code>'message_ack'</code></td>
1393
+ <td>Server acknowledged your sent message (tempId → real ID)</td>
1394
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1395
+ </tr>
1396
+ <tr>
1397
+ <td><code>'message_delivered'</code></td>
1398
+ <td>A single message you sent was delivered to all active recipients</td>
1399
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1400
+ </tr>
1401
+ <tr>
1402
+ <td><code>'messages_delivered'</code></td>
1403
+ <td>Batch delivery catch-up when a recipient comes online</td>
1404
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1405
+ </tr>
1406
+ <tr>
1407
+ <td><code>'conversation_created'</code></td>
1408
+ <td>A new conversation was created (or you were added to one)</td>
1409
+ <td><strong>App root</strong> — call <code>joinRoom</code> here for the new conversation. Keep for full session.</td>
1410
+ </tr>
1411
+ <tr>
1412
+ <td><code>'conversation_updated'</code></td>
1413
+ <td>A conversation's last message or metadata changed</td>
1414
+ <td><strong>App root</strong> — keep for full session. The server emits this for every message across all conversations; a global listener keeps the in-memory conversation list and unread badge always in sync.</td>
1415
+ </tr>
1416
+ <tr>
1417
+ <td><code>'conversation_deleted'</code></td>
1418
+ <td>A conversation was deleted</td>
1419
+ <td><strong>App root / conversation list screen</strong> — remove the conversation from state and navigate away if it was open.</td>
1420
+ </tr>
1421
+ <tr>
1422
+ <td><code>'participant_joined'</code></td>
1423
+ <td>A new participant was added to a group conversation</td>
1424
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1425
+ </tr>
1426
+ <tr>
1427
+ <td><code>'participant_left'</code></td>
1428
+ <td>A participant left or was removed from a group conversation</td>
1429
+ <td><strong>Chat detail screen</strong> — add on mount, remove on unmount.</td>
1430
+ </tr>
985
1431
  </tbody>
986
1432
  </table>
987
1433
  </section>
@@ -1059,7 +1505,7 @@ socketEmit.<span class="fn">markRead</span>(conversationId); <span
1059
1505
  <hr class="divider" style="margin:28px 0"/>
1060
1506
 
1061
1507
  <h3>Last-read pointer — where the user left off</h3>
1062
- <p>The store keeps a <strong>last-read pointer</strong> per conversation: the ID and timestamp of the last message the current user read. This is useful for scroll-to-unread and "unread from here" badges.</p>
1508
+ <p>The store keeps a <strong>last-read pointer</strong> per conversation: the ID and timestamp of the last message the current user read. This is used for scroll-to-first-unread, "unread from here" dividers, and unread count badges. See <a href="#step-load">Step 8 — Jump to first unread</a> for the complete implementation pattern.</p>
1063
1509
 
1064
1510
  <div class="callout tip">
1065
1511
  <strong>Automatic after connect</strong>
@@ -1910,19 +2356,114 @@ socket?.<span class="fn">on</span>(<span class="str">'unread_count_changed'</spa
1910
2356
  <span class="cm">// unreadCount is 0 — the conversation was just read.</span>
1911
2357
  });</code></pre>
1912
2358
 
1913
- <h3>Platform-specific badge updates</h3>
2359
+ <h3>Chat icon badge — the key insight</h3>
2360
+ <p>
2361
+ If you use <code>@antzsoft/chat-web-sdk</code> or <code>@antzsoft/chat-rn-sdk</code>,
2362
+ <strong>you do not need to listen to socket events manually</strong>.
2363
+ <code>useConversations()</code> is already subscribed internally — every
2364
+ <code>conversation_updated</code> and <code>unread_count_changed</code> event
2365
+ automatically updates the hook's data. Just sum the counts:
2366
+ </p>
1914
2367
 
1915
- <div data-p="rn">
1916
- <pre><code><span class="kw">import</span> * <span class="kw">as</span> Notifications <span class="kw">from</span> <span class="str">'expo-notifications'</span>;
1917
- <span class="kw">import</span> { AppState } <span class="kw">from</span> <span class="str">'react-native'</span>;
2368
+ <div data-p="web">
2369
+ <pre><code><span class="kw">import</span> { useConversations } <span class="kw">from</span> <span class="str">'@antzsoft/chat-web-sdk'</span>;
2370
+
2371
+ <span class="kw">function</span> <span class="fn">ChatIconButton</span>({ onClick }: { onClick: () => <span class="tp">void</span> }) {
2372
+ <span class="kw">const</span> { conversations } = <span class="fn">useConversations</span>();
2373
+
2374
+ <span class="cm">// Recalculates automatically on every socket update — no extra listener needed</span>
2375
+ <span class="kw">const</span> totalUnread = conversations.<span class="fn">reduce</span>(
2376
+ (sum, c) => sum + (c.unreadCount ?? <span class="num">0</span>), <span class="num">0</span>
2377
+ );
2378
+
2379
+ <span class="kw">return</span> (
2380
+ &lt;button onClick={onClick} style={{ position: <span class="str">'relative'</span> }}&gt;
2381
+ 💬
2382
+ {totalUnread &gt; <span class="num">0</span> &amp;&amp; (
2383
+ &lt;span style={{
2384
+ position: <span class="str">'absolute'</span>, top: <span class="num">-6</span>, right: <span class="num">-6</span>,
2385
+ backgroundColor: <span class="str">'#e53935'</span>, color: <span class="str">'#fff'</span>,
2386
+ borderRadius: <span class="str">'50%'</span>, minWidth: <span class="num">18</span>, height: <span class="num">18</span>,
2387
+ fontSize: <span class="num">11</span>, fontWeight: <span class="num">700</span>,
2388
+ display: <span class="str">'flex'</span>, alignItems: <span class="str">'center'</span>, justifyContent: <span class="str">'center'</span>,
2389
+ padding: <span class="str">'0 4px'</span>,
2390
+ }}&gt;
2391
+ {totalUnread &gt; <span class="num">99</span> ? <span class="str">'99+'</span> : totalUnread}
2392
+ &lt;/span&gt;
2393
+ )}
2394
+ &lt;/button&gt;
2395
+ );
2396
+ }
1918
2397
 
1919
- <span class="cm">// Keep badge in sync while app is active</span>
2398
+ <span class="cm">// Update browser tab title too</span>
1920
2399
  <span class="fn">useEffect</span>(() => {
1921
2400
  <span class="kw">const</span> total = conversations.<span class="fn">reduce</span>((s, c) => s + (c.unreadCount ?? <span class="num">0</span>), <span class="num">0</span>);
1922
- Notifications.<span class="fn">setBadgeCountAsync</span>(total);
1923
- }, [conversations]);
2401
+ document.title = total &gt; <span class="num">0</span> ? <span class="str">`(${total}) Antz Chat`</span> : <span class="str">'Antz Chat'</span>;
2402
+ }, [conversations]);</code></pre>
2403
+ </div>
2404
+
2405
+ <div data-p="rn">
2406
+ <pre><code><span class="kw">import</span> { useConversations } <span class="kw">from</span> <span class="str">'@antzsoft/chat-rn-sdk'</span>;
2407
+ <span class="kw">import</span> * <span class="kw">as</span> Notifications <span class="kw">from</span> <span class="str">'expo-notifications'</span>;
2408
+
2409
+ <span class="kw">function</span> <span class="fn">ChatTabIcon</span>({ color }: { color: <span class="tp">string</span> }) {
2410
+ <span class="kw">const</span> { conversations } = <span class="fn">useConversations</span>();
2411
+
2412
+ <span class="cm">// Recalculates automatically on every socket update — no extra listener needed</span>
2413
+ <span class="kw">const</span> totalUnread = conversations.<span class="fn">reduce</span>(
2414
+ (sum, c) => sum + (c.unreadCount ?? <span class="num">0</span>), <span class="num">0</span>
2415
+ );
2416
+
2417
+ <span class="cm">// Sync OS app icon badge</span>
2418
+ <span class="fn">useEffect</span>(() => {
2419
+ Notifications.<span class="fn">setBadgeCountAsync</span>(totalUnread);
2420
+ }, [totalUnread]);
1924
2421
 
1925
- <span class="cm">// Refresh when app comes to foreground (socket may have been down)</span>
2422
+ <span class="kw">return</span> (
2423
+ &lt;View&gt;
2424
+ &lt;Ionicons name=<span class="str">"chatbubbles-outline"</span> size={<span class="num">24</span>} color={color} /&gt;
2425
+ {totalUnread &gt; <span class="num">0</span> &amp;&amp; (
2426
+ &lt;View style={{
2427
+ position: <span class="str">'absolute'</span>, top: <span class="num">-4</span>, right: <span class="num">-6</span>,
2428
+ backgroundColor: <span class="str">'#e53935'</span>, borderRadius: <span class="num">9</span>,
2429
+ minWidth: <span class="num">18</span>, height: <span class="num">18</span>, alignItems: <span class="str">'center'</span>,
2430
+ justifyContent: <span class="str">'center'</span>, paddingHorizontal: <span class="num">4</span>,
2431
+ }}&gt;
2432
+ &lt;Text style={{ color: <span class="str">'#fff'</span>, fontSize: <span class="num">10</span>, fontWeight: <span class="str">'700'</span> }}&gt;
2433
+ {totalUnread &gt; <span class="num">99</span> ? <span class="str">'99+'</span> : String(totalUnread)}
2434
+ &lt;/Text&gt;
2435
+ &lt;/View&gt;
2436
+ )}
2437
+ &lt;/View&gt;
2438
+ );
2439
+ }
2440
+
2441
+ <span class="cm">// Wire into Tab.Navigator</span>
2442
+ &lt;Tab.Screen
2443
+ name=<span class="str">"Chat"</span>
2444
+ component={ChatScreen}
2445
+ options={{ tabBarIcon: ({ color }) =&gt; &lt;ChatTabIcon color={color} /&gt; }}
2446
+ /&gt;</code></pre>
2447
+ </div>
2448
+
2449
+ <p style="margin:16px 0 8px;color:#c8d0e0;font-size:14px">
2450
+ The full update chain — no polling, no manual socket code:
2451
+ </p>
2452
+ <pre style="background:var(--code-bg);padding:12px 16px;border-radius:8px;font-size:12px;color:#8892b0;border:1px solid var(--border)">New message sent
2453
+ → server emits conversation_updated (DB-accurate unreadCount per participant)
2454
+ → SocketProvider updates useConversations() React Query cache
2455
+ → totalUnread recalculates → badge re-renders
2456
+
2457
+ User reads conversation
2458
+ → server emits unread_count_changed to ALL devices of that user simultaneously
2459
+ → useConversations() cache updated → badge clears on every device at once</pre>
2460
+
2461
+ <h3>Platform-specific: foreground resume + tab title</h3>
2462
+
2463
+ <div data-p="rn">
2464
+ <pre><code><span class="kw">import</span> { AppState } <span class="kw">from</span> <span class="str">'react-native'</span>;
2465
+
2466
+ <span class="cm">// Refresh when app comes to foreground (socket may have been down while backgrounded)</span>
1926
2467
  <span class="fn">useEffect</span>(() => {
1927
2468
  <span class="kw">const</span> sub = AppState.<span class="fn">addEventListener</span>(<span class="str">'change'</span>, <span class="kw">async</span> (state) => {
1928
2469
  <span class="kw">if</span> (state === <span class="str">'active'</span>) {
@@ -1935,13 +2476,7 @@ socket?.<span class="fn">on</span>(<span class="str">'unread_count_changed'</spa
1935
2476
  </div>
1936
2477
 
1937
2478
  <div data-p="web">
1938
- <pre><code><span class="cm">// Browser tab title badge</span>
1939
- <span class="fn">useEffect</span>(() => {
1940
- <span class="kw">const</span> total = conversations.<span class="fn">reduce</span>((s, c) => s + (c.unreadCount ?? <span class="num">0</span>), <span class="num">0</span>);
1941
- document.title = total > <span class="num">0</span> ? <span class="str">`(${total}) Antz Chat`</span> : <span class="str">'Antz Chat'</span>;
1942
- }, [conversations]);
1943
-
1944
- <span class="cm">// Refresh when tab becomes visible (socket may have been down)</span>
2479
+ <pre><code><span class="cm">// Refresh when tab becomes visible (socket may have been down)</span>
1945
2480
  <span class="fn">useEffect</span>(() => {
1946
2481
  <span class="kw">function</span> <span class="fn">onVisible</span>() {
1947
2482
  <span class="kw">if</span> (document.visibilityState === <span class="str">'visible'</span>) {
@@ -2042,6 +2577,14 @@ document.getElementById('nav-platform-label').textContent =
2042
2577
  savedP === 'rn' ? 'React Native' : savedP === 'web' ? 'React / Next.js' : 'Node.js';
2043
2578
  applyGating();
2044
2579
 
2580
+ // ── What's New toggles ───────────────────────────────────────────────────
2581
+ function toggleVersion(id) {
2582
+ document.getElementById(id).classList.toggle('open');
2583
+ }
2584
+ function toggleItem(id) {
2585
+ document.getElementById(id).classList.toggle('open');
2586
+ }
2587
+
2045
2588
  // ── Progress bar ──────────────────────────────────────────────────────────
2046
2589
  window.addEventListener('scroll', () => {
2047
2590
  const el = document.getElementById('prog');