@antzsoft/chat-core 1.0.2 → 1.0.4

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
@@ -525,28 +525,61 @@ const client = new AntzChatClient({
525
525
  });
526
526
  ```
527
527
 
528
- ### Avatar for non-builtin modes
528
+ ### Avatar
529
529
 
530
- Pass the user's profile picture on init. The server fetches the URL (or decodes the base64), validates it (supported formats: JPEG, PNG, GIF, WebP; max **5 MB** by default), uploads it to its own storage, and serves it back as a **15-minute signed URL**. SHA-256 hash deduplication ensures the image is only re-uploaded when it actually changes — repeat connections are a no-op.
530
+ There are three ways to set or update a user's avatar:
531
531
 
532
- Server-side size limit is configurable via `AVATAR_MAX_SIZE` (bytes):
532
+ #### 1. Config on init (any mode)
533
533
 
534
- ```env
535
- AVATAR_MAX_SIZE=5242880 # 5 MB default — change to any value in bytes
536
- ```
534
+ Pass the avatar when constructing `AntzChatClient`. The server fetches the URL (or decodes the base64), validates, uploads to its own storage, and deduplicates by SHA-256 hash — repeat connections are a no-op.
537
535
 
538
536
  ```typescript
539
537
  const client = new AntzChatClient({
540
538
  // ...
541
539
  avatar: {
542
- url: 'https://cdn.yoursystem.com/avatars/user-58.jpg', // server fetches this
540
+ url: 'https://cdn.yoursystem.com/avatars/user-58.jpg',
543
541
  // OR
544
- // base64: 'data:image/jpeg;base64,...', // server decodes this
542
+ // base64: 'data:image/jpeg;base64,...',
545
543
  },
546
544
  });
547
545
  ```
548
546
 
549
- If the client doesn't provide an avatar nothing breaks. The user simply has no profile picture until one is provided.
547
+ #### 2. `client.auth.syncAvatar()`post-init update (any mode)
548
+
549
+ Call this any time after init to push a new avatar from a URL or base64 string. Use this when the avatar changes after the client is already running — for example when the user updates their profile in your external system.
550
+
551
+ ```typescript
552
+ // From a URL
553
+ await client.auth.syncAvatar({ url: 'https://cdn.example.com/new-avatar.jpg' });
554
+
555
+ // From base64
556
+ await client.auth.syncAvatar({ base64: 'data:image/jpeg;base64,...' });
557
+ ```
558
+
559
+ #### 3. `client.auth.uploadAvatar()` — file upload (builtin mode only)
560
+
561
+ Use when the user picks a file from their device. Sends as multipart to `PUT /users/me/avatar`.
562
+
563
+ ```typescript
564
+ const file = input.files[0]; // File from <input type="file">
565
+ const { avatarUrl } = await client.auth.uploadAvatar(file);
566
+ ```
567
+
568
+ **Which to use:**
569
+
570
+ | Scenario | Method |
571
+ |---|---|
572
+ | Avatar known at init and won't change | `AntzChatConfig.avatar` |
573
+ | Avatar URL changes at runtime | `client.auth.syncAvatar({ url })` |
574
+ | User picks a file to upload (builtin mode) | `client.auth.uploadAvatar(file)` |
575
+
576
+ Supported formats: JPEG, PNG, GIF, WebP — max **5 MB** by default. Server-side limit is configurable:
577
+
578
+ ```env
579
+ AVATAR_MAX_SIZE=5242880 # 5 MB default — change to any value in bytes
580
+ ```
581
+
582
+ If no avatar is provided — nothing breaks. The user simply has no profile picture until one is set.
550
583
 
551
584
  ---
552
585
 
@@ -605,7 +638,7 @@ client.socket.emit.joinRoom('conv-abc');
605
638
  await client.socket.emit.sendMessage({ conversationId: 'conv-abc', text: 'Hi', tempId: 'tmp-1' });
606
639
 
607
640
  // REST calls
608
- const convs = await client.conversations.list({ page: 1, limit: 20 });
641
+ const convs = await client.conversations.list(); // returns all conversations
609
642
  const msgs = await client.messages.list('conv-abc', { limit: 50 });
610
643
 
611
644
  // Upload files
@@ -654,6 +687,15 @@ const { user } = await authApi.register({
654
687
 
655
688
  // Logout
656
689
  await authApi.logout(tokens.refreshToken);
690
+
691
+ // Upload avatar (builtin mode — user picks a file)
692
+ const { avatarUrl } = await authApi.uploadAvatar(file);
693
+
694
+ // Sync avatar from URL (any mode — use when avatar changes after init)
695
+ const { avatarUrl } = await authApi.syncAvatar({ url: 'https://cdn.example.com/avatar.jpg' });
696
+
697
+ // Sync avatar from base64 (any mode)
698
+ const { avatarUrl } = await authApi.syncAvatar({ base64: 'data:image/jpeg;base64,...' });
657
699
  ```
658
700
 
659
701
  ---
@@ -670,18 +712,22 @@ import { messagesApi } from '@antzsoft/chat-core';
670
712
  | `get` | `(messageId: string) => Promise<Message>` | Fetch a single message. |
671
713
  | `send` | `(conversationId: string, payload: SendData) => Promise<Message>` | Send a message via REST (use `socketEmit.sendMessage` for real-time delivery). |
672
714
  | `update` | `(messageId: string, text: string) => Promise<Message>` | Edit message text. |
673
- | `delete` | `(messageId: string) => Promise<void>` | Delete a message. |
715
+ | `delete` | `(messageId: string) => Promise<void>` | Delete a message for everyone (own message within window, or admin). |
716
+ | `deleteForMe` | `(messageId: string) => Promise<void>` | Hide a message for the current user only — other participants are unaffected. |
674
717
  | `addReaction` | `(messageId: string, emoji: string) => Promise<Message>` | Add an emoji reaction. |
