@antzsoft/chat-core 1.0.2 → 1.0.3

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,7 +783,7 @@ 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. |
@@ -752,7 +798,32 @@ import { conversationsApi } from '@antzsoft/chat-core';
752
798
  | `unpin` | `(conversationId: string) => Promise<void>` | Unpin a conversation. |
753
799
  | `leave` | `(conversationId: string) => Promise<void>` | Leave a group conversation. |
754
800
  | `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). |
801
+
802
+ ```typescript
803
+ interface ConversationListParams {
804
+ /** Omit both page and limit to receive all results in one response */
805
+ page?: number;
806
+ limit?: number;
807
+ /** Filter by conversation type */
808
+ type?: 'direct' | 'group';
809
+ /** Only pinned (true) or unpinned (false) conversations */
810
+ isPinned?: boolean;
811
+ /** Only muted (true) or unmuted (false) conversations */
812
+ isMuted?: boolean;
813
+ /** Only conversations with at least one unread message */
814
+ hasUnread?: boolean;
815
+ /** Search by group name / description — uses the server-side text index */
816
+ search?: string;
817
+ /** Filter by the current user's role in the conversation */
818
+ role?: 'admin' | 'member';
819
+ /** Filter by whether the last message has attachments */
820
+ hasAttachments?: boolean;
821
+ /** Filter by last message attachment type */
822
+ attachmentType?: 'image' | 'video' | 'document' | 'audio';
823
+ /** Filter by notification enabled/disabled for the current user */
824
+ notificationsEnabled?: boolean;
825
+ }
826
+ ```
756
827
 
757
828
  ```typescript
