wexa-chat 0.1.32 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,16 @@
1
1
  # wexa-chat (Chat Core)
2
2
 
3
- TypeScript chat core for MongoDB with optional Redis and a built‑in WebSocket transport. Ships server APIs and a lightweight browser client bundle.
3
+ TypeScript chat core for MongoDB with optional Redis and a built‑in WebSocket transport. Ships server APIs and a lightweight browser client bundle used by the demo app.
4
4
 
5
5
  - Server: `import { initChat } from 'wexa-chat'`
6
6
  - Client: `import { SocketProvider, useSocket, getSocketManager, type ChatEvent } from 'wexa-chat/client'`
7
7
 
8
- ## Key Features
9
-
10
- - Single socket connection per user ID - no need to join individual conversations
11
- - Direct communication between users via unique identifiers
12
- - Messages are stored with conversationId for organization and retrieval
13
- - Support for various ID types (userId, organizationId, candidateId, applicationId)
14
- - Simplified Redis pub/sub for cross-instance communication
8
+ This README mirrors the integration used in the Next.js demo app.
15
9
 
16
10
  ## Installation
17
11
 
18
12
  ```bash
19
13
  npm install wexa-chat mongoose ioredis
20
- # or with pnpm
21
- pnpm add wexa-chat mongoose ioredis
22
14
  ```
23
15
 
24
16
  ## Server usage
