@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 +454 -39
- package/dist/chat.store-PY3YVYGN.js +7 -0
- package/dist/chat.store-PY3YVYGN.js.map +1 -0
- package/dist/chunk-TB52RCSF.js +54 -0
- package/dist/chunk-TB52RCSF.js.map +1 -0
- package/dist/index.cjs +173 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +155 -12
- package/dist/index.d.ts +155 -12
- package/dist/index.js +108 -55
- package/dist/index.js.map +1 -1
- package/docs/integration-guide.html +2076 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -525,28 +525,61 @@ const client = new AntzChatClient({
|
|
|
525
525
|
});
|
|
526
526
|
```
|
|
527
527
|
|
|
528
|
-
### Avatar
|
|
528
|
+
### Avatar
|
|
529
529
|
|
|
530
|
-
|
|
530
|
+
There are three ways to set or update a user's avatar:
|
|
531
531
|
|
|
532
|
-
|
|
532
|
+
#### 1. Config — on init (any mode)
|
|
533
533
|
|
|
534
|
-
|
|
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',
|
|
540
|
+
url: 'https://cdn.yoursystem.com/avatars/user-58.jpg',
|
|
543
541
|
// OR
|
|
544
|
-
// base64: 'data:image/jpeg;base64,...',
|
|
542
|
+
// base64: 'data:image/jpeg;base64,...',
|
|
545
543
|
},
|
|
546
544
|
});
|
|
547
545
|
```
|
|
548
546
|
|
|
549
|
-
|
|
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(
|
|
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?:
|
|
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
|
-
|
|
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>` |
|
|
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
|
|
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(),
|
|
892
|
-
platform: '
|
|
893
|
-
provider: '
|
|
894
|
-
token:
|
|
1081
|
+
deviceId: await getStableDeviceId(),
|
|
1082
|
+
platform: 'android',
|
|
1083
|
+
provider: 'fcm',
|
|
1084
|
+
token: await messaging().getToken(),
|
|
895
1085
|
});
|
|
896
1086
|
|
|
897
|
-
//
|
|
898
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|