@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.
package/README.md CHANGED
@@ -614,6 +614,13 @@ class AntzChatClient {
614
614
  * to the configured platformUploadFn, and confirms each upload with the server.
615
615
  */
616
616
  uploadFiles(files: UploadableFile[], conversationId?: string): Promise<BatchUploadResult>;
617
+
618
+ /**
619
+ * Upload or replace the group icon (admin only).
620
+ * Internally calls uploadFiles() to upload the file, then sets the icon on the conversation.
621
+ * Same presigned URL pipeline as message attachments — platformUploadFn is handled automatically.
622
+ */
623
+ uploadIcon(conversationId: string, file: UploadableFile): Promise<Conversation>;
617
624
  }
618
625
  ```
619
626
 
@@ -773,6 +780,56 @@ await messagesApi.send('conv-abc', {
773
780
  const results = await messagesApi.search({ query: 'deployment', conversationId: 'conv-abc' });
774
781
  ```
775
782
 
783
+ #### Jump to first unread message
784
+
785
+ Use `direction: 'after'` with the user's `lastReadMessageId` as the cursor to fetch only the unread messages. This powers a scroll-to-first-unread experience with an "↑ Unread messages" divider.
786
+
787
+ ```typescript
788
+ import { messagesApi, useChatStore } from '@antzsoft/chat-core';
789
+
790
+ // 1. Get the last-read pointer and seed the store
791
+ const { lastReadMessageId, lastReadAt } = await messagesApi.getLastRead(conversationId);
792
+
793
+ if (lastReadMessageId && lastReadAt) {
794
+ useChatStore.getState().setLastRead(conversationId, lastReadMessageId, lastReadAt);
795
+
796
+ // 2. Fetch all messages AFTER the last-read message — these are unread
797
+ const { data: unreadMessages, meta } = await messagesApi.list(conversationId, {
798
+ cursor: lastReadMessageId,
799
+ direction: 'after',
800
+ limit: 50,
801
+ });
802
+
803
+ // unreadMessages[0] is the first unread — scroll the list to this item
804
+ // meta.hasMore = true means there are more than 50 unread messages
805
+ if (unreadMessages.length > 0) {
806
+ scrollToMessage(unreadMessages[0].id);
807
+ }
808
+ } else {
809
+ // No prior read state — load latest messages normally
810
+ const { data: messages } = await messagesApi.list(conversationId, { limit: 30 });
811
+ }
812
+ ```
813
+
814
+ Render the divider in your message list by checking `useChatStore.lastRead[conversationId]`:
815
+
816
+ ```typescript
817
+ const lastRead = useChatStore((s) => s.lastRead[conversationId]);
818
+
819
+ function MessageRow({ message, prevMessage }) {
820
+ // Insert divider between the last-read message and the next one
821
+ const isFirstUnread = lastRead && prevMessage?.id === lastRead.messageId;
822
+ return (
823
+ <>
824
+ {isFirstUnread && <UnreadDivider />}
825
+ <MessageBubble message={message} />
826
+ </>
827
+ );
828
+ }
829
+ ```
830
+
831
+ After the user reads the messages, call `socketEmit.markRead(conversationId)` (see [Read Receipts](#chat-store-usechatstore)) to update the server and broadcast the receipt to other participants.
832
+
776
833
  ---
777
834
 
778
835
  ### Conversations API (`conversationsApi`)
@@ -787,7 +844,8 @@ import { conversationsApi } from '@antzsoft/chat-core';
787
844
  | `get` | `(conversationId: string) => Promise<Conversation>` | Fetch a single conversation. |
788
845
  | `createGroup` | `(data: CreateGroupData) => Promise<Conversation>` | Create a group conversation. |
789
846
  | `createDirect` | `(data: CreateDirectData) => Promise<Conversation>` | Start or retrieve a direct conversation with another user. |
790
- | `update` | `(conversationId: string, data: UpdateConversationData) => Promise<Conversation>` | Update group name, description, or icon. |
847
+ | `update` | `(conversationId: string, data: UpdateConversationData) => Promise<Conversation>` | Update group name or description. |
848
+ | `uploadIcon` | `(conversationId: string, fileId: string) => Promise<Conversation>` | Set the group icon from an already-uploaded file (admin only). Call `client.uploadFiles()` first to get the `fileId`, then pass it here. Server copies `storageKey` into `conversation.iconMeta`, deletes the `chat_files` record, and returns the conversation with a fresh `iconUrl`. |
791
849
  | `delete` | `(conversationId: string) => Promise<void>` | Delete a conversation (admin only). |
792
850
  | `addParticipants` | `(conversationId: string, userIds: string[]) => Promise<Conversation>` | Add one or more participants. |
793
851
  | `removeParticipant` | `(conversationId: string, userId: string) => Promise<Conversation>` | Remove a participant. |
@@ -829,7 +887,6 @@ interface ConversationListParams {
829
887
  interface CreateGroupData {
830
888
  name: string;
831
889
  description?: string;
832
- icon?: string; // Emoji or short string used as the group avatar
833
890
  participantIds: string[];
834
891
  }
835
892
 
@@ -840,7 +897,6 @@ interface CreateDirectData {
840
897
  interface UpdateConversationData {
841
898
  name?: string;
842
899
  description?: string;
843
- icon?: string;
844
900
  }
845
901
  ```
846
902
 
@@ -884,6 +940,37 @@ const group = await conversationsApi.createGroup({
884
940
  // Start a DM
885
941
  const dm = await conversationsApi.createDirect({ userId: 'user-b' });
886
942
 
943
+ // ── Group icon ────────────────────────────────────────────────────────────────
944
+ // Upload or replace the group icon (admin only).
945
+ // Uses the SAME presigned URL pipeline as message attachments — platformUploadFn
946
+ // is handled automatically from config, you never pass it explicitly.
947
+ // Always call AFTER createGroup — the group must exist first.
948
+
949
+ // Using AntzChatClient (headless) — one call, same as client.uploadFiles()
950
+ const updated = await client.uploadIcon(group.id, {
951
+ uri: 'blob:http://...', // URL.createObjectURL(file) on web, file URI on RN
952
+ name: 'icon.jpg',
953
+ type: 'image/jpeg',
954
+ size: file.size,
955
+ });
956
+ console.log(updated.iconUrl); // fresh signed URL, regenerated on every response
957
+
958
+ // What client.uploadIcon() does internally (same as attachment upload):
959
+ // 1. uploadFiles([file], conversationId)
960
+ // → POST /storage/presigned-url (creates temp chat_files record)
961
+ // → platformUploadFn uploads binary directly to S3/Azure/local
962
+ // → POST /storage/confirm/:fileId (marks chat_files active)
963
+ // 2. conversationsApi.uploadIcon(conversationId, fileId)
964
+ // → PUT /conversations/:id/icon { fileId }
965
+ // Server: validateAdmin() → copy storageKey into conversation.iconMeta
966
+ // → delete chat_files record (it was only a transport vehicle)
967
+ // → return conversation with fresh iconUrl
968
+
969
+ // iconUrl behaviour:
970
+ // - Never stored in DB — regenerated fresh from iconMeta.storageKey on every response
971
+ // - Previous icon deleted from storage automatically on replace
972
+ // - Non-admins get 403 Forbidden
973
+
887
974
  // Add members
888
975
  await conversationsApi.addParticipants(group.id, ['user-d', 'user-e']);
889
976
 
@@ -935,6 +1022,48 @@ socket?.on('unread_count_changed', ({ conversationId, unreadCount }) => {
935
1022
 
936
1023
  Both events are emitted to the user's **private room** (`user:{tenantId}:{userId}`) so every connected device of the same user receives them simultaneously. This is how reading on your phone automatically clears the badge on your browser tab.
937
1024
 
1025
+ #### Chat icon badge — complete pattern (headless / custom UI)
1026
+
1027
+ When building a custom UI using `chat-core` directly (no web/RN SDK), maintain your own unread state and update it on socket events:
1028
+
1029
+ ```typescript
1030
+ import { conversationsApi, tryGetSocket } from '@antzsoft/chat-core';
1031
+
1032
+ // 1. Cold start — fetch accurate counts from DB
1033
+ const summary = await conversationsApi.getUnreadSummary();
1034
+ let totalUnread = summary.totalUnread;
1035
+ updateBadge(totalUnread); // your UI function
1036
+
1037
+ // 2. While socket is connected — update on every event
1038
+ const socket = tryGetSocket();
1039
+
1040
+ socket?.on('conversation_updated', ({ conversationId, unreadCount }) => {
1041
+ // Server sends accurate DB count per conversation — recalculate total
1042
+ summary.byConversation = summary.byConversation
1043
+ .filter(c => c.conversationId !== conversationId)
1044
+ .concat({ conversationId, unreadCount });
1045
+ totalUnread = summary.byConversation.reduce((s, c) => s + c.unreadCount, 0);
1046
+ updateBadge(totalUnread);
1047
+ });
1048
+
1049
+ socket?.on('unread_count_changed', ({ conversationId }) => {
1050
+ // User read a conversation — clear it from the map
1051
+ summary.byConversation = summary.byConversation
1052
+ .filter(c => c.conversationId !== conversationId);
1053
+ totalUnread = summary.byConversation.reduce((s, c) => s + c.unreadCount, 0);
1054
+ updateBadge(totalUnread);
1055
+ });
1056
+
1057
+ // 3. On foreground / socket reconnect — resync from DB
1058
+ async function onForeground() {
1059
+ const fresh = await conversationsApi.getUnreadSummary();
1060
+ totalUnread = fresh.totalUnread;
1061
+ updateBadge(totalUnread);
1062
+ }
1063
+ ```
1064
+
1065
+ > **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.
1066
+
938
1067
  ---
939
1068
 
940
1069
  ### Storage API (`storageApi` and `uploadBatch`)
@@ -1306,6 +1435,60 @@ unsubscribe();
1306
1435
  disconnectSocket();
1307
1436
  ```
1308
1437
 
1438
+ #### Room membership — auto-join and `joinRoom`
1439
+
1440
+ **On every socket connection, the server automatically joins the user into all their existing conversation rooms.** No client action is needed. The moment `client.connect()` resolves, the user is already subscribed to real-time events for every conversation they belong to.
1441
+
1442
+ This means:
1443
+ - You do **not** need to call `joinRoom` when opening a chat screen.
1444
+ - `new_message`, `typing_indicator`, `read_receipt`, and all other conversation events are received for all conversations from the moment the socket connects.
1445
+ - Calling `joinRoom` on an already-joined room is safe (idempotent) but causes an unnecessary DB access check — avoid it in hot paths.
1446
+
1447
+ **When to call `joinRoom`:**
1448
+
1449
+ There is only one real use case — when the **user is added to a conversation while their socket is already connected** (either someone created a new group and added them, or they were added to an existing group). The server does not auto-join the user's socket to the new room — it only emits `conversation_created` to the user's personal room. The client must call `joinRoom` in response, otherwise the socket will not receive any `new_message`, `typing_indicator`, or other room events for that conversation.
1450
+
1451
+ ```typescript
1452
+ client.socket.on('conversation_created', (conv) => {
1453
+ // Fires when you're added to a new or existing conversation at runtime
1454
+ client.socket.emit.joinRoom(conv.id);
1455
+ });
1456
+ ```
1457
+
1458
+ **Where to add the `new_message` listener:**
1459
+
1460
+ Register it **once at app root level**, right after `client.connect()`. A single listener handles messages from all conversations — use `message.conversationId` to route to the right place. Never add it inside a screen or component.
1461
+
1462
+ ```typescript
1463
+ await client.connect();
1464
+
1465
+ client.socket.on('new_message', (event: NewMessageEvent) => {
1466
+ const { message } = event;
1467
+
1468
+ // Update the open chat view if this conversation is active
1469
+ if (message.conversationId === activeConversationId) {
1470
+ appendMessageToView(message);
1471
+ }
1472
+
1473
+ // Always update the conversation list (last message + unread badge)
1474
+ updateConversationList(message.conversationId, {
1475
+ lastMessage: message,
1476
+ // Don't increment unread if the user sent it or is currently viewing that chat
1477
+ incrementUnread: message.conversationId !== activeConversationId
1478
+ && message.senderId !== currentUserId,
1479
+ });
1480
+ });
1481
+
1482
+ // Only case where joinRoom is needed — new conversation created at runtime
1483
+ client.socket.on('conversation_created', (conv) => {
1484
+ client.socket.emit.joinRoom(conv.id);
1485
+ });
1486
+ ```
1487
+
1488
+ **`leaveRoom`** 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.
1489
+
1490
+ ---
1491
+
1309
1492
  #### `socketEmit` — outbound events
1310
1493
 
1311
1494
  ```typescript
@@ -1320,7 +1503,7 @@ All emit methods that have server responses use a 5-second ack timeout and retur
1320
1503
  | `leaveRoom` | `(conversationId: string) => void` | Leave a conversation room. Fire-and-forget. |
1321
1504
  | `sendMessage` | `(payload: SendMessagePayload) => Promise<unknown>` | Send a message. Ack-based. |
1322
1505
  | `updateMessage` | `(messageId: string, text: string) => Promise<unknown>` | Edit a message. Ack-based. |
1323
- | `deleteMessage` | `(messageId: string) => Promise<unknown>` | Delete a message for everyone (own message within window, or admin). Ack-based. |
1506
+ | `deleteMessage` | `(messageId: string) => Promise<unknown>` | Delete a message for everyone. Own messages must be within the delete window (default 30 min); group admins can delete any message with no time restriction. Ack-based. |
1324
1507
  | `deleteMessageForMe` | `(messageId: string) => Promise<unknown>` | Hide a message for the current user only. Ack-based. |
1325
1508
  | `addReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Add a reaction. Ack-based. |
1326
1509
  | `removeReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Remove a reaction. Ack-based. |
@@ -1360,21 +1543,29 @@ console.log('Online:', onlineIds); // ['user-a', 'user-c']
1360
1543
 
1361
1544
  Subscribe using `client.socket.on(event, handler)` (headless) or directly on the Socket.IO socket via `getSocket().on(event, handler)`.
1362
1545
 
1363
- | Event | Payload type | Description |
1364
- |---|---|---|
1365
- | `new_message` | `NewMessageEvent` | A new message was sent to a room you've joined. |
1366
- | `message_updated` | `MessageUpdatedEvent` | A message was edited. |
1367
- | `message_deleted` | `MessageDeletedEvent` | A message was deleted for everyone. |
1368
- | `message_deleted_for_me` | `{ messageId: string }` | A message was hidden for the current user only (fired only to that user). |
1369
- | `reaction_updated` | `ReactionUpdatedEvent` | Reactions on a message changed (full reaction array). |
1370
- | `typing_indicator` | `TypingIndicatorEvent` | A user started or stopped typing. |
1371
- | `user_status` | `UserStatusEvent` | A user's online/offline/away status changed. |
1372
- | `read_receipt` | `ReadReceiptEvent` | A user read messages in a conversation. |
1373
- | `message_ack` | `MessageAckEvent` | Server confirmation for a message you sent via socket (maps tempId to the real messageId). |
1374
- | `messages_delivered` | `MessagesDeliveredEvent` | Messages you sent were delivered to a recipient. |
1375
- | `conversation_created` | `Conversation` | A new conversation was created (or you were added to one). |
1376
- | `conversation_updated` | `Conversation` | A conversation's metadata was changed. |
1377
- | `conversation_deleted` | `{ conversationId: string }` | A conversation was deleted. |
1546
+ | Event | Payload type | Description | Where to listen / unlisten |
1547
+ |---|---|---|---|
1548
+ | `new_message` | `NewMessageEvent` | New message in any of the user's conversations — all rooms are auto-joined on connect. | **App root** after `client.connect()`. Never remove — must stay alive for the full app session to keep the conversation list and unread badges up to date. |
1549
+ | `message_updated` | `MessageUpdatedEvent` | A message was edited. | **Chat detail screen** — add on mount, remove on unmount. |
1550
+ | `message_deleted` | `MessageDeletedEvent` | A message was deleted for everyone. | **Chat detail screen** — add on mount, remove on unmount. |
1551
+ | `message_deleted_for_me` | `{ messageId: string; conversationId: string }` | A message was hidden for the current user only (fired only to that user's socket). | **Chat detail screen** — add on mount, remove on unmount. |
1552
+ | `reaction_updated` | `ReactionUpdatedEvent` | Reactions on a message changed (full reaction array). | **Chat detail screen** — add on mount, remove on unmount. |
1553
+ | `message_pin_updated` | `{ messageId: string; conversationId: string; isPinned: boolean; pinnedBy?: string; pinnedAt?: string }` | A message was pinned or unpinned. | **Chat detail screen** — add on mount, remove on unmount. |
1554
+ | `typing_indicator` | `TypingIndicatorEvent` | A user started or stopped typing. | **Chat detail screen** — add on mount, remove on unmount. |
1555
+ | `user_online` | `{ userId: string }` | A participant came online auto-updates `useChatStore.onlineUsers`. | **App root** — drives online indicators everywhere. Keep for full session. |
1556
+ | `user_offline` | `{ userId: string; lastSeen: string }` | A participant went offline auto-updates `useChatStore.lastSeen`. | **App root** drives online indicators and last-seen everywhere. Keep for full session. |
1557
+ | `user_status` | `UserStatusEvent` | Global presence broadcast (online/offline/away) to all connected clients. | **App root** — keep for full session. |
1558
+ | `online_users` | `string[]` | Full list of online user IDs sent on initial room join. | **App root** — keep for full session. |
1559
+ | `read_receipt` | `ReadReceiptEvent` | A user read messages in a conversation. | **Chat detail screen** (to update tick marks) + **app root** (to clear your own unread count when read on another device). |
1560
+ | `unread_count_changed` | `{ conversationId: string; unreadCount: number; userId: string }` | Your unread count changed for a conversation (fired to your personal room on all devices). | **App root / conversation list screen** — keep alive as long as the list is rendered. |
1561
+ | `message_ack` | `MessageAckEvent` | Server confirmation for a message you sent via socket (maps tempId to the real messageId). | **Chat detail screen** — add on mount, remove on unmount. |
1562
+ | `message_delivered` | `{ messageId: string; conversationId: string; deliveredAt: string }` | A single message you sent was delivered to all active recipients. | **Chat detail screen** — add on mount, remove on unmount. |
1563
+ | `messages_delivered` | `MessagesDeliveredEvent` | Batch delivery catch-up — fired when a recipient comes online and your pending messages are delivered. | **Chat detail screen** — add on mount, remove on unmount. |
1564
+ | `conversation_created` | `Conversation` | A new conversation was created (or you were added to one). | **App root** — call `joinRoom` here for the new conversation. Keep for full session. |
1565
+ | `conversation_updated` | `Conversation` | A conversation's last message or metadata changed — use this to update the conversation list. | **App root** — 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. |
1566
+ | `conversation_deleted` | `{ conversationId: string }` | A conversation was deleted. | **App root / conversation list screen** — remove from state and navigate away if it was open. |
1567
+ | `participant_joined` | `{ conversationId: string; userId: string; displayName: string; addedBy: string }` | A new participant was added to a group conversation. | **Chat detail screen** — add on mount, remove on unmount. |
1568
+ | `participant_left` | `{ conversationId: string; userId: string; displayName: string; removedBy?: string }` | A participant left or was removed from a group conversation. | **Chat detail screen** — add on mount, remove on unmount. |
1378
1569
 
1379
1570
  ```typescript
1380
1571
  import type {
@@ -1541,7 +1732,7 @@ The socket listeners for `read_receipt`, `user_online`, and `user_offline` are w
1541
1732
 
1542
1733
  #### Seeding state on initial load
1543
1734
 
1544
- The store starts empty. On first render, fetch the initial values from the API and seed the store:
1735
+ The store starts empty. On first render, fetch the initial values from the API and seed the store. Once seeded, `lastReadMessageId` can also be used as a cursor to [jump to the first unread message](#jump-to-first-unread-message) using `messagesApi.list()` with `direction: 'after'`.
1545
1736
 
1546
1737
  ```typescript
1547
1738
  import { messagesApi, usersApi, useChatStore, type LastReadEntry } from '@antzsoft/chat-core';
@@ -1713,8 +1904,7 @@ interface Conversation {
1713
1904
  conversationType: 'direct' | 'group';
1714
1905
  name?: string;
1715
1906
  description?: string;
1716
- icon?: string;
1717
- iconUrl?: string;
1907
+ iconUrl?: string; // Fresh signed URL generated on each response — never stored directly
1718
1908
  participants: Participant[];
1719
1909
  participantCount?: number;
1720
1910
  settings?: ConversationSettings;
package/dist/index.cjs CHANGED
@@ -514,6 +514,18 @@ var conversationsApi = {
514
514
  async getUnreadSummary() {
515
515
  const { data } = await getApiClient().get("/conversations/unread");
516
516
  return data;
517
+ },
518
+ /**
519
+ * Set the group icon from an already-uploaded file (admin only).
520
+ * The fileId comes from uploadBatch() / client.uploadFiles() — same as attachments.
521
+ * Server copies storageKey into conversation.iconMeta and deletes the chat_files record.
522
+ */
523
+ async uploadIcon(conversationId, fileId) {
524
+ const { data } = await getApiClient().put(
525
+ `/conversations/${conversationId}/icon`,
526
+ { fileId }
527
+ );
528
+ return normalizeConversation(data);
517
529
  }
518
530
  };
519
531
 
@@ -972,6 +984,12 @@ var AntzChatClient = class {
972
984
  uploadFiles(files, conversationId) {
973
985
  return uploadBatch(files, this._config.platformUploadFn, conversationId, this._config.upload.onProgress);
974
986
  }
987
+ async uploadIcon(conversationId, file) {
988
+ const result = await this.uploadFiles([file], conversationId);
989
+ const fileId = result.successful[0]?.id;
990
+ if (!fileId) throw new Error("Icon upload failed");
991
+ return conversationsApi.uploadIcon(conversationId, fileId);
992
+ }
975
993
  };
976
994
  // Annotate the CommonJS export names for ESM import in node:
977
995
  0 && (module.exports = {