@@ -37,13 +29,15 @@ export async function getChatInstance(httpServer?: HTTPServer) {
37
29
  // Attach WS transport when server becomes available
38
30
  if (httpServer && !(chatInstance.transport as any).socket) {
39
31
  chatInstance = await initChat(mongoose, {
32
+ participantModels: ['User'],
33
+ memberRoles: ['member', 'admin'],
40
34
  mongoUri: process.env.MONGODB_URI!,
41
35
  // Optional Redis for cross‑instance fanout
42
36
  redis: process.env.REDIS_URL || process.env.REDIS_HOST
43
37
  ? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
44
38
  : undefined,
45
- // IMPORTANT: must match the client endpoint
46
- socket: { path: '/api/socket' },
39
+ // IMPORTANT: must match the client (recommended '/ws')
40
+ socket: { path: '/ws' },
47
41
  enableTextSearch: true,
48
42
  }, httpServer);
49
43
  }
@@ -52,11 +46,13 @@ export async function getChatInstance(httpServer?: HTTPServer) {
52
46
 
53
47
  // First‑time init; only pass socket when server exists
54
48
  chatInstance = await initChat(mongoose, {
49
+ participantModels: ['User'],
50
+ memberRoles: ['member', 'admin'],
55
51
  mongoUri: process.env.MONGODB_URI!,
56
52
  redis: process.env.REDIS_URL || process.env.REDIS_HOST
57
53
  ? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
58
54
  : undefined,
59
- socket: httpServer ? { path: '/api/socket' } : undefined,
55
+ socket: httpServer ? { path: '/ws' } : undefined,
60
56
  enableTextSearch: true,
61
57
  }, httpServer);
62
58
 
@@ -67,8 +63,8 @@ export async function getChatInstance(httpServer?: HTTPServer) {
67
63
  ### WebSocket transport
68
64
 
69
65
  - Transport uses native `ws` and attaches an HTTP upgrade listener when `httpServer` is provided.
70
- - Set `socket.path` to the URL path for upgrades. The client bundle expects `/api/socket`.
71
- - Authentication: client connects to `ws(s)://host/api/socket?userId=...`. The server validates the handshake and associates the user.
66
+ - Set `socket.path` to the URL path for upgrades. The client bundle expects `/ws`; configure the same on the server.
67
+ - Authentication: client connects to `ws(s)://host/ws?userId=...&userModel=User`. The server validates the handshake and associates the user.
72
68
 
73
69
  ### Next.js warm‑up route
74
70
 
@@ -89,7 +85,7 @@ export default async function handler(_req: NextApiRequest, res: NextApiResponse
89
85
  }
90
86
  ```
91
87
 
92
- The browser client performs a one-time GET of `/api/socket` before opening the WebSocket.
88
+ The browser client performs a onetime GET of `/api/socket` before opening the WebSocket.
93
89
 
94
90
  ## Options overview (see `src/types/init.ts`)
95
91
 
@@ -100,6 +96,8 @@ export type SocketConfig = {
100
96
  };
101
97
 
102
98
  export type InitOptions = {
99
+ participantModels: string[]; // e.g. ['User']
100
+ memberRoles: string[]; // e.g. ['member', 'admin']
103
101
  mongoUri: string; // MongoDB connection
104
102
  enableTextSearch?: boolean;
105
103
  redis?: { // optional
@@ -107,6 +105,7 @@ export type InitOptions = {
107
105
  socketConnectTimeout?: number; keepAlive?: number;
108
106
  };
109
107
  socket?: SocketConfig; // WebSocket config
108
+ presence?: { enabled?: boolean; heartbeatSec?: number };
110
109
  rateLimit?: { sendPerMin?: number; typingPerMin?: number };
111
110
  }
112
111
  ```
@@ -122,26 +121,125 @@ export type InitOptions = {
122
121
  - `searchByParticipantPair({ organizationId, a, b })`
123
122
 
124
123
  - Messages
125
- - `sendMessage({ organizationId, conversationId, senderModel, senderId, text, targetUserId, kind?, parentMessageId?, rootThreadId? })`
124
+ - `sendMessage({ organizationId, conversationId, senderModel, senderId, text, kind?, parentMessageId?, rootThreadId? })`
126
125
  - `listMessages({ organizationId, conversationId, limit?, cursor? })`
127
126
  - `listMessagesWithSenders({ organizationId, conversationId, limit?, cursor?, populateSenders: true, populateOptions? })`
127
+ - `markRead({ organizationId, conversationId, participantModel, participantId, messageId })`
128
128
 
129
129
  DTO types live in `src/types/dto.ts`.
130
130
 
131
- ### Sending Messages with Direct Routing
131
+ ### Populating Message Senders with Infinite Scroll Support
132
+
133
+ The `listMessagesWithSenders` method allows you to retrieve messages with populated sender details, optimized for infinite scroll in chat interfaces. The implementation uses efficient batched lookups:
134
+
135
+ ```ts
136
+ // Basic usage
137
+ const messagesWithSenders = await chat.services.messages.listMessagesWithSenders({
138
+ organizationId: 'org-123',
139
+ conversationId: 'conv-456',
140
+ populateSenders: true // Required to enable population
141
+ });
142
+
143
+ // With customization
144
+ const messagesWithCustomSenders = await chat.services.messages.listMessagesWithSenders({
145
+ organizationId: 'org-123',
146
+ conversationId: 'conv-456',
147
+ populateSenders: true,
148
+ populateOptions: {
149
+ fields: '_id name email profilePicture', // Fields to select from sender model
150
+ modelMapping: {
151
+ 'User': 'CustomUserModel', // If your senderModel doesn't match the actual Mongoose model name
152
+ 'Bot': 'BotModel'
153
+ }
154
+ }
155
+ });
156
+
157
+ // Accessing populated data
158
+ messagesWithSenders.items.forEach(message => {
159
+ console.log(`Message from ${message.sender?.name}: ${message.text}`);
160
+ });
161
+ ```
162
+
163
+ The populated sender data is available in the `sender` property of each message. The implementation uses a batched approach that:
132
164
 
133
- The `sendMessage` method now supports direct routing to specific users with the new `targetUserId` parameter:
165
+ 1. Fetches messages using MongoDB aggregation
166
+ 2. Groups messages by sender model type
167
+ 3. Loads all senders for each model type in a single query per model
168
+ 4. Attaches sender data to each message efficiently
169
+
170
+ This approach minimizes database queries and maximizes performance, even with multiple sender types.
171
+
172
+ #### Infinite Scroll Implementation
173
+
174
+ The returned data structure supports infinite scroll with cursor-based pagination:
134
175
 
135
176
  ```ts
136
- // Send a message to a specific user
137
- await chat.services.messages.sendMessage({
177
+ // First load
178
+ const firstPage = await chat.services.messages.listMessagesWithSenders({
138
179
  organizationId: 'org-123',
139
180
  conversationId: 'conv-456',
140
- senderModel: 'User',
141
- senderId: 'user-789',
142
- text: 'Hello there!',
143
- targetUserId: 'org-321', // Target recipient ID (userId, organizationId, applicationId, etc.)
181
+ populateSenders: true,
182
+ limit: 20 // Number of messages per "page"
144
183
  });
184
+
185
+ // Display messages in your UI
186
+ displayMessages(firstPage.items);
187
+
188
+ // When user scrolls and needs more messages
189
+ if (firstPage.hasMore) {
190
+ // Load next batch using the cursor
191
+ const nextPage = await chat.services.messages.listMessagesWithSenders({
192
+ organizationId: 'org-123',
193
+ conversationId: 'conv-456',
194
+ populateSenders: true,
195
+ limit: 20,
196
+ cursor: firstPage.nextCursor // Pass the cursor from previous batch
197
+ });
198
+
199
+ // Append these messages to your UI
200
+ displayMessages([...existingMessages, ...nextPage.items]);
201
+
202
+ // Store the new cursor for further loading
203
+ nextCursor = nextPage.nextCursor;
204
+ hasMore = nextPage.hasMore;
205
+ }
206
+ ```
207
+
208
+ The method returns messages sorted by creation time (newest first), making it ideal for chat interfaces where newer messages appear at the bottom. The cursor-based pagination ensures efficient loading of large conversation histories.
209
+
210
+ The type `MessageWithSender` is exported from the package for proper TypeScript support.
211
+
212
+ ## Models
213
+
214
+ The package exports the following model types which can be imported directly:
215
+
216
+ ```ts
217
+ import {
218
+ // Model interfaces (document structures)
219
+ type IConversation,
220
+ type IMessage,
221
+
222
+ // Mongoose model types
223
+ type ConversationModel,
224
+ type MessageModel,
225
+
226
+ // Extended types
227
+ type MessageWithSender // Message with populated sender data
228
+ } from 'wexa-chat';
229
+ ```
230
+
231
+ To access the actual models:
232
+
233
+ ```ts
234
+ // Initialize the chat system
235
+ const chat = await initChat(mongoose, options);
236
+
237
+ // Access models
238
+ const { Conversation, Message } = chat.models;
239
+
240
+ // Example usage
241
+ const conversations = await Conversation.find({ organizationId }).lean();
242
+ const messages = await Message.find({ conversationId }).sort({ createdAt: -1 }).lean();
145
243
  ```
146
244
 
147
245
  ## Browser client
@@ -165,72 +263,44 @@ Use in components:
165
263
  import { useEffect } from 'react';
166
264
  import { useSocket, type ChatEvent } from 'wexa-chat/client';
167
265
 
168
- export function ChatWindow({ conversationId, otherUserId }: { conversationId: string, otherUserId: string }) {
266
+ export function ChatWindow({ conversationId }: { conversationId: string }) {
169
267
  const { isConnected, send, onEvent } = useSocket();
170
268
 
171
269
  useEffect(() => {
172
- if (!isConnected) return;
173
-
270
+ if (!conversationId) return;
271
+ if (isConnected) send({ type: 'join-conversation', conversationId });
174
272
  const off = onEvent((e: ChatEvent) => {
175
- if (e.type === 'message:received' && e.conversationId === conversationId) {
176
- // update UI with new message
177
- console.log('New message:', e.message);
178
- }
179
- if (e.type === 'typing' && e.conversationId === conversationId) {
180
- // update typing indicator
181
- console.log('Typing status:', e.state, 'from:', e.senderId);
273
+ if (e.type === 'message:created' && e.message?.conversationId === conversationId) {
274
+ // update UI
182
275
  }
183
276
  });
184
-
185
277
  return off;
186
- }, [conversationId, isConnected, onEvent]);
187
-
188
- // Send a message to the other user
189
- const sendMessage = (text: string) => {
190
- if (isConnected) {
191
- send({
192
- type: 'send-message',
193
- conversationId,
194
- targetUserId: otherUserId,
195
- text
196
- });
197
- }
198
- };
199
-
200
- // Send typing indicator
201
- const sendTyping = (isTyping: boolean) => {
202
- if (isConnected) {
203
- send({
204
- type: 'typing',
205
- conversationId,
206
- targetUserId: otherUserId,
207
- isTyping
208
- });
209
- }
210
- };
278
+ }, [conversationId, isConnected, send, onEvent]);
211
279
 
212
280
  return null;
213
281
  }
214
282
  ```
215
283
 
216
- ## Events
217
-
218
- ### Client → Server
284
+ You can also send:
219
285
 
220
- - `send-message` — `{ type: 'send-message', conversationId, targetUserId, text }`
221
- - `typing` — `{ type: 'typing', conversationId, targetUserId, isTyping: boolean }`
286
+ ```ts
287
+ send({ type: 'leave-conversation', conversationId });
288
+ send({ type: 'typing', conversationId, isTyping: true });
289
+ ```
222
290
 
223
- ### ServerClient
291
+ ## Events (server client)
224
292
 
225
- - `message:received` — `{ type: 'message:received', message, conversationId, senderId }`
226
- - `typing` — `{ type: 'typing', conversationId, senderId, state: 'start' | 'stop', at }`
293
+ - `message:created` — `{ type: 'message:created', message }`
294
+ - `conversation:read` — `{ type: 'conversation:read', conversationId, messageId, participantModel, participantId, at }`
295
+ - `typing` — `{ type: 'typing', conversationId, participantModel, participantId, state: 'start' | 'stop' }`
296
+ - `presence:join` / `presence:leave` — `{ type, organizationId, participantModel, participantId, at }`
227
297
 
228
298
  If Redis is configured, events are pub/sub‑broadcast across instances.
229
299
 
230
300
  ## Build
231
301
 
232
302
  ```bash
233
- pnpm build
303
+ npm run build
234
304
  ```
235
305
 
236
306
  Exports (from `package.json`):
@@ -248,8 +318,8 @@ Exports (from `package.json`):
248
318
 
249
319
  - `MONGODB_URI`
250
320
  - `REDIS_URL` or `REDIS_HOST`/`REDIS_PORT`/`REDIS_PASSWORD`/`REDIS_DB`
251
- - Ensure `socket.path` matches the client (recommended `/api/socket`).
321
+ - Ensure `socket.path` matches the client (recommended `/ws`).
252
322
 
253
323
  ## License
254
324
 
255
- MIT
325
+ MIT
@@ -1,15 +1,19 @@
1
1
  import { ReactNode } from 'react';
2
2
 
3
3
  type ChatEvent = {
4
- type: 'message:received';
5
- message: any;
6
- conversationId: string;
4
+ type: 'message:created';
5
+ [k: string]: any;
6
+ } | {
7
+ type: 'conversation:read';
7
8
  [k: string]: any;
8
9
  } | {
9
10
  type: 'typing';
10
- conversationId: string;
11
- senderId: string;
12
- state: string;
11
+ [k: string]: any;
12
+ } | {
13
+ type: 'presence:join';
14
+ [k: string]: any;
15
+ } | {
16
+ type: 'presence:leave';
13
17
  [k: string]: any;
14
18
  };
15
19
  type Listener<T> = (v: T) => void;
@@ -33,7 +33,7 @@ var SocketManager = class {
33
33
  return;
34
34
  const protocol = window.location.protocol === "https:" ? "wss" : "ws";
35
35
  const ws = new WebSocket(
36
- `${protocol}://${window.location.host}/api/socket?userId=${encodeURIComponent(userId)}`
36
+ `${protocol}://${window.location.host}/ws?userId=${encodeURIComponent(userId)}`
37
37
  );
38
38
  this.ws = ws;
39
39
  this.connecting = true;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/client/socketManager.ts","../../src/client/SocketProvider.tsx"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export type ChatEvent =\n | { type: 'message:received'; message: any; conversationId: string; [k: string]: any }\n | { type: 'typing'; conversationId: string; senderId: string; state: string; [k: string]: any };\n\ntype Listener<T> = (v: T) => void;\n\nclass SocketManager {\n private ws: WebSocket | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private manualClose = false;\n private attempt = 0;\n private warmedUp = false;\n private connecting = false;\n\n private connListeners = new Set<Listener<boolean>>();\n private eventListeners = new Set<Listener<ChatEvent>>();\n\n private getUserId: () => string | undefined = () => undefined;\n\n constructor(getUserId?: () => string | undefined) {\n if (getUserId) this.getUserId = getUserId;\n }\n\n setGetUserId = (fn: () => string | undefined) => {\n this.getUserId = fn;\n };\n\n private notifyConn(v: boolean) {\n this.connListeners.forEach((l) => l(v));\n }\n private notifyEvent(e: ChatEvent) {\n this.eventListeners.forEach((l) => l(e));\n }\n\n addConnListener(l: Listener<boolean>) {\n this.connListeners.add(l);\n return () => this.connListeners.delete(l);\n }\n addEventListener(l: Listener<ChatEvent>) {\n this.eventListeners.add(l);\n return () => this.eventListeners.delete(l);\n }\n\n connect = async () => {\n const userId = this.getUserId();\n if (!userId || typeof window === 'undefined') return;\n\n // Prevent overlapping connect attempts\n if (this.connecting) return;\n\n if (!this.warmedUp) {\n try {\n await fetch('/api/socket', { method: 'GET', keepalive: true });\n } catch {}\n this.warmedUp = true;\n }\n\n if (\n this.ws &&\n (this.ws.readyState === WebSocket.OPEN ||\n this.ws.readyState === WebSocket.CONNECTING ||\n this.ws.readyState === WebSocket.CLOSING)\n )\n return;\n\n const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';\n const ws = new WebSocket(\n `${protocol}://${window.location.host}/api/socket?userId=${encodeURIComponent(userId)}`,\n );\n this.ws = ws;\n this.connecting = true;\n\n ws.onopen = () => {\n if (this.ws !== ws) return;\n this.attempt = 0;\n this.manualClose = false;\n this.notifyConn(true);\n this.connecting = false;\n };\n ws.onmessage = (msg) => {\n if (this.ws !== ws) return;\n try {\n this.notifyEvent(JSON.parse(msg.data as string));\n } catch {}\n };\n ws.onclose = () => {\n if (this.ws !== ws) return;\n this.notifyConn(false);\n this.ws = null;\n this.connecting = false;\n if (this.manualClose) return;\n this.attempt += 1;\n const delay = Math.min(5000, 500 * Math.pow(2, this.attempt));\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = setTimeout(this.connect, delay);\n };\n ws.onerror = () => {\n try {\n ws.close();\n } catch {}\n if (this.ws === ws) {\n this.connecting = false;\n }\n };\n };\n\n send = (payload: unknown) => {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;\n try {\n this.ws.send(JSON.stringify(payload));\n return true;\n } catch {\n return false;\n }\n };\n\n close = () => {\n this.manualClose = true;\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n try {\n this.ws?.close();\n } catch {}\n this.ws = null;\n };\n}\n\nexport default SocketManager;\n\n// Singleton accessor that survives Next.js Fast Refresh by storing on globalThis\ndeclare const globalThis: typeof global & {\n __socketManagerSingleton?: SocketManager | null;\n};\n\nexport function getSocketManager(getUserId?: () => string | undefined) {\n if (!globalThis.__socketManagerSingleton) {\n globalThis.__socketManagerSingleton = new SocketManager(getUserId);\n } else if (getUserId) {\n globalThis.__socketManagerSingleton.setGetUserId(getUserId);\n }\n return globalThis.__socketManagerSingleton;\n}\n","'use client';\n\nimport { createContext, useContext, useEffect, useMemo, useState, useCallback, type ReactNode } from 'react';\nimport { getSocketManager } from './socketManager';\nimport type { ChatEvent } from './socketManager';\n\nexport type SocketCtxShape = {\n isConnected: boolean;\n send: (payload: unknown) => boolean;\n onEvent: (cb: (e: ChatEvent) => void) => () => void;\n};\n\nconst SocketCtx = createContext<SocketCtxShape | null>(null);\n\nexport function SocketProvider({\n children,\n getUserId,\n}: {\n children: ReactNode;\n getUserId: () => string | undefined;\n}) {\n const [isConnected, setIsConnected] = useState(false);\n\n // Singleton manager for this tab\n const mgr = useMemo(() => getSocketManager(getUserId), [getUserId]);\n\n useEffect(() => {\n // keep user id up to date and ensure connection\n mgr.setGetUserId(getUserId);\n if (getUserId()) mgr.connect();\n // Note: we intentionally DO NOT close the socket on unmount to avoid\n // duplicate connects under React Strict Mode/Fast Refresh.\n }, [mgr, getUserId]);\n\n useEffect(() => {\n const off = mgr.addConnListener(setIsConnected);\n return () => {\n off();\n };\n }, [mgr]);\n\n const sendFn = useCallback<SocketCtxShape['send']>((payload: unknown) => mgr.send(payload), [mgr]);\n const onEventFn = useCallback<SocketCtxShape['onEvent']>((cb: (e: ChatEvent) => void) => mgr.addEventListener(cb), [mgr]);\n\n const value = useMemo<SocketCtxShape>(\n () => ({\n isConnected,\n send: sendFn,\n onEvent: onEventFn,\n }),\n [isConnected, sendFn, onEventFn],\n );\n\n return <SocketCtx.Provider value={value}>{children}</SocketCtx.Provider>;\n}\n\nexport function useSocket() {\n const ctx = useContext(SocketCtx);\n if (!ctx) throw new Error('useSocket must be used within <SocketProvider />');\n return ctx;\n}\n"]}
1
+ {"version":3,"sources":["../../src/client/socketManager.ts","../../src/client/SocketProvider.tsx"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export type ChatEvent =\n | { type: 'message:created'; [k: string]: any }\n | { type: 'conversation:read'; [k: string]: any }\n | { type: 'typing'; [k: string]: any }\n | { type: 'presence:join'; [k: string]: any }\n | { type: 'presence:leave'; [k: string]: any };\n\ntype Listener<T> = (v: T) => void;\n\nclass SocketManager {\n private ws: WebSocket | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private manualClose = false;\n private attempt = 0;\n private warmedUp = false;\n private connecting = false;\n\n private connListeners = new Set<Listener<boolean>>();\n private eventListeners = new Set<Listener<ChatEvent>>();\n\n private getUserId: () => string | undefined = () => undefined;\n\n constructor(getUserId?: () => string | undefined) {\n if (getUserId) this.getUserId = getUserId;\n }\n\n setGetUserId = (fn: () => string | undefined) => {\n this.getUserId = fn;\n };\n\n private notifyConn(v: boolean) {\n this.connListeners.forEach((l) => l(v));\n }\n private notifyEvent(e: ChatEvent) {\n this.eventListeners.forEach((l) => l(e));\n }\n\n addConnListener(l: Listener<boolean>) {\n this.connListeners.add(l);\n return () => this.connListeners.delete(l);\n }\n addEventListener(l: Listener<ChatEvent>) {\n this.eventListeners.add(l);\n return () => this.eventListeners.delete(l);\n }\n\n connect = async () => {\n const userId = this.getUserId();\n if (!userId || typeof window === 'undefined') return;\n\n // Prevent overlapping connect attempts\n if (this.connecting) return;\n\n if (!this.warmedUp) {\n try {\n await fetch('/api/socket', { method: 'GET', keepalive: true });\n } catch {}\n this.warmedUp = true;\n }\n\n if (\n this.ws &&\n (this.ws.readyState === WebSocket.OPEN ||\n this.ws.readyState === WebSocket.CONNECTING ||\n this.ws.readyState === WebSocket.CLOSING)\n )\n return;\n\n const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';\n const ws = new WebSocket(\n `${protocol}://${window.location.host}/ws?userId=${encodeURIComponent(userId)}`,\n );\n this.ws = ws;\n this.connecting = true;\n\n ws.onopen = () => {\n if (this.ws !== ws) return;\n this.attempt = 0;\n this.manualClose = false;\n this.notifyConn(true);\n this.connecting = false;\n };\n ws.onmessage = (msg) => {\n if (this.ws !== ws) return;\n try {\n this.notifyEvent(JSON.parse(msg.data as string));\n } catch {}\n };\n ws.onclose = () => {\n if (this.ws !== ws) return;\n this.notifyConn(false);\n this.ws = null;\n this.connecting = false;\n if (this.manualClose) return;\n this.attempt += 1;\n const delay = Math.min(5000, 500 * Math.pow(2, this.attempt));\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = setTimeout(this.connect, delay);\n };\n ws.onerror = () => {\n try {\n ws.close();\n } catch {}\n if (this.ws === ws) {\n this.connecting = false;\n }\n };\n };\n\n send = (payload: unknown) => {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;\n try {\n this.ws.send(JSON.stringify(payload));\n return true;\n } catch {\n return false;\n }\n };\n\n close = () => {\n this.manualClose = true;\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n try {\n this.ws?.close();\n } catch {}\n this.ws = null;\n };\n}\n\nexport default SocketManager;\n\n// Singleton accessor that survives Next.js Fast Refresh by storing on globalThis\ndeclare const globalThis: typeof global & {\n __socketManagerSingleton?: SocketManager | null;\n};\n\nexport function getSocketManager(getUserId?: () => string | undefined) {\n if (!globalThis.__socketManagerSingleton) {\n globalThis.__socketManagerSingleton = new SocketManager(getUserId);\n } else if (getUserId) {\n globalThis.__socketManagerSingleton.setGetUserId(getUserId);\n }\n return globalThis.__socketManagerSingleton;\n}\n","'use client';\n\nimport { createContext, useContext, useEffect, useMemo, useState, useCallback, type ReactNode } from 'react';\nimport { getSocketManager } from './socketManager';\nimport type { ChatEvent } from './socketManager';\n\nexport type SocketCtxShape = {\n isConnected: boolean;\n send: (payload: unknown) => boolean;\n onEvent: (cb: (e: ChatEvent) => void) => () => void;\n};\n\nconst SocketCtx = createContext<SocketCtxShape | null>(null);\n\nexport function SocketProvider({\n children,\n getUserId,\n}: {\n children: ReactNode;\n getUserId: () => string | undefined;\n}) {\n const [isConnected, setIsConnected] = useState(false);\n\n // Singleton manager for this tab\n const mgr = useMemo(() => getSocketManager(getUserId), [getUserId]);\n\n useEffect(() => {\n // keep user id up to date and ensure connection\n mgr.setGetUserId(getUserId);\n if (getUserId()) mgr.connect();\n // Note: we intentionally DO NOT close the socket on unmount to avoid\n // duplicate connects under React Strict Mode/Fast Refresh.\n }, [mgr, getUserId]);\n\n useEffect(() => {\n const off = mgr.addConnListener(setIsConnected);\n return () => {\n off();\n };\n }, [mgr]);\n\n const sendFn = useCallback<SocketCtxShape['send']>((payload: unknown) => mgr.send(payload), [mgr]);\n const onEventFn = useCallback<SocketCtxShape['onEvent']>((cb: (e: ChatEvent) => void) => mgr.addEventListener(cb), [mgr]);\n\n const value = useMemo<SocketCtxShape>(\n () => ({\n isConnected,\n send: sendFn,\n onEvent: onEventFn,\n }),\n [isConnected, sendFn, onEventFn],\n );\n\n return <SocketCtx.Provider value={value}>{children}</SocketCtx.Provider>;\n}\n\nexport function useSocket() {\n const ctx = useContext(SocketCtx);\n if (!ctx) throw new Error('useSocket must be used within <SocketProvider />');\n return ctx;\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -70,6 +70,14 @@ type SocketConfig$1 = {
70
70
  };
71
71
  };
72
72
  type InitOptions = {
73
+ /**
74
+ * Models that can participate in conversations
75
+ */
76
+ participantModels: string[];
77
+ /**
78
+ * Valid roles for conversation participants
79
+ */
80
+ memberRoles: string[];
73
81
  /**
74
82
  * MongoDB connection URI
75
83
  */
@@ -83,9 +91,16 @@ type InitOptions = {
83
91
  */
84
92
  redis?: RedisConfig;
85
93
  /**
86
- * Socket configuration
94
+ * Socket.IO configuration
87
95
  */
88
96
  socket?: SocketConfig$1;
97
+ /**
98
+ * Presence tracking configuration
99
+ */
100
+ presence?: {
101
+ enabled?: boolean;
102
+ heartbeatSec?: number;
103
+ };
89
104
  /**
90
105
  * Rate limiting configuration
91
106
  */
@@ -113,7 +128,6 @@ type SendMessageArgs = {
113
128
  senderId: string;
114
129
  text: string;
115
130
  kind?: "text" | "system";
116
- targetUserId?: string;
117
131
  parentMessageId?: string;
118
132
  rootThreadId?: string;
119
133
  };
@@ -128,6 +142,13 @@ type ListMessagesArgs = {
128
142
  modelMapping?: Record<string, string>;
129
143
  };
130
144
  };
145
+ type MarkReadArgs = {
146
+ organizationId: string;
147
+ conversationId: string;
148
+ participantModel: string;
149
+ participantId: string;
150
+ messageId: string;
151
+ };
131
152
  type SearchMessagesArgs = {
132
153
  organizationId: string;
133
154
  query?: string;
@@ -260,7 +281,10 @@ declare function createConversationsService(models: {
260
281
  * Message service hooks interface
261
282
  */
262
283
  interface MessageServiceHooks {
263
- onMessageCreated?: (message: IMessage, targetUserId?: string) => void | Promise<void>;
284
+ onMessageCreated?: (message: IMessage) => void;
285
+ onMessageRead?: (args: MarkReadArgs & {
286
+ at: Date;
287
+ }) => void;
264
288
  }
265
289
  /**
266
290
  * Message with populated sender information
@@ -277,6 +301,7 @@ interface MessagesService {
277
301
  listMessagesWithSenders(args: ListMessagesArgs & {
278
302
  populateSenders: true;
279
303
  }): Promise<PaginatedResult<MessageWithSender>>;
304
+ markRead(args: MarkReadArgs): Promise<void>;
280
305
  }
281
306
  /**
282
307
  * Create messages service
@@ -290,58 +315,46 @@ declare function createMessagesService(models: {
290
315
  }, hooks?: MessageServiceHooks): MessagesService;
291
316
 
292
317
  /**
293
- * In-memory subscription maps for users and sockets
318
+ * In-memory subscription maps for conversations and sockets
294
319
  */
295
320
  /**
296
- * Subscribe a socket to a user's messages
297
- * @param userId The user ID to subscribe to
321
+ * Subscribe a socket to a conversation
322
+ * @param conversationId The conversation ID to subscribe to
298
323
  * @param socketId The socket ID to subscribe
299
324
  */
300
- declare function subscribeToUser(userId: string, socketId: string): void;
325
+ declare function subscribeLocal(conversationId: string, socketId: string): void;
301
326
  /**
302
- * Unsubscribe a socket from a user's messages
303
- * @param userId The user ID to unsubscribe from
327
+ * Unsubscribe a socket from a conversation
328
+ * @param conversationId The conversation ID to unsubscribe from
304
329
  * @param socketId The socket ID to unsubscribe
305
330
  */
306
- declare function unsubscribeFromUser(userId: string, socketId: string): void;
331
+ declare function unsubscribeLocal(conversationId: string, socketId: string): void;
307
332
  /**
308
333
  * Clean up all subscriptions for a socket
309
334
  * @param socketId The socket ID to clean up
310
335
  */
311
336
  declare function cleanupLocal(socketId: string): void;
312
337
  /**
313
- * Check if a socket is subscribed to a user
314
- * @param userId The user ID to check
315
- * @param socketId The socket ID to check
316
- */
317
- declare function isSubscribedToUser(userId: string, socketId: string): boolean;
318
- /**
319
- * Get all sockets subscribed to a user
320
- * @param userId The user ID
321
- */
322
- declare function getSocketsForUser(userId: string): Set<string>;
323
- /**
324
- * Fan out a message to all local subscribers of a user
325
- * @param userId The user ID to fan out to
338
+ * Fan out a message to all local subscribers of a conversation
339
+ * @param conversationId The conversation ID to fan out to
326
340
  * @param payload The payload to send
327
341
  * @param emitter Function to emit events to socket IDs
328
342
  */
329
- declare function fanoutToUser(userId: string, payload: any, emitter: (toSocketId: string, event: any) => void): void;
330
- declare function subscribeLocal(_conversationId: string, _socketId: string): void;
331
- declare function unsubscribeLocal(_conversationId: string, _socketId: string): void;
332
- declare function fanoutLocal(_conversationId: string, payload: any, emitter: (toSocketId: string, event: any) => void): void;
343
+ declare function fanoutLocal(conversationId: string, payload: any, emitter: (toSocketId: string, event: any) => void): void;
333
344
 
334
345
  interface SocketConfig {
335
346
  path?: string;
336
347
  }
337
348
  interface SocketUser {
338
349
  id: string;
350
+ model: string;
339
351
  }
340
352
  declare class SocketTransport {
341
353
  private wss;
342
354
  private transport;
343
355
  private sockets;
344
356
  private users;
357
+ private joinedConversations;
345
358
  private authenticate?;
346
359
  private ioShim;
347
360
  constructor(server: Server, transport: Transport, config?: SocketConfig);
@@ -357,10 +370,6 @@ declare class SocketTransport {
357
370
  * Get Socket.IO server instance
358
371
  */
359
372
  getIO(): any;
360
- /**
361
- * Send a message to a specific user
362
- */
363
- sendToUser(userId: string, payload: any): void;
364
373
  /**
365
374
  * Close Socket.IO server
366
375
  */
@@ -382,11 +391,6 @@ interface Transport {
382
391
  unsubscribeLocal: typeof unsubscribeLocal;
383
392
  cleanupLocal: typeof cleanupLocal;
384
393
  fanoutLocal: typeof fanoutLocal;
385
- subscribeToUser: typeof subscribeToUser;
386
- unsubscribeFromUser: typeof unsubscribeFromUser;
387
- isSubscribedToUser: typeof isSubscribedToUser;
388
- getSocketsForUser: typeof getSocketsForUser;
389
- fanoutToUser: typeof fanoutToUser;
390
394
  publishToConversation: (conversationId: string, payload: any) => Promise<number>;
391
395
  startRedisListener: (onEvent: (conversationId: string, payload: any) => void) => (() => void) | null;
392
396
  }
@@ -473,6 +477,12 @@ declare function validateSendMessageArgs(args: SendMessageArgs): string | null;
473
477
  * @returns Validation error message or null if valid
474
478
  */
475
479
  declare function validateCreateConversationArgs(args: CreateConversationArgs): string | null;
480
+ /**
481
+ * Validates mark read arguments
482
+ * @param args Mark read arguments to validate
483
+ * @returns Validation error message or null if valid
484
+ */
485
+ declare function validateMarkReadArgs(args: MarkReadArgs): string | null;
476
486
  /**
477
487
  * Validates pagination parameters
478
488
  * @param limit Pagination limit
@@ -536,4 +546,4 @@ interface ChatServices {
536
546
  */
537
547
  declare function initChat(mongooseInstance: typeof mongoose, options: InitOptions, server?: Server): Promise<ChatServices>;
538
548
 
539
- export { type Actor, type ChatServices, type ConversationModel, type ConversationReadEvent, type ConversationsService, type CreateConversationArgs, type EnvironmentConfig, type IConversation, type IMessage, type InitOptions, type ListMessagesArgs, type MessageCreatedEvent, type MessageModel, type MessageServiceHooks, type MessageWithSender, type MessagesService, type PaginatedResult, type PresenceEvent, type RedisConfig, type SearchConversationsArgs, type SearchMessagesArgs, type SendMessageArgs, type SocketConfig$1 as SocketConfig, type Transport, type TransportConfig, type TypingEvent, createConversationId, createConversationModel, createConversationsService, createCursor, createMessageId, createMessageModel, createMessagesService, createModels, createPaginatedResponse, createThreadId, createTransport, ensureIndexes, generateId, getRedisConfig, initChat, modelRegistry, parseCursor, searchByParticipantPair, searchConversations, searchMessages, validateCreateConversationArgs, validateInitOptions, validatePagination, validateSendMessageArgs };
549
+ export { type Actor, type ChatServices, type ConversationModel, type ConversationReadEvent, type ConversationsService, type CreateConversationArgs, type EnvironmentConfig, type IConversation, type IMessage, type InitOptions, type ListMessagesArgs, type MarkReadArgs, type MessageCreatedEvent, type MessageModel, type MessageServiceHooks, type MessageWithSender, type MessagesService, type PaginatedResult, type PresenceEvent, type RedisConfig, type SearchConversationsArgs, type SearchMessagesArgs, type SendMessageArgs, type SocketConfig$1 as SocketConfig, type Transport, type TransportConfig, type TypingEvent, createConversationId, createConversationModel, createConversationsService, createCursor, createMessageId, createMessageModel, createMessagesService, createModels, createPaginatedResponse, createThreadId, createTransport, ensureIndexes, generateId, getRedisConfig, initChat, modelRegistry, parseCursor, searchByParticipantPair, searchConversations, searchMessages, validateCreateConversationArgs, validateInitOptions, validateMarkReadArgs, validatePagination, validateSendMessageArgs };