@antzsoft/chat-core 1.0.0 → 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 +518 -31
- 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 +252 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +217 -13
- package/dist/index.d.ts +217 -13
- package/dist/index.js +186 -55
- package/dist/index.js.map +1 -1
- package/docs/integration-guide.html +2076 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -325,6 +325,27 @@ interface AntzChatConfig {
|
|
|
325
325
|
*/
|
|
326
326
|
encryptionMode?: 'none' | 'server';
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* The user's ID in the external auth system.
|
|
330
|
+
* Required for non-builtin authentication modes (antz, external, wso2).
|
|
331
|
+
* Sent as the "x-user-id" request header when provided.
|
|
332
|
+
* Optional.
|
|
333
|
+
*/
|
|
334
|
+
userId?: string;
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Optional profile picture for non-builtin authentication modes.
|
|
338
|
+
* Supply a publicly accessible URL or a base64-encoded data URI.
|
|
339
|
+
* The server fetches/decodes the image, stores it in its own storage,
|
|
340
|
+
* and serves back a 15-minute signed URL. Hash-based deduplication means
|
|
341
|
+
* repeat connections with the same image are a no-op.
|
|
342
|
+
* Optional.
|
|
343
|
+
*/
|
|
344
|
+
avatar?: {
|
|
345
|
+
url?: string;
|
|
346
|
+
base64?: string;
|
|
347
|
+
};
|
|
348
|
+
|
|
328
349
|
/**
|
|
329
350
|
* Fine-grained upload constraints and callbacks.
|
|
330
351
|
* Optional — sensible defaults are applied for all sub-fields.
|
|
@@ -483,6 +504,85 @@ const config: AntzChatConfig = {
|
|
|
483
504
|
|
|
484
505
|
---
|
|
485
506
|
|
|
507
|
+
## Non-Builtin Authentication (antz / external / wso2)
|
|
508
|
+
|
|
509
|
+
When the Antz Chat server runs in `antz`, `external`, or `wso2` mode, authentication is handled by an external system. The SDK must pass three things on every request:
|
|
510
|
+
|
|
511
|
+
| Config field | HTTP header | When required |
|
|
512
|
+
|---|---|---|
|
|
513
|
+
| `authToken` or `authProvider` | `Authorization: Bearer <token>` | Always |
|
|
514
|
+
| `userId` | `x-user-id` | Non-builtin modes |
|
|
515
|
+
| `tenantId` | `X-Tenant-ID` | Non-builtin modes |
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
const client = new AntzChatClient({
|
|
519
|
+
apiUrl: 'https://api.yourapp.com/api/v1',
|
|
520
|
+
persistStorage,
|
|
521
|
+
platformUploadFn,
|
|
522
|
+
authToken: 'jwt-from-your-auth-system',
|
|
523
|
+
userId: '58', // the user's ID in your external system
|
|
524
|
+
tenantId: '11', // your tenant/zoo/org ID
|
|
525
|
+
});
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Avatar
|
|
529
|
+
|
|
530
|
+
There are three ways to set or update a user's avatar:
|
|
531
|
+
|
|
532
|
+
#### 1. Config — on init (any mode)
|
|
533
|
+
|
|
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.
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
const client = new AntzChatClient({
|
|
538
|
+
// ...
|
|
539
|
+
avatar: {
|
|
540
|
+
url: 'https://cdn.yoursystem.com/avatars/user-58.jpg',
|
|
541
|
+
// OR
|
|
542
|
+
// base64: 'data:image/jpeg;base64,...',
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
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.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
486
586
|
## API Reference
|
|
487
587
|
|
|
488
588
|
### `AntzChatClient` (Headless)
|
|
@@ -538,7 +638,7 @@ client.socket.emit.joinRoom('conv-abc');
|
|
|
538
638
|
await client.socket.emit.sendMessage({ conversationId: 'conv-abc', text: 'Hi', tempId: 'tmp-1' });
|
|
539
639
|
|
|
540
640
|
// REST calls
|
|
541
|
-
const convs = await client.conversations.list(
|
|
641
|
+
const convs = await client.conversations.list(); // returns all conversations
|
|
542
642
|
const msgs = await client.messages.list('conv-abc', { limit: 50 });
|
|
543
643
|
|
|
544
644
|
// Upload files
|
|
@@ -568,6 +668,8 @@ import { authApi } from '@antzsoft/chat-core';
|
|
|
568
668
|
| `logout` | `(refreshToken?: string) => Promise<void>` | Invalidate the current session. |
|
|
569
669
|
| `logoutAll` | `() => Promise<void>` | Invalidate all sessions for the current user. |
|
|
570
670
|
| `getMe` | `() => Promise<User>` | Fetch the current user's profile. |
|
|
671
|
+
| `uploadAvatar` | `(file: File \| Blob, mimeType?: string) => Promise<{ avatarUrl: string }>` | Multipart avatar upload for builtin auth mode. |
|
|
672
|
+
| `syncAvatar` | `(source: { url?: string; base64?: string }) => Promise<{ avatarUrl: string }>` | Sync avatar from a URL or base64 string — for non-builtin modes or post-init updates. |
|
|
571
673
|
|
|
572
674
|
```typescript
|
|
573
675
|
// Login
|
|
@@ -578,12 +680,22 @@ const { user } = await authApi.register({
|
|
|
578
680
|
email: 'new@example.com',
|
|
579
681
|
password: 'hunter2',
|
|
580
682
|
username: 'newuser',
|
|
581
|
-
|
|
683
|
+
firstName: 'New',
|
|
684
|
+
lastName: 'User',
|
|
582
685
|
tenantId: 'tenant-xyz',
|
|
583
686
|
});
|
|
584
687
|
|
|
585
688
|
// Logout
|
|
586
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,...' });
|
|
587
699
|
```
|
|
588
700
|
|
|
589
701
|
---
|
|
@@ -600,18 +712,22 @@ import { messagesApi } from '@antzsoft/chat-core';
|
|
|
600
712
|
| `get` | `(messageId: string) => Promise<Message>` | Fetch a single message. |
|
|
601
713
|
| `send` | `(conversationId: string, payload: SendData) => Promise<Message>` | Send a message via REST (use `socketEmit.sendMessage` for real-time delivery). |
|
|
602
714
|
| `update` | `(messageId: string, text: string) => Promise<Message>` | Edit message text. |
|
|
603
|
-
| `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. |
|
|
604
717
|
| `addReaction` | `(messageId: string, emoji: string) => Promise<Message>` | Add an emoji reaction. |
|
|
605
718
|
| `removeReaction` | `(messageId: string, emoji: string) => Promise<Message>` | Remove an emoji reaction. |
|
|
606
719
|
| `star` | `(messageId: string) => Promise<void>` | Star a message. |
|
|
607
720
|
| `unstar` | `(messageId: string) => Promise<void>` | Unstar a message. |
|
|
608
721
|
| `getStarred` | `(params?: { page?: number; limit?: number; conversationId?: string }) => Promise<PaginatedResponse<Message>>` | List starred messages. |
|
|
609
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. |
|
|
610
724
|
| `markAsRead` | `(conversationId: string, messageId?: string) => Promise<void>` | Mark messages as read via REST. |
|
|
611
725
|
| `pin` | `(messageId: string) => Promise<Message>` | Pin a message. |
|
|
612
726
|
| `unpin` | `(messageId: string) => Promise<Message>` | Unpin a message. |
|
|
613
727
|
| `getPinned` | `(conversationId: string) => Promise<Message[]>` | List pinned messages in a conversation. |
|
|
614
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
|
+
|
|
615
731
|
```typescript
|
|
616
732
|
interface ListMessagesParams {
|
|
617
733
|
cursor?: string; // Opaque cursor for pagination
|
|
@@ -667,7 +783,7 @@ import { conversationsApi } from '@antzsoft/chat-core';
|
|
|
667
783
|
|
|
668
784
|
| Method | Signature | Description |
|
|
669
785
|
|---|---|---|
|
|
670
|
-
| `list` | `(params?:
|
|
786
|
+
| `list` | `(params?: ConversationListParams) => Promise<PaginatedResponse<Conversation>>` | List conversations with optional server-side filters. |
|
|
671
787
|
| `get` | `(conversationId: string) => Promise<Conversation>` | Fetch a single conversation. |
|
|
672
788
|
| `createGroup` | `(data: CreateGroupData) => Promise<Conversation>` | Create a group conversation. |
|
|
673
789
|
| `createDirect` | `(data: CreateDirectData) => Promise<Conversation>` | Start or retrieve a direct conversation with another user. |
|
|
@@ -682,7 +798,32 @@ import { conversationsApi } from '@antzsoft/chat-core';
|
|
|
682
798
|
| `unpin` | `(conversationId: string) => Promise<void>` | Unpin a conversation. |
|
|
683
799
|
| `leave` | `(conversationId: string) => Promise<void>` | Leave a group conversation. |
|
|
684
800
|
| `getMembers` | `(conversationId: string) => Promise<User[]>` | Fetch full user profiles for all participants. |
|
|
685
|
-
|
|
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
|
+
```
|
|
686
827
|
|
|
687
828
|
```typescript
|
|
688
829
|
interface CreateGroupData {
|
|
@@ -703,6 +844,36 @@ interface UpdateConversationData {
|
|
|
703
844
|
}
|
|
704
845
|
```
|
|
705
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
|
+
|
|
706
877
|
```typescript
|
|
707
878
|
// Create a group
|
|
708
879
|
const group = await conversationsApi.createGroup({
|
|
@@ -719,8 +890,51 @@ await conversationsApi.addParticipants(group.id, ['user-d', 'user-e']);
|
|
|
719
890
|
// Mute for 8 hours
|
|
720
891
|
const mutedUntil = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString();
|
|
721
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);
|
|
722
903
|
```
|
|
723
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
|
+
});
|
|
934
|
+
```
|
|
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
|
+
|
|
724
938
|
---
|
|
725
939
|
|
|
726
940
|
### Storage API (`storageApi` and `uploadBatch`)
|
|
@@ -783,19 +997,21 @@ result.failed.forEach((f) => console.error('Failed:', f.filename, f.error));
|
|
|
783
997
|
|
|
784
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.
|
|
785
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
|
+
|
|
786
1002
|
```typescript
|
|
787
1003
|
import { devicesApi } from '@antzsoft/chat-core';
|
|
788
1004
|
```
|
|
789
1005
|
|
|
790
1006
|
| Method | Signature | Description |
|
|
791
1007
|
|---|---|---|
|
|
792
|
-
| `register` | `(payload: RegisterDeviceTokenPayload) => Promise<void>` | Register or refresh a push token. Upserts by `deviceId
|
|
793
|
-
| `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. |
|
|
794
1010
|
|
|
795
1011
|
```typescript
|
|
796
1012
|
type RegisterDeviceTokenPayload =
|
|
797
1013
|
| {
|
|
798
|
-
deviceId: string; // Stable UUID — generate once
|
|
1014
|
+
deviceId: string; // Stable UUID — generate once on install, persist, never regenerate
|
|
799
1015
|
platform: 'ios' | 'android' | 'web';
|
|
800
1016
|
provider: 'expo' | 'fcm' | 'apns';
|
|
801
1017
|
token: string; // The OS-issued push token
|
|
@@ -812,20 +1028,239 @@ type RegisterDeviceTokenPayload =
|
|
|
812
1028
|
};
|
|
813
1029
|
```
|
|
814
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
|
+
|
|
815
1045
|
```typescript
|
|
816
|
-
// Mobile (Expo)
|
|
1046
|
+
// ── Mobile (Expo) ──────────────────────────────────────────────────────────
|
|
817
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';
|
|
818
1079
|
|
|
819
|
-
const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync();
|
|
820
1080
|
await devicesApi.register({
|
|
821
|
-
deviceId: await getStableDeviceId(),
|
|
822
|
-
platform: '
|
|
823
|
-
provider: '
|
|
824
|
-
token:
|
|
1081
|
+
deviceId: await getStableDeviceId(),
|
|
1082
|
+
platform: 'android',
|
|
1083
|
+
provider: 'fcm',
|
|
1084
|
+
token: await messaging().getToken(),
|
|
825
1085
|
});
|
|
826
1086
|
|
|
827
|
-
//
|
|
828
|
-
|
|
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);
|
|
829
1264
|
```
|
|
830
1265
|
|
|
831
1266
|
---
|
|
@@ -885,7 +1320,8 @@ All emit methods that have server responses use a 5-second ack timeout and retur
|
|
|
885
1320
|
| `leaveRoom` | `(conversationId: string) => void` | Leave a conversation room. Fire-and-forget. |
|
|
886
1321
|
| `sendMessage` | `(payload: SendMessagePayload) => Promise<unknown>` | Send a message. Ack-based. |
|
|
887
1322
|
| `updateMessage` | `(messageId: string, text: string) => Promise<unknown>` | Edit a message. Ack-based. |
|
|
888
|
-
| `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. |
|
|
889
1325
|
| `addReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Add a reaction. Ack-based. |
|
|
890
1326
|
| `removeReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Remove a reaction. Ack-based. |
|
|
891
1327
|
| `pinMessage` | `(messageId: string) => Promise<unknown>` | Pin a message. Ack-based. |
|
|
@@ -928,7 +1364,8 @@ Subscribe using `client.socket.on(event, handler)` (headless) or directly on the
|
|
|
928
1364
|
|---|---|---|
|
|
929
1365
|
| `new_message` | `NewMessageEvent` | A new message was sent to a room you've joined. |
|
|
930
1366
|
| `message_updated` | `MessageUpdatedEvent` | A message was edited. |
|
|
931
|
-
| `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). |
|
|
932
1369
|
| `reaction_updated` | `ReactionUpdatedEvent` | Reactions on a message changed (full reaction array). |
|
|
933
1370
|
| `typing_indicator` | `TypingIndicatorEvent` | A user started or stopped typing. |
|
|
934
1371
|
| `user_status` | `UserStatusEvent` | A user's online/offline/away status changed. |
|
|
@@ -1064,11 +1501,20 @@ import { useChatStore } from '@antzsoft/chat-core';
|
|
|
1064
1501
|
| `pendingTarget` | `{ conversationId: string; messageId: string } \| null` | Scroll-to target for deep-linked messages. |
|
|
1065
1502
|
| `typingUsers` | `Record<string, TypingUser[]>` | Map of conversationId → users currently typing. |
|
|
1066
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. |
|
|
1067
1506
|
| `replyingTo` | `Message \| null` | Message being replied to in the composer. |
|
|
1068
1507
|
| `editingMessage` | `Message \| null` | Message being edited in the composer. |
|
|
1069
1508
|
| `isSidebarOpen` | `boolean` | Conversation list sidebar visibility. |
|
|
1070
1509
|
| `isGroupInfoOpen` | `boolean` | Group info panel visibility. |
|
|
1071
1510
|
|
|
1511
|
+
```typescript
|
|
1512
|
+
interface LastReadEntry {
|
|
1513
|
+
messageId: string;
|
|
1514
|
+
readAt: string; // ISO 8601
|
|
1515
|
+
}
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1072
1518
|
#### Actions
|
|
1073
1519
|
|
|
1074
1520
|
| Action | Signature | Description |
|
|
@@ -1080,6 +1526,8 @@ import { useChatStore } from '@antzsoft/chat-core';
|
|
|
1080
1526
|
| `setUserOnline` | `(userId: string) => void` | Mark a user as online. |
|
|
1081
1527
|
| `setUserOffline` | `(userId: string) => void` | Mark a user as offline. |
|
|
1082
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. |
|
|
1083
1531
|
| `setReplyingTo` | `(message: Message \| null) => void` | Set reply context; clears `editingMessage`. |
|
|
1084
1532
|
| `setEditingMessage` | `(message: Message \| null) => void` | Set edit context; clears `replyingTo`. |
|
|
1085
1533
|
| `toggleSidebar` | `() => void` | Toggle sidebar open/closed. |
|
|
@@ -1087,15 +1535,61 @@ import { useChatStore } from '@antzsoft/chat-core';
|
|
|
1087
1535
|
| `toggleGroupInfo` | `() => void` | Toggle group info panel. |
|
|
1088
1536
|
| `setGroupInfoOpen` | `(open: boolean) => void` | Set group info panel state explicitly. |
|
|
1089
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
|
+
|
|
1090
1546
|
```typescript
|
|
1091
|
-
|
|
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)
|
|
1092
1588
|
const activeId = useChatStore((s) => s.activeConversationId);
|
|
1093
1589
|
const typingInActive = useChatStore((s) =>
|
|
1094
1590
|
activeId ? (s.typingUsers[activeId] ?? []) : [],
|
|
1095
1591
|
);
|
|
1096
|
-
const { setActiveConversation, setReplyingTo } = useChatStore.getState();
|
|
1097
1592
|
|
|
1098
|
-
// Wire typing indicator events
|
|
1099
1593
|
client.socket.on('typing_indicator', (evt: TypingIndicatorEvent) => {
|
|
1100
1594
|
const { addTypingUser, removeTypingUser } = useChatStore.getState();
|
|
1101
1595
|
if (evt.isTyping) {
|
|
@@ -1108,16 +1602,6 @@ client.socket.on('typing_indicator', (evt: TypingIndicatorEvent) => {
|
|
|
1108
1602
|
removeTypingUser(evt.conversationId, evt.userId);
|
|
1109
1603
|
}
|
|
1110
1604
|
});
|
|
1111
|
-
|
|
1112
|
-
// Wire user status events
|
|
1113
|
-
client.socket.on('user_status', (evt: UserStatusEvent) => {
|
|
1114
|
-
const { setUserOnline, setUserOffline } = useChatStore.getState();
|
|
1115
|
-
if (evt.status === 'online') {
|
|
1116
|
-
setUserOnline(evt.userId);
|
|
1117
|
-
} else {
|
|
1118
|
-
setUserOffline(evt.userId);
|
|
1119
|
-
}
|
|
1120
|
-
});
|
|
1121
1605
|
```
|
|
1122
1606
|
|
|
1123
1607
|
---
|
|
@@ -1129,10 +1613,13 @@ client.socket.on('user_status', (evt: UserStatusEvent) => {
|
|
|
1129
1613
|
```typescript
|
|
1130
1614
|
interface User {
|
|
1131
1615
|
id: string;
|
|
1616
|
+
externalId?: string; // external system user ID (non-builtin modes only)
|
|
1132
1617
|
tenantId: string;
|
|
1133
1618
|
email: string;
|
|
1134
1619
|
username: string;
|
|
1135
1620
|
displayName: string;
|
|
1621
|
+
firstName?: string;
|
|
1622
|
+
lastName?: string;
|
|
1136
1623
|
avatarUrl?: string;
|
|
1137
1624
|
phone?: string;
|
|
1138
1625
|
status: 'online' | 'offline' | 'away';
|