@antzsoft/chat-core 1.3.9 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ npm install @antzsoft/chat-core
43
43
  | Capability | What the SDK provides |
44
44
  |---|---|
45
45
  | Authentication | Login, register, logout, token refresh (automatic on 401) |
46
- | Conversations | List, create (group/DM), update, delete, mute, pin, leave, manage members |
46
+ | Conversations | List, create (group/DM), update, delete, mute, pin, mark unread, leave, manage members |
47
47
  | Messages | Send, edit, delete, react, star, pin, search, paginate, @mention |
48
48
  | Mentions | Group @mentions with `@all` (admin-gated); token parse/build/render helpers; mention pierces mute |
49
49
  | File uploads | Presigned URL pipeline — request URL → upload binary (multipart POST for S3/local, PUT for Azure) → confirm. Files ≥ 10 MB on S3 or local use chunked multipart (parallel parts → complete). |
@@ -945,6 +945,8 @@ import { conversationsApi } from '@antzsoft/chat-core';
945
945
  | `unmute` | `(conversationId: string) => Promise<void>` | Unmute a conversation. |
946
946
  | `pin` | `(conversationId: string) => Promise<void>` | Pin a conversation to the top of the list. Max 5 pins — server returns `400` if the limit is reached. |
947
947
  | `unpin` | `(conversationId: string) => Promise<void>` | Unpin a conversation. |
948
+ | `markUnread` | `(conversationId: string) => Promise<void>` | Manually flag a conversation as unread (`Conversation.isManuallyUnread` becomes `true`), independent of `unreadCount`. |
949
+ | `markRead` | `(conversationId: string) => Promise<void>` | Clear the manual unread flag. Also cleared automatically whenever the conversation is opened/read through the normal mark-as-read flow. |
948
950
  | `leave` | `(conversationId: string, andDelete?: boolean) => Promise<void>` | Leave a group conversation. Pass `andDelete: true` to also hide it from the caller's list in one atomic operation ("Exit and Delete"). When the last admin calls `leave()`, the server automatically promotes the longest-standing active member to admin before completing the exit — no client action required. |
949
951
  | `getMembers` | `(conversationId: string) => Promise<User[]>` | Fetch full user profiles for all participants. |
950
952
 