758
829
  interface CreateGroupData {
@@ -773,6 +844,36 @@ interface UpdateConversationData {
773
844
  }
774
845
  ```
775
846
 
847
+ ```typescript
848
+ // All conversations (no page/limit = server returns everything)
849
+ const { data } = await conversationsApi.list();
850
+
851
+ // Filter by type
852
+ const groups = await conversationsApi.list({ type: 'group' });
853
+ const dms = await conversationsApi.list({ type: 'direct' });
854
+
855
+ // Filter pinned / muted / unread
856
+ const pinned = await conversationsApi.list({ isPinned: true });
857
+ const muted = await conversationsApi.list({ isMuted: true });
858
+ const unread = await conversationsApi.list({ hasUnread: true });
859
+
860
+ // Text search (uses server-side MongoDB text index on name + description)
861
+ const results = await conversationsApi.list({ search: 'design' });
862
+
863
+ // Filter by current user's role
864
+ const adminConvs = await conversationsApi.list({ role: 'admin' });
865
+
866
+ // Combine filters — pinned group conversations with unread messages
867
+ const urgent = await conversationsApi.list({
868
+ type: 'group',
869
+ isPinned: true,
870
+ hasUnread: true,
871
+ });
872
+
873
+ // Explicit pagination (pass page + limit to opt in)
874
+ const page1 = await conversationsApi.list({ page: 1, limit: 20 });
875
+ ```
876
+
776
877
  ```typescript
777
878
  // Create a group
778
879
  const group = await conversationsApi.createGroup({
@@ -789,8 +890,51 @@ await conversationsApi.addParticipants(group.id, ['user-d', 'user-e']);
789
890
  // Mute for 8 hours
790
891
  const mutedUntil = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString();
791
892
  await conversationsApi.mute(group.id, mutedUntil);
893
+
894
+ // ── Unread counts ──────────────────────────────────────────────────────────
895
+
896
+ // Total unread across all conversations + per-conversation breakdown
897
+ const summary = await conversationsApi.getUnreadSummary();
898
+ // summary.totalUnread → 12
899
+ // summary.byConversation → [{ conversationId, unreadCount }, ...]
900
+
901
+ // Unread count for one specific conversation
902
+ const { unreadCount } = await conversationsApi.getUnreadCount(conversationId);
903
+ ```
904
+
905
+ #### When to call unread APIs vs relying on socket
906
+
907
+ The socket keeps unread counts live while the app is connected. The REST APIs are the **source of truth** for everything else:
908
+
909
+ | Situation | What to do |
910
+ |---|---|
911
+ | App cold start | Call `getUnreadSummary()` — hydrate local state before socket connects |
912
+ | App comes to foreground | Call `getUnreadSummary()` — catch up on anything received while socket was down |
913
+ | Socket reconnects after drop | Call `getUnreadSummary()` — reconcile any drift during the outage |
914
+ | Push notification opens a specific chat | Call `getUnreadCount(conversationId)` — refresh just that conversation |
915
+ | User is actively chatting (socket connected) | Use `unreadCount` from `conversationsApi.list()` or the `conversation_updated` socket event — no need to poll REST |
916
+
917
+ #### Socket events for real-time unread updates
918
+
919
+ ```typescript
920
+ import { tryGetSocket } from '@antzsoft/chat-core';
921
+
922
+ const socket = tryGetSocket();
923
+
924
+ // Fires when a new message arrives — updates unreadCount for that conversation
925
+ socket?.on('conversation_updated', ({ conversationId, unreadCount }) => {
926
+ // unreadCount is calculated server-side from the DB — always accurate
927
+ });
928
+
929
+ // Fires when YOU mark a conversation as read — resets unreadCount to 0
930
+ // Also fires on your OTHER devices (same user, different session)
931
+ socket?.on('unread_count_changed', ({ conversationId, unreadCount }) => {
932
+ // unreadCount is 0 here — conversation was just read
933
+ });
792
934
  ```
793
935
 
936
+ 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
+
794
938
  ---
795
939
 
796
940
  ### Storage API (`storageApi` and `uploadBatch`)
@@ -853,19 +997,21 @@ result.failed.forEach((f) => console.error('Failed:', f.filename, f.error));
853
997
 
854
998
  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
999
 
1000
+ 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.
1001
+
856
1002
  ```typescript
857
1003
  import { devicesApi } from '@antzsoft/chat-core';
858
1004
  ```
859
1005
 
860
1006
  | Method | Signature | Description |
861
1007
  |---|---|---|
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. |
1008
+ | `register` | `(payload: RegisterDeviceTokenPayload) => Promise<void>` | Register or refresh a push token. Upserts by `deviceId` — safe to call on every app launch. |
1009
+ | `remove` | `(deviceId: string) => Promise<void>` | Deactivate a device token. Call on logout or when the user disables notifications. |
864
1010
 
865
1011
  ```typescript
866
1012
  type RegisterDeviceTokenPayload =
867
1013
  | {
868
- deviceId: string; // Stable UUID — generate once and persist
1014
+ deviceId: string; // Stable UUID — generate once on install, persist, never regenerate
869
1015
  platform: 'ios' | 'android' | 'web';
870
1016
  provider: 'expo' | 'fcm' | 'apns';
871
1017
  token: string; // The OS-issued push token
@@ -882,20 +1028,239 @@ type RegisterDeviceTokenPayload =
882
1028
  };
883
1029
  ```
884
1030
 
1031
+ #### The `deviceId` rule
1032
+
1033
+ `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.
1034
+
1035
+ #### When to call `register()`
1036
+
1037
+ **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.
1038
+
1039
+ **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.
1040
+
1041
+ #### When to call `remove()`
1042
+
1043
+ 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.
1044
+
885
1045
  ```typescript
886
- // Mobile (Expo)
1046
+ // ── Mobile (Expo) ──────────────────────────────────────────────────────────
887
1047
  import * as Notifications from 'expo-notifications';
1048
+ import * as Device from 'expo-device';
1049
+ import * as SecureStore from 'expo-secure-store';
1050
+ import * as Crypto from 'expo-crypto';
1051
+
1052
+ async function getStableDeviceId(): Promise<string> {
1053
+ const existing = await SecureStore.getItemAsync('chat-device-id');
1054
+ if (existing) return existing;
1055
+ const id = Crypto.randomUUID();
1056
+ await SecureStore.setItemAsync('chat-device-id', id);
1057
+ return id;
1058
+ }
1059
+
1060
+ // Call after every login / on every app launch after auth
1061
+ async function registerPushToken(): Promise<void> {
1062
+ if (!Device.isDevice) return; // simulators cannot receive push
1063
+ const { status } = await Notifications.requestPermissionsAsync();
1064
+ if (status !== 'granted') return;
1065
+ const { data: token } = await Notifications.getExpoPushTokenAsync();
1066
+ await devicesApi.register({
1067
+ deviceId: await getStableDeviceId(),
1068
+ platform: Device.osName === 'iOS' ? 'ios' : 'android',
1069
+ provider: 'expo',
1070
+ token,
1071
+ });
1072
+ }
1073
+
1074
+ // On logout
1075
+ await devicesApi.remove(await getStableDeviceId());
1076
+
1077
+ // ── Mobile (FCM — direct Firebase, no Expo) ────────────────────────────────
1078
+ import messaging from '@react-native-firebase/messaging';
888
1079
 
889
- const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync();
890
1080
  await devicesApi.register({
891
- deviceId: await getStableDeviceId(), // from SecureStore
892
- platform: 'ios',
893
- provider: 'expo',
894
- token: expoPushToken,
1081
+ deviceId: await getStableDeviceId(),
1082
+ platform: 'android',
1083
+ provider: 'fcm',
1084
+ token: await messaging().getToken(),
895
1085
  });
896
1086
 
897
- // On logout
898
- await devicesApi.remove(deviceId);
1087
+ // FCM tokens can rotate — re-register on rotation
1088
+ messaging().onTokenRefresh(async (newToken) => {
1089
+ await devicesApi.register({
1090
+ deviceId: await getStableDeviceId(),
1091
+ platform: 'android',
1092
+ provider: 'fcm',
1093
+ token: newToken,
1094
+ });
1095
+ });
1096
+
1097
+ // ── Web (VAPID) ────────────────────────────────────────────────────────────
1098
+ function getStableDeviceId(): string {
1099
+ let id = localStorage.getItem('chat-device-id');
1100
+ if (!id) { id = crypto.randomUUID(); localStorage.setItem('chat-device-id', id); }
1101
+ return id;
1102
+ }
1103
+
1104
+ function subToPayload(sub: PushSubscription, deviceId: string) {
1105
+ const b64 = (buf: ArrayBuffer | null) =>
1106
+ buf ? btoa(String.fromCharCode(...new Uint8Array(buf))) : '';
1107
+ return {
1108
+ deviceId, platform: 'web' as const, provider: 'web-push' as const,
1109
+ endpoint: sub.endpoint,
1110
+ p256dh: b64(sub.getKey('p256dh')),
1111
+ auth: b64(sub.getKey('auth')),
1112
+ };
1113
+ }
1114
+
1115
+ // On app init after login — re-registers any existing subscription
1116
+ async function syncPushSubscription(): Promise<void> {
1117
+ if (!('serviceWorker' in navigator) || Notification.permission !== 'granted') return;
1118
+ const reg = await navigator.serviceWorker.ready;
1119
+ const existing = await reg.pushManager.getSubscription();
1120
+ if (existing) await devicesApi.register(subToPayload(existing, getStableDeviceId()));
1121
+ }
1122
+
1123
+ // When user clicks "Enable notifications"
1124
+ async function enablePushNotifications(): Promise<void> {
1125
+ const permission = await Notification.requestPermission();
1126
+ if (permission !== 'granted') return;
1127
+ const reg = await navigator.serviceWorker.ready;
1128
+ const sub = await reg.pushManager.subscribe({
1129
+ userVisibleOnly: true,
1130
+ applicationServerKey: YOUR_VAPID_PUBLIC_KEY,
1131
+ });
1132
+ await devicesApi.register(subToPayload(sub, getStableDeviceId()));
1133
+ }
1134
+
1135
+ // On logout or "Disable notifications"
1136
+ async function disablePushNotifications(): Promise<void> {
1137
+ const reg = await navigator.serviceWorker.ready;
1138
+ const sub = await reg.pushManager.getSubscription();
1139
+ if (sub) await sub.unsubscribe();
1140
+ await devicesApi.remove(getStableDeviceId());
1141
+ }
1142
+ ```
1143
+
1144
+ #### Token lifecycle
1145
+
1146
+ | Event | Action |
1147
+ |---|---|
1148
+ | App launch after login (mobile) | Call `register()` — upsert handles token rotation automatically |
1149
+ | App init after login (web, subscription exists) | Call `syncPushSubscription()` — refreshes `lastUsedAt`, catches silent endpoint rotation |
1150
+ | User enables notifications (web) | Call `enablePushNotifications()` — requests permission, subscribes, registers |
1151
+ | User logs out (any platform) | Call `remove(deviceId)` |
1152
+ | FCM / Expo token rotates | Call `register()` again with new token — upsert updates the existing record |
1153
+ | Notification engine gets `DeviceNotRegistered` from Expo/FCM/VAPID | Engine auto-marks token inactive — no action needed in app |
1154
+
1155
+ ---
1156
+
1157
+ ### Notification Preferences (`usersApi.updatePreferences` / `usersApi.getPreferences`)
1158
+
1159
+ 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.
1160
+
1161
+ 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.
1162
+
1163
+ ```typescript
1164
+ import { usersApi } from '@antzsoft/chat-core';
1165
+ import type { UserPreferences } from '@antzsoft/chat-core';
1166
+ ```
1167
+
1168
+ | Method | Description |
1169
+ |---|---|
1170
+ | `getPreferences()` | Fetch current preferences. Returns `null` if no record exists (all defaults apply). |
1171
+ | `updatePreferences(prefs)` | Partial update — only fields you pass are changed. |
1172
+
1173
+ ```typescript
1174
+ // Fetch current preferences
1175
+ const prefs = await usersApi.getPreferences();
1176
+ // prefs is null if no record yet (all defaults apply — everything enabled, quietHours off)
1177
+
1178
+ // Disable reaction notifications
1179
+ await usersApi.updatePreferences({ notifyOnReaction: false });
1180
+
1181
+ // Hide message content from notification body
1182
+ await usersApi.updatePreferences({ messagePreview: false });
1183
+
1184
+ // Enable quiet hours (no push 23:00–07:00 IST)
1185
+ await usersApi.updatePreferences({
1186
+ quietHours: { enabled: true, start: '23:00', end: '07:00', timezone: 'Asia/Kolkata' },
1187
+ });
1188
+
1189
+ // Turn off all notifications (master switch)
1190
+ await usersApi.updatePreferences({ notificationsEnabled: false });
1191
+ ```
1192
+
1193
+ #### `UserPreferences` type
1194
+
1195
+ ```typescript
1196
+ interface UserPreferences {
1197
+ /** Master switch — false disables all push. Default: true */
1198
+ notificationsEnabled?: boolean;
1199
+
1200
+ /** Play sound with notifications. Default: true */
1201
+ soundEnabled?: boolean;
1202
+
1203
+ /** Show message text in notification body.
1204
+ * false = show "New message" only (privacy mode). Default: true */
1205
+ messagePreview?: boolean;
1206
+
1207
+ /** Notify when @mentioned in a group. Default: true */
1208
+ notifyOnMention?: boolean;
1209
+
1210
+ /** Notify when someone reacts to your message. Default: true */
1211
+ notifyOnReaction?: boolean;
1212
+
1213
+ /** Notify when added to a group. Default: true */
1214
+ notifyOnGroupInvite?: boolean;
1215
+
1216
+ /** Quiet hours — no push delivered during this window. Default: disabled */
1217
+ quietHours?: {
1218
+ enabled: boolean;
1219
+ start: string; // HH:MM — e.g. "22:00"
1220
+ end: string; // HH:MM — e.g. "08:00"
1221
+ timezone: string; // IANA — e.g. "Asia/Kolkata"
1222
+ };
1223
+ }
1224
+ ```
1225
+
1226
+ #### Adding future preferences
1227
+
1228
+ 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.
1229
+
1230
+ ---
1231
+
1232
+ ### Users API (`usersApi`)
1233
+
1234
+ 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).
1235
+
1236
+ ```typescript
1237
+ import { usersApi } from '@antzsoft/chat-core';
1238
+ ```
1239
+
1240
+ | Method | Signature | Description |
1241
+ |---|---|---|
1242
+ | `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. |
1243
+ | `getById` | `(userId: string) => Promise<User>` | Fetch a single user by their chat system ID. |
1244
+ | `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. |
1245
+
1246
+ All methods return `User` objects that include `externalId` for non-builtin modes.
1247
+
1248
+ ```typescript
1249
+ // All users (no page/limit = server returns everything)
1250
+ const { data } = await usersApi.list();
1251
+
1252
+ // Search by name / username / email
1253
+ const { data: results } = await usersApi.list({ query: 'john' });
1254
+
1255
+ // Paginated (opt in by passing page + limit)
1256
+ const { data, meta } = await usersApi.list({ page: 1, limit: 20 });
1257
+
1258
+ // Search + paginated
1259
+ const { data: results } = await usersApi.list({ query: 'john', page: 1, limit: 10 });
1260
+
1261
+ // Get a specific user
1262
+ const user = await usersApi.getById('64abc...');
1263
+ console.log(user.displayName, user.externalId);
899
1264
  ```
900
1265
 
901
1266
  ---
@@ -955,7 +1320,8 @@ All emit methods that have server responses use a 5-second ack timeout and retur
955
1320
  | `leaveRoom` | `(conversationId: string) => void` | Leave a conversation room. Fire-and-forget. |
956
1321
  | `sendMessage` | `(payload: SendMessagePayload) => Promise<unknown>` | Send a message. Ack-based. |
957
1322
  | `updateMessage` | `(messageId: string, text: string) => Promise<unknown>` | Edit a message. Ack-based. |
958
- | `deleteMessage` | `(messageId: string) => Promise<unknown>` | Delete a message. Ack-based. |
1323
+ | `deleteMessage` | `(messageId: string) => Promise<unknown>` | Delete a message for everyone (own message within window, or admin). Ack-based. |
1324
+ | `deleteMessageForMe` | `(messageId: string) => Promise<unknown>` | Hide a message for the current user only. Ack-based. |
959
1325
  | `addReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Add a reaction. Ack-based. |
960
1326
  | `removeReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Remove a reaction. Ack-based. |
961
1327
  | `pinMessage` | `(messageId: string) => Promise<unknown>` | Pin a message. Ack-based. |
@@ -998,7 +1364,8 @@ Subscribe using `client.socket.on(event, handler)` (headless) or directly on the
998
1364
  |---|---|---|
999
1365
  | `new_message` | `NewMessageEvent` | A new message was sent to a room you've joined. |
1000
1366
  | `message_updated` | `MessageUpdatedEvent` | A message was edited. |
1001
- | `message_deleted` | `MessageDeletedEvent` | A message was deleted. |
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). |
1002
1369
  | `reaction_updated` | `ReactionUpdatedEvent` | Reactions on a message changed (full reaction array). |
1003
1370
  | `typing_indicator` | `TypingIndicatorEvent` | A user started or stopped typing. |
1004
1371
  | `user_status` | `UserStatusEvent` | A user's online/offline/away status changed. |
@@ -1134,11 +1501,20 @@ import { useChatStore } from '@antzsoft/chat-core';
1134
1501
  | `pendingTarget` | `{ conversationId: string; messageId: string } \| null` | Scroll-to target for deep-linked messages. |
1135
1502
  | `typingUsers` | `Record<string, TypingUser[]>` | Map of conversationId → users currently typing. |
1136
1503
  | `onlineUsers` | `string[]` | Array of user IDs currently online. |
1504
+ | `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. |
1505
+ | `lastSeen` | `Record<string, string>` | Map of userId → ISO timestamp — each user's last-seen time. Hydrated by `user_offline` socket events automatically. |
1137
1506
  | `replyingTo` | `Message \| null` | Message being replied to in the composer. |
1138
1507
  | `editingMessage` | `Message \| null` | Message being edited in the composer. |
1139
1508
  | `isSidebarOpen` | `boolean` | Conversation list sidebar visibility. |
1140
1509
  | `isGroupInfoOpen` | `boolean` | Group info panel visibility. |
1141
1510
 
1511
+ ```typescript
1512
+ interface LastReadEntry {
1513
+ messageId: string;
1514
+ readAt: string; // ISO 8601
1515
+ }
1516
+ ```
1517
+
1142
1518
  #### Actions
1143
1519
 
1144
1520
  | Action | Signature | Description |
@@ -1150,6 +1526,8 @@ import { useChatStore } from '@antzsoft/chat-core';
1150
1526
  | `setUserOnline` | `(userId: string) => void` | Mark a user as online. |
1151
1527
  | `setUserOffline` | `(userId: string) => void` | Mark a user as offline. |
1152
1528
  | `setOnlineUsers` | `(userIds: string[]) => void` | Replace the full online users list. |
1529
+ | `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. |
1530
+ | `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
1531
  | `setReplyingTo` | `(message: Message \| null) => void` | Set reply context; clears `editingMessage`. |
1154
1532
  | `setEditingMessage` | `(message: Message \| null) => void` | Set edit context; clears `replyingTo`. |
1155
1533
  | `toggleSidebar` | `() => void` | Toggle sidebar open/closed. |
@@ -1157,15 +1535,61 @@ import { useChatStore } from '@antzsoft/chat-core';
1157
1535
  | `toggleGroupInfo` | `() => void` | Toggle group info panel. |
1158
1536
  | `setGroupInfoOpen` | `(open: boolean) => void` | Set group info panel state explicitly. |
1159
1537
 
1538
+ #### Automatic socket wiring
1539
+
1540
+ 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.
1541
+
1542
+ #### Seeding state on initial load
1543
+
1544
+ The store starts empty. On first render, fetch the initial values from the API and seed the store:
1545
+
1160
1546
  ```typescript
1161
- // In a component
1547
+ import { messagesApi, usersApi, useChatStore, type LastReadEntry } from '@antzsoft/chat-core';
1548
+
1549
+ // Seed last-read for the active conversation when it opens
1550
+ const conversationId = 'conv-abc';
1551
+ const { lastReadMessageId, lastReadAt } = await messagesApi.getLastRead(conversationId);
1552
+ if (lastReadMessageId && lastReadAt) {
1553
+ useChatStore.getState().setLastRead(conversationId, lastReadMessageId, lastReadAt);
1554
+ }
1555
+
1556
+ // Seed last-seen for a specific user (e.g. in a DM header)
1557
+ const { lastSeenAt } = await usersApi.getLastSeen('user-xyz');
1558
+ if (lastSeenAt) {
1559
+ useChatStore.getState().setLastSeen('user-xyz', lastSeenAt);
1560
+ }
1561
+ ```
1562
+
1563
+ After seeding, socket events keep the store live — no further fetches are needed.
1564
+
1565
+ #### Reading state in components
1566
+
1567
+ ```typescript
1568
+ import { useChatStore, type LastReadEntry } from '@antzsoft/chat-core';
1569
+
1570
+ // Last-read pointer for the open conversation
1571
+ const lastRead: LastReadEntry | undefined = useChatStore(
1572
+ (s) => s.lastRead['conv-abc'],
1573
+ );
1574
+ // lastRead.messageId — scroll here on open
1575
+ // lastRead.readAt — "read up to" timestamp
1576
+
1577
+ // Last-seen for a user (e.g. "Last seen 5 minutes ago" in a DM)
1578
+ const lastSeenAt: string | undefined = useChatStore(
1579
+ (s) => s.lastSeen['user-xyz'],
1580
+ );
1581
+
1582
+ // Online presence
1583
+ const isOnline = useChatStore((s) => s.onlineUsers.includes('user-xyz'));
1584
+ ```
1585
+
1586
+ ```typescript
1587
+ // Typing indicator wiring (still manual — not auto-wired)
1162
1588
  const activeId = useChatStore((s) => s.activeConversationId);
1163
1589
  const typingInActive = useChatStore((s) =>
1164
1590
  activeId ? (s.typingUsers[activeId] ?? []) : [],
1165
1591
  );
1166
- const { setActiveConversation, setReplyingTo } = useChatStore.getState();
1167
1592
 
1168
- // Wire typing indicator events
1169
1593
  client.socket.on('typing_indicator', (evt: TypingIndicatorEvent) => {
1170
1594
  const { addTypingUser, removeTypingUser } = useChatStore.getState();
1171
1595
  if (evt.isTyping) {
@@ -1178,16 +1602,6 @@ client.socket.on('typing_indicator', (evt: TypingIndicatorEvent) => {
1178
1602
  removeTypingUser(evt.conversationId, evt.userId);
1179
1603
  }
1180
1604
  });
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
1605
  ```
1192
1606
 
1193
1607
  ---
@@ -1199,6 +1613,7 @@ client.socket.on('user_status', (evt: UserStatusEvent) => {
1199
1613
  ```typescript
1200
1614
  interface User {
1201
1615
  id: string;
1616
+ externalId?: string; // external system user ID (non-builtin modes only)
1202
1617
  tenantId: string;
1203
1618
  email: string;
1204
1619
  username: string;
@@ -0,0 +1,7 @@
1
+ import {
2
+ useChatStore
3
+ } from "./chunk-TB52RCSF.js";
4
+ export {
5
+ useChatStore
6
+ };
7
+ //# sourceMappingURL=chat.store-PY3YVYGN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}