675
718
  | `removeReaction` | `(messageId: string, emoji: string) => Promise<Message>` | Remove an emoji reaction. |
676
719
  | `star` | `(messageId: string) => Promise<void>` | Star a message. |
677
720
  | `unstar` | `(messageId: string) => Promise<void>` | Unstar a message. |
678
721
  | `getStarred` | `(params?: { page?: number; limit?: number; conversationId?: string }) => Promise<PaginatedResponse<Message>>` | List starred messages. |
679
722
  | `search` | `(params: SearchParams) => Promise<PaginatedResponse<Message>>` | Full-text message search. |
723
+ | `getLastRead` | `(conversationId: string) => Promise<{ lastReadMessageId: string \| null; lastReadAt: string \| null }>` | Fetch the current user's last-read pointer for a conversation. Use on initial load; after that the store is kept live by socket events. |
680
724
  | `markAsRead` | `(conversationId: string, messageId?: string) => Promise<void>` | Mark messages as read via REST. |
681
725
  | `pin` | `(messageId: string) => Promise<Message>` | Pin a message. |
682
726
  | `unpin` | `(messageId: string) => Promise<Message>` | Unpin a message. |
683
727
  | `getPinned` | `(conversationId: string) => Promise<Message[]>` | List pinned messages in a conversation. |
684
728
 