@@ -1165,6 +1167,44 @@ async function onForeground() {
1165
1167
 
1166
1168
  > **Using `@antzsoft/chat-web-sdk` or `@antzsoft/chat-rn-sdk`?** You don't need any of this — `useConversations()` handles socket subscriptions internally. Just sum `conversations.reduce((s, c) => s + (c.unreadCount ?? 0), 0)` and it updates automatically.
1167
1169
 
1170
+ #### Mark as Unread (v1.4.1+)
1171
+
1172
+ `isManuallyUnread` is a separate flag from `unreadCount` — it lets a user re-flag a conversation they've already read so it stands out again in the list, without fabricating unread messages or moving the read-receipt cursor. This is the same "mark as unread" behavior as WhatsApp/Telegram: a dot indicator, not a count.
1173
+
1174
+ | Field / method | Type | Description |
1175
+ |---|---|---|
1176
+ | `Conversation.isManuallyUnread` | `boolean \| undefined` | `true` once flagged; `false`/absent otherwise. Independent of `unreadCount` — both can be `true`/`>0` at once, or `isManuallyUnread` can be `true` while `unreadCount` is `0`. |
1177
+ | `conversationsApi.markUnread(id)` | `Promise<void>` | Sets the flag. |
1178
+ | `conversationsApi.markRead(id)` | `Promise<void>` | Clears the flag. |
1179
+
1180
+ ```typescript
1181
+ // Flag a fully-read conversation as unread
1182
+ await conversationsApi.markUnread(conversationId);
1183
+
1184
+ // Clear it manually (rarely needed — see auto-clear below)
1185
+ await conversationsApi.markRead(conversationId);
1186
+
1187
+ // Render: numbered badge takes priority; fall back to a plain dot
1188
+ const conv = await conversationsApi.get(conversationId);
1189
+ if ((conv.unreadCount ?? 0) > 0) {
1190
+ showBadge(conv.unreadCount);
1191
+ } else if (conv.isManuallyUnread) {
1192
+ showDot();
1193
+ }
1194
+ ```
1195
+
1196
+ **Auto-clears on read.** Opening the conversation — anything that runs the normal mark-as-read flow (socket `mark_read`, or the REST notification-catchup path) — clears `isManuallyUnread` server-side automatically, same as WhatsApp. You do not need to call `markRead()` yourself after the user opens the chat; it's only for the explicit "un-flag without opening" action (e.g. an X button on the dot).
1197
+
1198
+ **Live sync across devices.** Unlike `mute`/`pin` (which currently only take effect on the next fetch), toggling `isManuallyUnread` emits a `conversation_updated` socket event to the caller's other connected sessions immediately:
1199
+
1200
+ ```typescript
1201
+ socket?.on('conversation_updated', (conv) => {
1202
+ // conv.isManuallyUnread reflects the latest state, pushed live
1203
+ });
1204
+ ```
1205
+
1206
+ **Web/RN SDK hooks expose named mutations:** `markUnread`, `markRead` (both SDKs) — cache updated optimistically, no manual invalidation needed. The built-in `ConversationList` component already renders the dot and exposes the toggle from its existing Pin/Mute menu.
1207
+
1168
1208
  #### Clear / Delete Chat (v1.2.6+)
1169
1209
 
1170
1210
  `conversationsApi.delete(conversationId)` hides a conversation from the caller's list. Any participant can call it — no admin role required. Other participants are completely unaffected.
@@ -2845,6 +2885,7 @@ interface Conversation {
2845
2885
  isPinned?: boolean;
2846
2886
  isMuted?: boolean;
2847
2887
  mutedUntil?: string;
2888
+ isManuallyUnread?: boolean; // v1.4.1+ — manually flagged unread, independent of unreadCount
2848
2889
  }
2849
2890
 
2850
2891
  interface ConversationSettings {
@@ -3122,6 +3163,24 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
3122
3163
 
3123
3164
  ## Changelog
3124
3165
 
3166
+ ### v1.4.1
3167
+
3168
+ - **New: manual "mark as unread."** A conversation can now be flagged unread independently of `unreadCount` — the same UX as WhatsApp/Telegram's "mark as unread": re-flag a conversation you've already read so it stands out again, without fabricating unread messages.
3169
+ - **New field:** `Conversation.isManuallyUnread?: boolean`. Present on every conversation object returned from `conversationsApi.list/get`, and on `conversation_created`/`conversation_updated` socket payloads that carry a full conversation DTO. `false`/absent for a conversation that has never been manually flagged.
3170
+ - **New API methods:** `conversationsApi.markUnread(conversationId)` and `conversationsApi.markRead(conversationId)` — both `Promise<void>`, following the same shape as `pin`/`unpin`.
3171
+ - **Auto-clears on read.** Opening the conversation (anything that calls the existing mark-as-read flow — socket `mark_read` or the REST notification-catchup path) clears the flag server-side automatically, same as WhatsApp: you don't have to manually un-flag it after reading.
3172
+ - **Live sync.** Toggling the flag emits a `conversation_updated` event to the caller's other connected devices/tabs, so mark-unread/mark-read state stays in sync across sessions immediately — unlike `mute`/`pin`, which currently only take effect on next fetch.
3173
+
3174
+ **Backward compatible, additive-only.** `isManuallyUnread` is optional; an older SDK against a server with this change simply ignores the extra field. **No integration changes required** unless you want to surface the new flag/actions in your own conversation list UI (the RN and web SDKs' built-in `ConversationList` already do, see their changelogs).
3175
+
3176
+ ### v1.4.0
3177
+
3178
+ - **Fix: stale transit encryption key after token refresh, causing continuous `"Transit decryption failed for event: user_online"` spam.** `reconnectSocket()` called `socket.connect()` to apply a refreshed token while preserving the transit session — but socket.io-client's `connect()` is a no-op on an already-connected socket. A token refresh fires while the user is actively on a chat screen (socket still connected), so the new auth was never actually sent; the socket kept running on the stale token until the server eventually dropped it, and the disconnect handler then cleared the transit session, desyncing the client's key from the server's.
3179
+
3180
+ **Fix:** in the transit branch of `reconnectSocket()`, force a real transport re-cycle (disconnect → connect) so the fresh auth (token + `transitSessionId`) is actually sent and the server re-links the *same* session (same key), instead of silently continuing on stale auth. A new internal `_preservingSession` guard stops the disconnect handler from wiping the key being deliberately carried forward. Non-transit and already-disconnected paths are unchanged.
3181
+
3182
+ **Backward compatible.** Only affects transit-encryption deployments that refresh auth tokens while the socket is connected. **No integration changes required** — the fix is entirely internal to `reconnectSocket()`.
3183
+
3125
3184
  ### v1.3.9
3126
3185
 
3127
3186
  - **New: group @mentions.** Tag members in a group message; a mentioned user is notified even if they muted the group (the mention pierces mute). Mentions are stored inline in the message text as self-describing tokens `@[DisplayName](userId)` (and `@[all](all)` for @all), plus a flat, denormalized `mentions: string[]` array on the message for fan-out and "who was mentioned" lookups. No offsets are stored — the token is self-locating and survives edits.
package/dist/index.cjs CHANGED
@@ -1216,6 +1216,12 @@ var conversationsApi = {
1216
1216
  async unpin(conversationId) {
1217
1217
  await getApiClient().post(`/conversations/${conversationId}/unpin`);
1218
1218
  },
1219
+ async markUnread(conversationId) {
1220
+ await getApiClient().post(`/conversations/${conversationId}/unread`);
1221
+ },
1222
+ async markRead(conversationId) {
1223
+ await getApiClient().post(`/conversations/${conversationId}/unread/clear`);
1224
+ },
1219
1225
  async leave(conversationId, andDelete) {
1220
1226
  const url = andDelete ? `/conversations/${conversationId}/leave?delete=true` : `/conversations/${conversationId}/leave`;
1221
1227
  await getApiClient().post(url);
@@ -1525,6 +1531,7 @@ var _getToken = null;
1525
1531
  var _userId;
1526
1532
  var _tenantId;
1527
1533
  var _config2 = null;
1534
+ var _preservingSession = false;
1528
1535
  function setStatus(s) {
1529
1536
  _status = s;
1530
1537
  _statusListeners.forEach((l) => l(s));
@@ -1676,7 +1683,7 @@ async function _doConnect(config, getToken) {
1676
1683
  _socket.on("connect", () => setStatus("connected"));
1677
1684
  _socket.on("disconnect", () => {
1678
1685
  setStatus("disconnected");
1679
- clearTransitSession();
1686
+ if (!_preservingSession) clearTransitSession();
1680
1687
  });
1681
1688
  _socket.on("connect_error", (err) => {
1682
1689
  console.error("[AntzChat] Socket connect_error:", err?.message, err?.data);
@@ -1779,7 +1786,13 @@ function reconnectSocket(token, userId, tenantId) {
1779
1786
  ...tenantId && { tenantId },
1780
1787
  transitSessionId: existing.sessionId
1781
1788
  };
1782
- _socket.connect();
1789
+ _preservingSession = true;
1790
+ try {
1791
+ if (_socket.connected) _socket.disconnect();
1792
+ _socket.connect();
1793
+ } finally {
1794
+ _preservingSession = false;
1795
+ }
1783
1796
  return;
1784
1797
  }
1785
1798
  if (_getToken) {