@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 +574 -57
- 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 +193 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +171 -15
- package/dist/index.d.ts +171 -15
- package/dist/index.js +128 -55
- package/dist/index.js.map +1 -1
- package/docs/integration-guide.html +2324 -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,11 +783,12 @@ 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. |
|
|
744
|
-
| `update` | `(conversationId: string, data: UpdateConversationData) => Promise<Conversation>` | Update group name
|
|
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
|
-
|
|
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>` |
|
|
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
|
|
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(),
|
|
892
|
-
platform: '
|
|
893
|
-
provider: '
|
|
894
|
-
token:
|
|
1122
|
+
deviceId: await getStableDeviceId(),
|
|
1123
|
+
platform: 'android',
|
|
1124
|
+
provider: 'fcm',
|
|
1125
|
+
token: await messaging().getToken(),
|
|
895
1126
|
});
|
|
896
1127
|
|
|
897
|
-
//
|
|
898
|
-
|
|
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` |
|
|
1000
|
-
| `message_updated` | `MessageUpdatedEvent` | A message was edited. |
|
|
1001
|
-
| `message_deleted` | `MessageDeletedEvent` | A message was deleted. |
|
|
1002
|
-
| `
|
|
1003
|
-
| `
|
|
1004
|
-
| `
|
|
1005
|
-
| `
|
|
1006
|
-
| `
|
|
1007
|
-
| `
|
|
1008
|
-
| `
|
|
1009
|
-
| `
|
|
1010
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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;
|