729
+ > **Delete permissions** — `delete()` (for everyone) requires either: the message belongs to the current user AND was sent within the delete window, OR the current user is a group admin. The server default window is **1800 s (30 min)** when `conversation.settings.messageConfig.deleteWindowSeconds` is not set. DMs have no admin role — only the sender can delete for everyone in a DM. `deleteForMe()` is always allowed for any message. See the [integration guide — Edit & Delete section](docs/integration-guide.html#step-edit) for a ready-to-use `getDeleteOptions()` helper.
730
+
685
731
  ```typescript
686
732
  interface ListMessagesParams {
687
733
  cursor?: string; // Opaque cursor for pagination
@@ -737,11 +783,12 @@ import { conversationsApi } from '@antzsoft/chat-core';
737
783
 
738
784
  | Method | Signature | Description |
739
785
  |---|---|---|
740
- | `list` | `(params?: { page?: number; limit?: number }) => Promise<PaginatedResponse<Conversation>>` | List all conversations the current user is part of. |
786
+ | `list` | `(params?: ConversationListParams) => Promise<PaginatedResponse<Conversation>>` | List conversations with optional server-side filters. |
741
787
  | `get` | `(conversationId: string) => Promise<Conversation>` | Fetch a single conversation. |
742
788
  | `createGroup` | `(data: CreateGroupData) => Promise<Conversation>` | Create a group conversation. |
743
789
  | `createDirect` | `(data: CreateDirectData) => Promise<Conversation>` | Start or retrieve a direct conversation with another user. |
744
- | `update` | `(conversationId: string, data: UpdateConversationData) => Promise<Conversation>` | Update group name, description, or icon. |
790
+ | `update` | `(conversationId: string, data: UpdateConversationData) => Promise<Conversation>` | Update group name or description. |
791
+ | `uploadIcon` | `(conversationId: string, file: File \| { uri: string; name: string; type: string }) => Promise<Conversation>` | Upload or replace the group icon (admin only). Deletes previous icon from storage. Returns conversation with fresh `iconUrl`. |
745
792
  | `delete` | `(conversationId: string) => Promise<void>` | Delete a conversation (admin only). |
746
793
  | `addParticipants` | `(conversationId: string, userIds: string[]) => Promise<Conversation>` | Add one or more participants. |
747
794
  | `removeParticipant` | `(conversationId: string, userId: string) => Promise<Conversation>` | Remove a participant. |
@@ -752,13 +799,37 @@ import { conversationsApi } from '@antzsoft/chat-core';
752
799
  | `unpin` | `(conversationId: string) => Promise<void>` | Unpin a conversation. |
753
800
  | `leave` | `(conversationId: string) => Promise<void>` | Leave a group conversation. |
754
801
  | `getMembers` | `(conversationId: string) => Promise<User[]>` | Fetch full user profiles for all participants. |
755
- | `searchUsers` | `(query: string) => Promise<User[]>` | Search users by name or email (for adding to conversations). |
802
+
803
+ ```typescript
804
+ interface ConversationListParams {
805
+ /** Omit both page and limit to receive all results in one response */
806
+ page?: number;
807
+ limit?: number;
808
+ /** Filter by conversation type */
809
+ type?: 'direct' | 'group';
810
+ /** Only pinned (true) or unpinned (false) conversations */
811
+ isPinned?: boolean;
812
+ /** Only muted (true) or unmuted (false) conversations */
813
+ isMuted?: boolean;
814
+ /** Only conversations with at least one unread message */
815
+ hasUnread?: boolean;
816
+ /** Search by group name / description — uses the server-side text index */
817
+ search?: string;
818
+ /** Filter by the current user's role in the conversation */
819
+ role?: 'admin' | 'member';
820
+ /** Filter by whether the last message has attachments */
821
+ hasAttachments?: boolean;
822
+ /** Filter by last message attachment type */
823
+ attachmentType?: 'image' | 'video' | 'document' | 'audio';
824
+ /** Filter by notification enabled/disabled for the current user */
825
+ notificationsEnabled?: boolean;
826
+ }
827
+ ```
756
828
 
757
829
  ```typescript
758
830
  interface CreateGroupData {
759
831
  name: string;
760
832
  description?: string;
761
- icon?: string; // Emoji or short string used as the group avatar
762
833
  participantIds: string[];
763
834
  }
764
835
 
@@ -769,10 +840,39 @@ interface CreateDirectData {
769
840
  interface UpdateConversationData {
770
841
  name?: string;
771
842
  description?: string;
772
- icon?: string;
773
843
  }
774
844
  ```
775
845
 
846
+ ```typescript
847
+ // All conversations (no page/limit = server returns everything)
848
+ const { data } = await conversationsApi.list();
849
+
850
+ // Filter by type
851
+ const groups = await conversationsApi.list({ type: 'group' });
852
+ const dms = await conversationsApi.list({ type: 'direct' });
853
+
854
+ // Filter pinned / muted / unread
855
+ const pinned = await conversationsApi.list({ isPinned: true });
856
+ const muted = await conversationsApi.list({ isMuted: true });
857
+ const unread = await conversationsApi.list({ hasUnread: true });
858
+
859
+ // Text search (uses server-side MongoDB text index on name + description)
860
+ const results = await conversationsApi.list({ search: 'design' });
861
+
862
+ // Filter by current user's role
863
+ const adminConvs = await conversationsApi.list({ role: 'admin' });
864
+
865
+ // Combine filters — pinned group conversations with unread messages
866
+ const urgent = await conversationsApi.list({
867
+ type: 'group',
868
+ isPinned: true,
869
+ hasUnread: true,
870
+ });
871
+
872
+ // Explicit pagination (pass page + limit to opt in)
873
+ const page1 = await conversationsApi.list({ page: 1, limit: 20 });
874
+ ```
875
+
776
876
  ```typescript
777
877
  // Create a group
778
878
  const group = await conversationsApi.createGroup({
@@ -789,8 +889,93 @@ await conversationsApi.addParticipants(group.id, ['user-d', 'user-e']);
789
889
  // Mute for 8 hours
790
890
  const mutedUntil = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString();
791
891
  await conversationsApi.mute(group.id, mutedUntil);
892
+
893
+ // ── Unread counts ──────────────────────────────────────────────────────────
894
+
895
+ // Total unread across all conversations + per-conversation breakdown
896
+ const summary = await conversationsApi.getUnreadSummary();
897
+ // summary.totalUnread → 12
898
+ // summary.byConversation → [{ conversationId, unreadCount }, ...]
899
+
900
+ // Unread count for one specific conversation
901
+ const { unreadCount } = await conversationsApi.getUnreadCount(conversationId);
902
+ ```
903
+
904
+ #### When to call unread APIs vs relying on socket
905
+
906
+ The socket keeps unread counts live while the app is connected. The REST APIs are the **source of truth** for everything else:
907
+
908
+ | Situation | What to do |
909
+ |---|---|
910
+ | App cold start | Call `getUnreadSummary()` — hydrate local state before socket connects |
911
+ | App comes to foreground | Call `getUnreadSummary()` — catch up on anything received while socket was down |
912
+ | Socket reconnects after drop | Call `getUnreadSummary()` — reconcile any drift during the outage |
913
+ | Push notification opens a specific chat | Call `getUnreadCount(conversationId)` — refresh just that conversation |
914
+ | User is actively chatting (socket connected) | Use `unreadCount` from `conversationsApi.list()` or the `conversation_updated` socket event — no need to poll REST |
915
+
916
+ #### Socket events for real-time unread updates
917
+
918
+ ```typescript
919
+ import { tryGetSocket } from '@antzsoft/chat-core';
920
+
921
+ const socket = tryGetSocket();
922
+
923
+ // Fires when a new message arrives — updates unreadCount for that conversation
924
+ socket?.on('conversation_updated', ({ conversationId, unreadCount }) => {
925
+ // unreadCount is calculated server-side from the DB — always accurate
926
+ });
927
+
928
+ // Fires when YOU mark a conversation as read — resets unreadCount to 0
929
+ // Also fires on your OTHER devices (same user, different session)
930
+ socket?.on('unread_count_changed', ({ conversationId, unreadCount }) => {
931
+ // unreadCount is 0 here — conversation was just read
932
+ });
792
933
  ```
793
934
 
935
+ 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.
936
+
937
+ #### Chat icon badge — complete pattern (headless / custom UI)
938
+
939
+ When building a custom UI using `chat-core` directly (no web/RN SDK), maintain your own unread state and update it on socket events:
940
+
941
+ ```typescript
942
+ import { conversationsApi, tryGetSocket } from '@antzsoft/chat-core';
943
+
944
+ // 1. Cold start — fetch accurate counts from DB
945
+ const summary = await conversationsApi.getUnreadSummary();
946
+ let totalUnread = summary.totalUnread;
947
+ updateBadge(totalUnread); // your UI function
948
+
949
+ // 2. While socket is connected — update on every event
950
+ const socket = tryGetSocket();
951
+
952
+ socket?.on('conversation_updated', ({ conversationId, unreadCount }) => {
953
+ // Server sends accurate DB count per conversation — recalculate total
954
+ summary.byConversation = summary.byConversation
955
+ .filter(c => c.conversationId !== conversationId)
956
+ .concat({ conversationId, unreadCount });
957
+ totalUnread = summary.byConversation.reduce((s, c) => s + c.unreadCount, 0);
958
+ updateBadge(totalUnread);
959
+ });
960
+
961
+ socket?.on('unread_count_changed', ({ conversationId }) => {
962
+ // User read a conversation — clear it from the map
963
+ summary.byConversation = summary.byConversation
964
+ .filter(c => c.conversationId !== conversationId);
965
+ totalUnread = summary.byConversation.reduce((s, c) => s + c.unreadCount, 0);
966
+ updateBadge(totalUnread);
967
+ });
968
+
969
+ // 3. On foreground / socket reconnect — resync from DB
970
+ async function onForeground() {
971
+ const fresh = await conversationsApi.getUnreadSummary();
972
+ totalUnread = fresh.totalUnread;
973
+ updateBadge(totalUnread);
974
+ }
975
+ ```
976
+
977
+ > **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.
978
+
794
979
  ---
795
980
 
796
981
  ### Storage API (`storageApi` and `uploadBatch`)
@@ -853,19 +1038,21 @@ result.failed.forEach((f) => console.error('Failed:', f.filename, f.error));
853
1038
 
854
1039
  Used for push notification token registration. The SDK does not call this automatically — the host app is responsible for obtaining the device token from the OS and registering it.
855
1040
 
1041
+ The server stores one token document per physical device in `chat_device_tokens`. A single user can have multiple active tokens (phone + tablet + browser) — the server delivers push to all of them simultaneously.
1042
+
856
1043
  ```typescript
857
1044
  import { devicesApi } from '@antzsoft/chat-core';
858
1045
  ```
859
1046
 
860
1047
  | Method | Signature | Description |
861
1048
  |---|---|---|
862
- | `register` | `(payload: RegisterDeviceTokenPayload) => Promise<void>` | Register or refresh a push token. Upserts by `deviceId`. |
863
- | `remove` | `(deviceId: string) => Promise<void>` | Remove a device token. Call this on logout to stop push delivery. |
1049
+ | `register` | `(payload: RegisterDeviceTokenPayload) => Promise<void>` | Register or refresh a push token. Upserts by `deviceId` — safe to call on every app launch. |
1050
+ | `remove` | `(deviceId: string) => Promise<void>` | Deactivate a device token. Call on logout or when the user disables notifications. |
864
1051
 
865
1052
  ```typescript
866
1053
  type RegisterDeviceTokenPayload =
867
1054
  | {
868
- deviceId: string; // Stable UUID — generate once and persist
1055
+ deviceId: string; // Stable UUID — generate once on install, persist, never regenerate
869
1056
  platform: 'ios' | 'android' | 'web';
870
1057
  provider: 'expo' | 'fcm' | 'apns';
871
1058
  token: string; // The OS-issued push token
@@ -882,20 +1069,239 @@ type RegisterDeviceTokenPayload =
882
1069
  };
883
1070
  ```
884
1071
 
1072
+ #### The `deviceId` rule
1073
+
1074
+ `deviceId` must be a **stable UUID that persists across app restarts**. Generate it once on first install and store it in `SecureStore` / `AsyncStorage` (mobile) or `localStorage` (web). If you lose it, the old token becomes an orphan in the server DB and the user may receive duplicate notifications until the stale token expires.
1075
+
1076
+ #### When to call `register()`
1077
+
1078
+ **Mobile:** Call on every app launch after authentication. Expo and FCM can silently rotate tokens after OS upgrades or reinstalls. Calling on every launch ensures the server always has the current token — the upsert is a no-op if the token hasn't changed.
1079
+
1080
+ **Web:** Call on app init if a subscription already exists (to refresh `lastUsedAt` and catch silent endpoint rotation), and again when the user explicitly enables notifications.
1081
+
1082
+ #### When to call `remove()`
1083
+
1084
+ Call on logout, or when the user disables push in your settings UI. This sets the token to inactive on the server — push stops immediately for that device. Other devices belonging to the same user are unaffected.
1085
+
885
1086
  ```typescript
886
- // Mobile (Expo)
1087
+ // ── Mobile (Expo) ──────────────────────────────────────────────────────────
887
1088
  import * as Notifications from 'expo-notifications';
1089
+ import * as Device from 'expo-device';
1090
+ import * as SecureStore from 'expo-secure-store';
1091
+ import * as Crypto from 'expo-crypto';
1092
+
1093
+ async function getStableDeviceId(): Promise<string> {
1094
+ const existing = await SecureStore.getItemAsync('chat-device-id');
1095
+ if (existing) return existing;
1096
+ const id = Crypto.randomUUID();
1097
+ await SecureStore.setItemAsync('chat-device-id', id);
1098
+ return id;
1099
+ }
1100
+
1101
+ // Call after every login / on every app launch after auth
1102
+ async function registerPushToken(): Promise<void> {
1103
+ if (!Device.isDevice) return; // simulators cannot receive push
1104
+ const { status } = await Notifications.requestPermissionsAsync();
1105
+ if (status !== 'granted') return;
1106
+ const { data: token } = await Notifications.getExpoPushTokenAsync();
1107
+ await devicesApi.register({
1108
+ deviceId: await getStableDeviceId(),
1109
+ platform: Device.osName === 'iOS' ? 'ios' : 'android',
1110
+ provider: 'expo',
1111
+ token,
1112
+ });
1113
+ }
1114
+
1115
+ // On logout
1116
+ await devicesApi.remove(await getStableDeviceId());
1117
+
1118
+ // ── Mobile (FCM — direct Firebase, no Expo) ────────────────────────────────
1119
+ import messaging from '@react-native-firebase/messaging';
888
1120
 
889
- const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync();
890
1121
  await devicesApi.register({
891
- deviceId: await getStableDeviceId(), // from SecureStore
892
- platform: 'ios',
893
- provider: 'expo',
894
- token: expoPushToken,
1122
+ deviceId: await getStableDeviceId(),
1123
+ platform: 'android',
1124
+ provider: 'fcm',
1125
+ token: await messaging().getToken(),
895
1126
  });
896
1127
 
897
- // On logout
898
- await devicesApi.remove(deviceId);
1128
+ // FCM tokens can rotate — re-register on rotation
1129
+ messaging().onTokenRefresh(async (newToken) => {
1130
+ await devicesApi.register({
1131
+ deviceId: await getStableDeviceId(),
1132
+ platform: 'android',
1133
+ provider: 'fcm',
1134
+ token: newToken,
1135
+ });
1136
+ });
1137
+
1138
+ // ── Web (VAPID) ────────────────────────────────────────────────────────────
1139
+ function getStableDeviceId(): string {
1140
+ let id = localStorage.getItem('chat-device-id');
1141
+ if (!id) { id = crypto.randomUUID(); localStorage.setItem('chat-device-id', id); }
1142
+ return id;
1143
+ }
1144
+
1145
+ function subToPayload(sub: PushSubscription, deviceId: string) {
1146
+ const b64 = (buf: ArrayBuffer | null) =>
1147
+ buf ? btoa(String.fromCharCode(...new Uint8Array(buf))) : '';
1148
+ return {
1149
+ deviceId, platform: 'web' as const, provider: 'web-push' as const,
1150
+ endpoint: sub.endpoint,
1151
+ p256dh: b64(sub.getKey('p256dh')),
1152
+ auth: b64(sub.getKey('auth')),
1153
+ };
1154
+ }
1155
+
1156
+ // On app init after login — re-registers any existing subscription
1157
+ async function syncPushSubscription(): Promise<void> {
1158
+ if (!('serviceWorker' in navigator) || Notification.permission !== 'granted') return;
1159
+ const reg = await navigator.serviceWorker.ready;
1160
+ const existing = await reg.pushManager.getSubscription();
1161
+ if (existing) await devicesApi.register(subToPayload(existing, getStableDeviceId()));
1162
+ }
1163
+
1164
+ // When user clicks "Enable notifications"
1165
+ async function enablePushNotifications(): Promise<void> {
1166
+ const permission = await Notification.requestPermission();
1167
+ if (permission !== 'granted') return;
1168
+ const reg = await navigator.serviceWorker.ready;
1169
+ const sub = await reg.pushManager.subscribe({
1170
+ userVisibleOnly: true,
1171
+ applicationServerKey: YOUR_VAPID_PUBLIC_KEY,
1172
+ });
1173
+ await devicesApi.register(subToPayload(sub, getStableDeviceId()));
1174
+ }
1175
+
1176
+ // On logout or "Disable notifications"
1177
+ async function disablePushNotifications(): Promise<void> {
1178
+ const reg = await navigator.serviceWorker.ready;
1179
+ const sub = await reg.pushManager.getSubscription();
1180
+ if (sub) await sub.unsubscribe();
1181
+ await devicesApi.remove(getStableDeviceId());
1182
+ }
1183
+ ```
1184
+
1185
+ #### Token lifecycle
1186
+
1187
+ | Event | Action |
1188
+ |---|---|
1189
+ | App launch after login (mobile) | Call `register()` — upsert handles token rotation automatically |
1190
+ | App init after login (web, subscription exists) | Call `syncPushSubscription()` — refreshes `lastUsedAt`, catches silent endpoint rotation |
1191
+ | User enables notifications (web) | Call `enablePushNotifications()` — requests permission, subscribes, registers |
1192
+ | User logs out (any platform) | Call `remove(deviceId)` |
1193
+ | FCM / Expo token rotates | Call `register()` again with new token — upsert updates the existing record |
1194
+ | Notification engine gets `DeviceNotRegistered` from Expo/FCM/VAPID | Engine auto-marks token inactive — no action needed in app |
1195
+
1196
+ ---
1197
+
1198
+ ### Notification Preferences (`usersApi.updatePreferences` / `usersApi.getPreferences`)
1199
+
1200
+ Notification preferences are stored per user in `chat_user_prefs` on the server. A preferences record with all defaults is created automatically when a device token is first registered — so this API is always available after push registration.
1201
+
1202
+ All fields are optional on update — only send what changed. Any future preference field is added to this same collection and the same API; no new endpoints needed.
1203
+
1204
+ ```typescript
1205
+ import { usersApi } from '@antzsoft/chat-core';
1206
+ import type { UserPreferences } from '@antzsoft/chat-core';
1207
+ ```
1208
+
1209
+ | Method | Description |
1210
+ |---|---|
1211
+ | `getPreferences()` | Fetch current preferences. Returns `null` if no record exists (all defaults apply). |
1212
+ | `updatePreferences(prefs)` | Partial update — only fields you pass are changed. |
1213
+
1214
+ ```typescript
1215
+ // Fetch current preferences
1216
+ const prefs = await usersApi.getPreferences();
1217
+ // prefs is null if no record yet (all defaults apply — everything enabled, quietHours off)
1218
+
1219
+ // Disable reaction notifications
1220
+ await usersApi.updatePreferences({ notifyOnReaction: false });
1221
+
1222
+ // Hide message content from notification body
1223
+ await usersApi.updatePreferences({ messagePreview: false });
1224
+
1225
+ // Enable quiet hours (no push 23:00–07:00 IST)
1226
+ await usersApi.updatePreferences({
1227
+ quietHours: { enabled: true, start: '23:00', end: '07:00', timezone: 'Asia/Kolkata' },
1228
+ });
1229
+
1230
+ // Turn off all notifications (master switch)
1231
+ await usersApi.updatePreferences({ notificationsEnabled: false });
1232
+ ```
1233
+
1234
+ #### `UserPreferences` type
1235
+
1236
+ ```typescript
1237
+ interface UserPreferences {
1238
+ /** Master switch — false disables all push. Default: true */
1239
+ notificationsEnabled?: boolean;
1240
+
1241
+ /** Play sound with notifications. Default: true */
1242
+ soundEnabled?: boolean;
1243
+
1244
+ /** Show message text in notification body.
1245
+ * false = show "New message" only (privacy mode). Default: true */
1246
+ messagePreview?: boolean;
1247
+
1248
+ /** Notify when @mentioned in a group. Default: true */
1249
+ notifyOnMention?: boolean;
1250
+
1251
+ /** Notify when someone reacts to your message. Default: true */
1252
+ notifyOnReaction?: boolean;
1253
+
1254
+ /** Notify when added to a group. Default: true */
1255
+ notifyOnGroupInvite?: boolean;
1256
+
1257
+ /** Quiet hours — no push delivered during this window. Default: disabled */
1258
+ quietHours?: {
1259
+ enabled: boolean;
1260
+ start: string; // HH:MM — e.g. "22:00"
1261
+ end: string; // HH:MM — e.g. "08:00"
1262
+ timezone: string; // IANA — e.g. "Asia/Kolkata"
1263
+ };
1264
+ }
1265
+ ```
1266
+
1267
+ #### Adding future preferences
1268
+
1269
+ Add the new field to the server schema (`user-prefs.schema.ts`) and to the `UserPreferences` interface above. The same `updatePreferences()` / `getPreferences()` API handles it — no new endpoints, no new collections.
1270
+
1271
+ ---
1272
+
1273
+ ### Users API (`usersApi`)
1274
+
1275
+ User data is served directly from the chat server's MongoDB shadow records — no external user-service call is made. Shadow records are kept warm by the server's background sync job (runs on startup and periodically).
1276
+
1277
+ ```typescript
1278
+ import { usersApi } from '@antzsoft/chat-core';
1279
+ ```
1280
+
1281
+ | Method | Signature | Description |
1282
+ |---|---|---|
1283
+ | `list` | `(params?: { query?: string; page?: number; limit?: number }) => Promise<PaginatedResponse<User>>` | List users, optionally filtered by a search query. Omit `page`/`limit` for all results. |
1284
+ | `getById` | `(userId: string) => Promise<User>` | Fetch a single user by their chat system ID. |
1285
+ | `getLastSeen` | `(userId: string) => Promise<{ lastSeenAt: string \| null }>` | Fetch a user's last-seen timestamp. Use on initial load; after that the store is kept live by `user_offline` socket events. |
1286
+
1287
+ All methods return `User` objects that include `externalId` for non-builtin modes.
1288
+
1289
+ ```typescript
1290
+ // All users (no page/limit = server returns everything)
1291
+ const { data } = await usersApi.list();
1292
+
1293
+ // Search by name / username / email
1294
+ const { data: results } = await usersApi.list({ query: 'john' });
1295
+
1296
+ // Paginated (opt in by passing page + limit)
1297
+ const { data, meta } = await usersApi.list({ page: 1, limit: 20 });
1298
+
1299
+ // Search + paginated
1300
+ const { data: results } = await usersApi.list({ query: 'john', page: 1, limit: 10 });
1301
+
1302
+ // Get a specific user
1303
+ const user = await usersApi.getById('64abc...');
1304
+ console.log(user.displayName, user.externalId);
899
1305
  ```
900
1306
 
901
1307
  ---
@@ -941,6 +1347,60 @@ unsubscribe();
941
1347
  disconnectSocket();
942
1348
  ```
943
1349
 
1350
+ #### Room membership — auto-join and `joinRoom`
1351
+
1352
+ **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.
1353
+
1354
+ This means:
1355
+ - You do **not** need to call `joinRoom` when opening a chat screen.
1356
+ - `new_message`, `typing_indicator`, `read_receipt`, and all other conversation events are received for all conversations from the moment the socket connects.
1357
+ - Calling `joinRoom` on an already-joined room is safe (idempotent) but causes an unnecessary DB access check — avoid it in hot paths.
1358
+
1359
+ **When to call `joinRoom`:**
1360
+
1361
+ 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.
1362
+
1363
+ ```typescript
1364
+ client.socket.on('conversation_created', (conv) => {
1365
+ // Fires when you're added to a new or existing conversation at runtime
1366
+ client.socket.emit.joinRoom(conv.id);
1367
+ });
1368
+ ```
1369
+
1370
+ **Where to add the `new_message` listener:**
1371
+
1372
+ 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.
1373
+
1374
+ ```typescript
1375
+ await client.connect();
1376
+
1377
+ client.socket.on('new_message', (event: NewMessageEvent) => {
1378
+ const { message } = event;
1379
+
1380
+ // Update the open chat view if this conversation is active
1381
+ if (message.conversationId === activeConversationId) {
1382
+ appendMessageToView(message);
1383
+ }
1384
+
1385
+ // Always update the conversation list (last message + unread badge)
1386
+ updateConversationList(message.conversationId, {
1387
+ lastMessage: message,
1388
+ // Don't increment unread if the user sent it or is currently viewing that chat
1389
+ incrementUnread: message.conversationId !== activeConversationId
1390
+ && message.senderId !== currentUserId,
1391
+ });
1392
+ });
1393
+
1394
+ // Only case where joinRoom is needed — new conversation created at runtime
1395
+ client.socket.on('conversation_created', (conv) => {
1396
+ client.socket.emit.joinRoom(conv.id);
1397
+ });
1398
+ ```
1399
+
1400
+ **`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.
1401
+
1402
+ ---
1403
+
944
1404
  #### `socketEmit` — outbound events
945
1405
 
946
1406
  ```typescript
@@ -955,7 +1415,8 @@ All emit methods that have server responses use a 5-second ack timeout and retur
955
1415
  | `leaveRoom` | `(conversationId: string) => void` | Leave a conversation room. Fire-and-forget. |
956
1416
  | `sendMessage` | `(payload: SendMessagePayload) => Promise<unknown>` | Send a message. Ack-based. |
957
1417
  | `updateMessage` | `(messageId: string, text: string) => Promise<unknown>` | Edit a message. Ack-based. |
958
- | `deleteMessage` | `(messageId: string) => Promise<unknown>` | Delete a message. Ack-based. |
1418
+ | `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. |
1419
+ | `deleteMessageForMe` | `(messageId: string) => Promise<unknown>` | Hide a message for the current user only. Ack-based. |
959
1420
  | `addReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Add a reaction. Ack-based. |
960
1421
  | `removeReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Remove a reaction. Ack-based. |
961
1422
  | `pinMessage` | `(messageId: string) => Promise<unknown>` | Pin a message. Ack-based. |
@@ -994,20 +1455,29 @@ console.log('Online:', onlineIds); // ['user-a', 'user-c']
994
1455
 
995
1456
  Subscribe using `client.socket.on(event, handler)` (headless) or directly on the Socket.IO socket via `getSocket().on(event, handler)`.
996
1457
 
997
- | Event | Payload type | Description |
998
- |---|---|---|
999
- | `new_message` | `NewMessageEvent` | A new message was sent to a room you've joined. |
1000
- | `message_updated` | `MessageUpdatedEvent` | A message was edited. |
1001
- | `message_deleted` | `MessageDeletedEvent` | A message was deleted. |
1002
- | `reaction_updated` | `ReactionUpdatedEvent` | Reactions on a message changed (full reaction array). |
1003
- | `typing_indicator` | `TypingIndicatorEvent` | A user started or stopped typing. |
1004
- | `user_status` | `UserStatusEvent` | A user's online/offline/away status changed. |
1005
- | `read_receipt` | `ReadReceiptEvent` | A user read messages in a conversation. |
1006
- | `message_ack` | `MessageAckEvent` | Server confirmation for a message you sent via socket (maps tempId to the real messageId). |
1007
- | `messages_delivered` | `MessagesDeliveredEvent` | Messages you sent were delivered to a recipient. |
1008
- | `conversation_created` | `Conversation` | A new conversation was created (or you were added to one). |
1009
- | `conversation_updated` | `Conversation` | A conversation's metadata was changed. |
1010
- | `conversation_deleted` | `{ conversationId: string }` | A conversation was deleted. |
1458
+ | Event | Payload type | Description | Where to listen / unlisten |
1459
+ |---|---|---|---|
1460
+ | `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. |
1461
+ | `message_updated` | `MessageUpdatedEvent` | A message was edited. | **Chat detail screen** — add on mount, remove on unmount. |
1462
+ | `message_deleted` | `MessageDeletedEvent` | A message was deleted for everyone. | **Chat detail screen** — add on mount, remove on unmount. |
1463
+ | `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. |
1464
+ | `reaction_updated` | `ReactionUpdatedEvent` | Reactions on a message changed (full reaction array). | **Chat detail screen** — add on mount, remove on unmount. |
1465
+ | `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. |
1466
+ | `typing_indicator` | `TypingIndicatorEvent` | A user started or stopped typing. | **Chat detail screen** — add on mount, remove on unmount. |
1467
+ | `user_online` | `{ userId: string }` | A participant came online auto-updates `useChatStore.onlineUsers`. | **App root** drives online indicators everywhere. Keep for full session. |
1468
+ | `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. |
1469
+ | `user_status` | `UserStatusEvent` | Global presence broadcast (online/offline/away) to all connected clients. | **App root** — keep for full session. |
1470
+ | `online_users` | `string[]` | Full list of online user IDs — sent on initial room join. | **App root** — keep for full session. |
1471
+ | `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). |
1472
+ | `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. |
1473
+ | `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. |
1474
+ | `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. |
1475
+ | `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. |
1476
+ | `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. |
1477
+ | `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. |
1478
+ | `conversation_deleted` | `{ conversationId: string }` | A conversation was deleted. | **App root / conversation list screen** — remove from state and navigate away if it was open. |
1479
+ | `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. |
1480
+ | `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. |
1011
1481
 
1012
1482
  ```typescript
1013
1483
  import type {
@@ -1134,11 +1604,20 @@ import { useChatStore } from '@antzsoft/chat-core';
1134
1604
  | `pendingTarget` | `{ conversationId: string; messageId: string } \| null` | Scroll-to target for deep-linked messages. |
1135
1605
  | `typingUsers` | `Record<string, TypingUser[]>` | Map of conversationId → users currently typing. |
1136
1606
  | `onlineUsers` | `string[]` | Array of user IDs currently online. |
1607
+ | `lastRead` | `Record<string, LastReadEntry>` | Map of conversationId → `{ messageId, readAt }` — the current user's last-read pointer per conversation. Hydrated by `read_receipt` socket events automatically. |
1608
+ | `lastSeen` | `Record<string, string>` | Map of userId → ISO timestamp — each user's last-seen time. Hydrated by `user_offline` socket events automatically. |
1137
1609
  | `replyingTo` | `Message \| null` | Message being replied to in the composer. |
1138
1610
  | `editingMessage` | `Message \| null` | Message being edited in the composer. |
1139
1611
  | `isSidebarOpen` | `boolean` | Conversation list sidebar visibility. |
1140
1612
  | `isGroupInfoOpen` | `boolean` | Group info panel visibility. |
1141
1613
 
1614
+ ```typescript
1615
+ interface LastReadEntry {
1616
+ messageId: string;
1617
+ readAt: string; // ISO 8601
1618
+ }
1619
+ ```
1620
+
1142
1621
  #### Actions
1143
1622
 
1144
1623
  | Action | Signature | Description |
@@ -1150,6 +1629,8 @@ import { useChatStore } from '@antzsoft/chat-core';
1150
1629
  | `setUserOnline` | `(userId: string) => void` | Mark a user as online. |
1151
1630
  | `setUserOffline` | `(userId: string) => void` | Mark a user as offline. |
1152
1631
  | `setOnlineUsers` | `(userIds: string[]) => void` | Replace the full online users list. |
1632
+ | `setLastRead` | `(conversationId: string, messageId: string, readAt: string) => void` | Update the last-read pointer for a conversation. Called automatically by the `read_receipt` socket listener — only call manually to seed state on initial load. |
1633
+ | `setLastSeen` | `(userId: string, lastSeenAt: string) => void` | Update a user's last-seen timestamp. Called automatically by the `user_offline` socket listener — only call manually to seed state on initial load. |
1153
1634
  | `setReplyingTo` | `(message: Message \| null) => void` | Set reply context; clears `editingMessage`. |
1154
1635
  | `setEditingMessage` | `(message: Message \| null) => void` | Set edit context; clears `replyingTo`. |
1155
1636
  | `toggleSidebar` | `() => void` | Toggle sidebar open/closed. |
@@ -1157,15 +1638,61 @@ import { useChatStore } from '@antzsoft/chat-core';
1157
1638
  | `toggleGroupInfo` | `() => void` | Toggle group info panel. |
1158
1639
  | `setGroupInfoOpen` | `(open: boolean) => void` | Set group info panel state explicitly. |
1159
1640
 
1641
+ #### Automatic socket wiring
1642
+
1643
+ The socket listeners for `read_receipt`, `user_online`, and `user_offline` are wired automatically inside `connectSocket`. You do not need to subscribe to these events yourself — `lastRead` and `lastSeen` in the store are kept up to date as long as the socket is connected.
1644
+
1645
+ #### Seeding state on initial load
1646
+
1647
+ The store starts empty. On first render, fetch the initial values from the API and seed the store:
1648
+
1649
+ ```typescript
1650
+ import { messagesApi, usersApi, useChatStore, type LastReadEntry } from '@antzsoft/chat-core';
1651
+
1652
+ // Seed last-read for the active conversation when it opens
1653
+ const conversationId = 'conv-abc';
1654
+ const { lastReadMessageId, lastReadAt } = await messagesApi.getLastRead(conversationId);
1655
+ if (lastReadMessageId && lastReadAt) {
1656
+ useChatStore.getState().setLastRead(conversationId, lastReadMessageId, lastReadAt);
1657
+ }
1658
+
1659
+ // Seed last-seen for a specific user (e.g. in a DM header)
1660
+ const { lastSeenAt } = await usersApi.getLastSeen('user-xyz');
1661
+ if (lastSeenAt) {
1662
+ useChatStore.getState().setLastSeen('user-xyz', lastSeenAt);
1663
+ }
1664
+ ```
1665
+
1666
+ After seeding, socket events keep the store live — no further fetches are needed.
1667
+
1668
+ #### Reading state in components
1669
+
1160
1670
  ```typescript
1161
- // In a component
1671
+ import { useChatStore, type LastReadEntry } from '@antzsoft/chat-core';
1672
+
1673
+ // Last-read pointer for the open conversation
1674
+ const lastRead: LastReadEntry | undefined = useChatStore(
1675
+ (s) => s.lastRead['conv-abc'],
1676
+ );
1677
+ // lastRead.messageId — scroll here on open
1678
+ // lastRead.readAt — "read up to" timestamp
1679
+
1680
+ // Last-seen for a user (e.g. "Last seen 5 minutes ago" in a DM)
1681
+ const lastSeenAt: string | undefined = useChatStore(
1682
+ (s) => s.lastSeen['user-xyz'],
1683
+ );
1684
+
1685
+ // Online presence
1686
+ const isOnline = useChatStore((s) => s.onlineUsers.includes('user-xyz'));
1687
+ ```
1688
+
1689
+ ```typescript
1690
+ // Typing indicator wiring (still manual — not auto-wired)
1162
1691
  const activeId = useChatStore((s) => s.activeConversationId);
1163
1692
  const typingInActive = useChatStore((s) =>
1164
1693
  activeId ? (s.typingUsers[activeId] ?? []) : [],
1165
1694
  );
1166
- const { setActiveConversation, setReplyingTo } = useChatStore.getState();
1167
1695
 
1168
- // Wire typing indicator events
1169
1696
  client.socket.on('typing_indicator', (evt: TypingIndicatorEvent) => {
1170
1697
  const { addTypingUser, removeTypingUser } = useChatStore.getState();
1171
1698
  if (evt.isTyping) {
@@ -1178,16 +1705,6 @@ client.socket.on('typing_indicator', (evt: TypingIndicatorEvent) => {
1178
1705
  removeTypingUser(evt.conversationId, evt.userId);
1179
1706
  }
1180
1707
  });
1181
-
1182
- // Wire user status events
1183
- client.socket.on('user_status', (evt: UserStatusEvent) => {
1184
- const { setUserOnline, setUserOffline } = useChatStore.getState();
1185
- if (evt.status === 'online') {
1186
- setUserOnline(evt.userId);
1187
- } else {
1188
- setUserOffline(evt.userId);
1189
- }
1190
- });
1191
1708
  ```
1192
1709
 
1193
1710
  ---
@@ -1199,6 +1716,7 @@ client.socket.on('user_status', (evt: UserStatusEvent) => {
1199
1716
  ```typescript
1200
1717
  interface User {
1201
1718
  id: string;
1719
+ externalId?: string; // external system user ID (non-builtin modes only)
1202
1720
  tenantId: string;
1203
1721
  email: string;
1204
1722
  username: string;
@@ -1298,8 +1816,7 @@ interface Conversation {
1298
1816
  conversationType: 'direct' | 'group';
1299
1817
  name?: string;
1300
1818
  description?: string;
1301
- icon?: string;
1302
- iconUrl?: string;
1819
+ iconUrl?: string; // Fresh signed URL generated on each response — never stored directly
1303
1820
  participants: Participant[];
1304
1821
  participantCount?: number;
1305
1822
  settings?: ConversationSettings;