@enfin/chat 1.2.0

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.
Files changed (73) hide show
  1. package/README.md +224 -0
  2. package/dist/assets/music/i_phone_message.mp3 +0 -0
  3. package/dist/call/WebRTCManager.d.ts +54 -0
  4. package/dist/call/WebRTCManager.js +274 -0
  5. package/dist/call/signaling.d.ts +3 -0
  6. package/dist/call/signaling.js +24 -0
  7. package/dist/call/types.d.ts +25 -0
  8. package/dist/call/types.js +13 -0
  9. package/dist/components/Chat.d.ts +14 -0
  10. package/dist/components/Chat.js +452 -0
  11. package/dist/components/index.d.ts +3 -0
  12. package/dist/components/index.js +2 -0
  13. package/dist/context/ChatContext.d.ts +59 -0
  14. package/dist/context/ChatContext.js +647 -0
  15. package/dist/esm/call/WebRTCManager.d.ts +54 -0
  16. package/dist/esm/call/WebRTCManager.js +274 -0
  17. package/dist/esm/call/signaling.d.ts +3 -0
  18. package/dist/esm/call/signaling.js +24 -0
  19. package/dist/esm/call/types.d.ts +25 -0
  20. package/dist/esm/call/types.js +13 -0
  21. package/dist/esm/components/Chat.d.ts +14 -0
  22. package/dist/esm/components/Chat.js +452 -0
  23. package/dist/esm/components/index.d.ts +3 -0
  24. package/dist/esm/components/index.js +2 -0
  25. package/dist/esm/context/ChatContext.d.ts +59 -0
  26. package/dist/esm/context/ChatContext.js +647 -0
  27. package/dist/esm/hooks/index.d.ts +6 -0
  28. package/dist/esm/hooks/index.js +6 -0
  29. package/dist/esm/hooks/useCall.d.ts +15 -0
  30. package/dist/esm/hooks/useCall.js +38 -0
  31. package/dist/esm/hooks/useChat.d.ts +23 -0
  32. package/dist/esm/hooks/useChat.js +40 -0
  33. package/dist/esm/hooks/useMessages.d.ts +17 -0
  34. package/dist/esm/hooks/useMessages.js +38 -0
  35. package/dist/esm/hooks/usePresence.d.ts +4 -0
  36. package/dist/esm/hooks/usePresence.js +12 -0
  37. package/dist/esm/hooks/useRooms.d.ts +9 -0
  38. package/dist/esm/hooks/useRooms.js +19 -0
  39. package/dist/esm/hooks/useSocket.d.ts +7 -0
  40. package/dist/esm/hooks/useSocket.js +92 -0
  41. package/dist/esm/index.d.ts +11 -0
  42. package/dist/esm/index.js +15 -0
  43. package/dist/esm/utils/index.d.ts +2 -0
  44. package/dist/esm/utils/index.js +2 -0
  45. package/dist/esm/utils/ringtone.d.ts +2 -0
  46. package/dist/esm/utils/ringtone.js +39 -0
  47. package/dist/esm/utils/testUtils.d.ts +102 -0
  48. package/dist/esm/utils/testUtils.js +153 -0
  49. package/dist/hooks/index.d.ts +6 -0
  50. package/dist/hooks/index.js +6 -0
  51. package/dist/hooks/useCall.d.ts +15 -0
  52. package/dist/hooks/useCall.js +38 -0
  53. package/dist/hooks/useChat.d.ts +23 -0
  54. package/dist/hooks/useChat.js +40 -0
  55. package/dist/hooks/useMessages.d.ts +17 -0
  56. package/dist/hooks/useMessages.js +38 -0
  57. package/dist/hooks/usePresence.d.ts +4 -0
  58. package/dist/hooks/usePresence.js +12 -0
  59. package/dist/hooks/useRooms.d.ts +9 -0
  60. package/dist/hooks/useRooms.js +19 -0
  61. package/dist/hooks/useSocket.d.ts +7 -0
  62. package/dist/hooks/useSocket.js +92 -0
  63. package/dist/index.d.ts +11 -0
  64. package/dist/index.js +15 -0
  65. package/dist/public/music/i_phone_message.mp3 +0 -0
  66. package/dist/style.css +1226 -0
  67. package/dist/utils/index.d.ts +2 -0
  68. package/dist/utils/index.js +2 -0
  69. package/dist/utils/ringtone.d.ts +2 -0
  70. package/dist/utils/ringtone.js +39 -0
  71. package/dist/utils/testUtils.d.ts +102 -0
  72. package/dist/utils/testUtils.js +153 -0
  73. package/package.json +44 -0
@@ -0,0 +1,647 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
3
+ import { io } from 'socket.io-client';
4
+ import { SDK_VERSION } from '@enfin/chat-shared';
5
+ import { WebRTCManager } from '../call/WebRTCManager';
6
+ import { attachSignaling } from '../call/signaling';
7
+ import { startRingtone, stopRingtone } from '../utils/ringtone';
8
+ function getApiUrl(baseUrl, path) {
9
+ const trimmedBase = baseUrl.replace(/\/+$/, '');
10
+ const normalizedPath = path.replace(/^\/+/, '');
11
+ const hasApi = /\/api$/.test(trimmedBase);
12
+ return hasApi ? `${trimmedBase}/${normalizedPath}` : `${trimmedBase}/api/${normalizedPath}`;
13
+ }
14
+ function getSocketUrl(baseUrl) {
15
+ return baseUrl.replace(/\/+$/, '').replace(/\/api$/, '');
16
+ }
17
+ const ChatContext = createContext(null);
18
+ export function ChatProvider({ config, userId, userName, serverUrl, children }) {
19
+ const socketRef = useRef(null);
20
+ const refreshUsersRef = useRef(async () => { });
21
+ const [socket, setSocket] = useState(null);
22
+ const [connected, setConnected] = useState(false);
23
+ const [loading, setLoading] = useState(true);
24
+ const [error, setError] = useState();
25
+ const [tenantId, setTenantId] = useState();
26
+ const [validated, setValidated] = useState(false);
27
+ const [messages, setMessages] = useState([]);
28
+ const [rooms, setRooms] = useState([]);
29
+ const [users, setUsers] = useState([]);
30
+ const [currentRoom, setCurrentRoom] = useState();
31
+ const [activeCall, setActiveCall] = useState();
32
+ const [typingUsers, setTypingUsers] = useState([]);
33
+ const [isMuted, setIsMuted] = useState(false);
34
+ const [callState, setCallState] = useState('idle');
35
+ const [callError, setCallError] = useState();
36
+ const [remoteStream, setRemoteStream] = useState(null);
37
+ const [busyBanner, setBusyBanner] = useState();
38
+ const rtcManagerRef = useRef(null);
39
+ const detachSignalingRef = useRef(null);
40
+ useEffect(() => {
41
+ let mounted = true;
42
+ const validationUrl = config.validationUrl || getApiUrl(serverUrl, 'validation/validate');
43
+ async function validateApiKey() {
44
+ setLoading(true);
45
+ setError(undefined);
46
+ setValidated(false);
47
+ setTenantId(undefined);
48
+ if (!config?.apiKey) {
49
+ setError('Chat API key is required');
50
+ setLoading(false);
51
+ return;
52
+ }
53
+ try {
54
+ const response = await fetch(validationUrl, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({ apiKey: config.apiKey, clientVersion: config.version || SDK_VERSION }),
58
+ });
59
+ if (!response.ok) {
60
+ throw new Error(`API key validation failed with status ${response.status}`);
61
+ }
62
+ const payload = await response.json();
63
+ if (!payload.valid) {
64
+ setError(payload.error || 'Invalid API key');
65
+ setLoading(false);
66
+ return;
67
+ }
68
+ if (!payload.tenantId) {
69
+ setError('API key validation did not return a tenantId');
70
+ setLoading(false);
71
+ return;
72
+ }
73
+ if (mounted) {
74
+ setTenantId(payload.tenantId);
75
+ setValidated(true);
76
+ setLoading(false);
77
+ }
78
+ }
79
+ catch (err) {
80
+ if (!mounted)
81
+ return;
82
+ setError(err?.message || 'API key validation failed');
83
+ setLoading(false);
84
+ }
85
+ }
86
+ validateApiKey();
87
+ return () => {
88
+ mounted = false;
89
+ };
90
+ }, [config.apiKey, config.version, config.validationUrl]);
91
+ useEffect(() => {
92
+ if (!validated || !tenantId)
93
+ return;
94
+ const socketUrl = `${getSocketUrl(serverUrl)}/chat`;
95
+ console.log('========================================');
96
+ console.log('[CLIENT] Connecting socket to:', socketUrl);
97
+ console.log('[CLIENT] Auth:', { apiKey: config.apiKey?.slice(0, 12) + '...', userId, userName, tenantId });
98
+ console.log('========================================');
99
+ const newSocket = io(socketUrl, {
100
+ auth: { apiKey: config.apiKey, userId, userName, tenantId },
101
+ transports: ['websocket'],
102
+ });
103
+ newSocket.on('connect', () => {
104
+ console.log('[CLIENT] socket CONNECTED — socket.id =', newSocket.id);
105
+ setConnected(true);
106
+ setLoading(false);
107
+ // Load users and rooms after connecting (with a small delay to ensure server handshake is fully done)
108
+ setTimeout(() => {
109
+ loadRooms(userId, newSocket);
110
+ refreshUsers();
111
+ }, 200);
112
+ });
113
+ newSocket.on('disconnect', (reason) => {
114
+ console.log('[CLIENT] socket DISCONNECTED — reason =', reason);
115
+ stopRingtone();
116
+ setConnected(false);
117
+ });
118
+ newSocket.on('connect_error', (err) => {
119
+ console.log('[CLIENT] socket CONNECT_ERROR —', err?.message || err);
120
+ });
121
+ newSocket.on('message:new', (message) => {
122
+ console.log('[CLIENT] message:new RECEIVED');
123
+ console.log('[CLIENT] _id =', message?._id);
124
+ console.log('[CLIENT] roomId =', message?.roomId, 'type =', typeof message?.roomId);
125
+ console.log('[CLIENT] senderId =', message?.senderId);
126
+ console.log('[CLIENT] content =', message?.content);
127
+ console.log('[CLIENT] full message =', JSON.stringify(message));
128
+ setMessages(prev => {
129
+ if (message?._id && prev.some(m => m._id === message._id)) {
130
+ console.log(`[CLIENT] message:new DEDUP — _id=${message._id} already in state, ignoring`);
131
+ return prev;
132
+ }
133
+ const next = [...prev, message];
134
+ console.log(`[CLIENT] messages state now has ${next.length} entries`);
135
+ return next;
136
+ });
137
+ });
138
+ newSocket.on('message:typing', (data) => {
139
+ console.log('[CLIENT] message:typing RECEIVED —', JSON.stringify(data));
140
+ // Skip typing events from self
141
+ if (data.userId === userId) {
142
+ console.log(`[CLIENT] message:typing SKIPPED — from self (userId=${userId})`);
143
+ return;
144
+ }
145
+ setTypingUsers(prev => {
146
+ if (data.typing) {
147
+ const existing = prev.find(u => u.userId === data.userId);
148
+ if (existing) {
149
+ const typingIn = existing.typingIn?.includes(data.roomId)
150
+ ? existing.typingIn
151
+ : [...(existing.typingIn || []), data.roomId];
152
+ return prev.map(u => u.userId === data.userId ? { ...u, typingIn, lastSeen: new Date() } : u);
153
+ }
154
+ return [...prev, { userId: data.userId, status: 'online', lastSeen: new Date(), typingIn: [data.roomId] }];
155
+ }
156
+ // Stop typing — remove only this room from the user's typingIn list
157
+ return prev
158
+ .map(u => {
159
+ if (u.userId !== data.userId)
160
+ return u;
161
+ const typingIn = (u.typingIn || []).filter(r => r !== data.roomId);
162
+ return { ...u, typingIn };
163
+ })
164
+ .filter(u => !u.typingIn || u.typingIn.length === 0);
165
+ });
166
+ });
167
+ newSocket.on('call:incoming', (call) => {
168
+ console.log('[CLIENT] call:incoming RECEIVED —', JSON.stringify(call));
169
+ setActiveCall(call);
170
+ startRingtone(config.ringtoneUrl);
171
+ // Attach signaling NOW so we don't lose the offer/ICE candidates that
172
+ // the caller may already be sending. The manager buffers them internally
173
+ // until the user clicks Accept (which then requests mic + processes the offer).
174
+ if (!rtcManagerRef.current) {
175
+ const manager = new WebRTCManager({ iceServers: config.iceServers }, {
176
+ sendSignal: () => { },
177
+ onStateChange: (s) => setCallState(s),
178
+ onRemoteStream: (s) => setRemoteStream(s),
179
+ onError: (e) => setCallError(e?.message || 'WebRTC error')
180
+ });
181
+ // This is a CALLEE — we received the offer via call:incoming, not via startCall.
182
+ // Mark the manager so it buffers offers until the user clicks Accept.
183
+ manager.markAsCallee();
184
+ rtcManagerRef.current = manager;
185
+ if (detachSignalingRef.current)
186
+ detachSignalingRef.current();
187
+ // CRITICAL: use newSocket (the live socket from this effect's closure),
188
+ // NOT the `socket` state variable which may be null in the stale closure.
189
+ detachSignalingRef.current = attachSignaling(newSocket, manager);
190
+ }
191
+ });
192
+ newSocket.on('call:accepted', (call) => {
193
+ console.log('[CLIENT] call:accepted RECEIVED —', JSON.stringify(call));
194
+ stopRingtone();
195
+ setActiveCall(call);
196
+ });
197
+ newSocket.on('call:rejected', () => {
198
+ console.log('[CLIENT] call:rejected RECEIVED');
199
+ stopRingtone();
200
+ });
201
+ newSocket.on('call:ended', () => {
202
+ console.log('[CLIENT] call:ended RECEIVED');
203
+ stopRingtone();
204
+ setActiveCall(undefined);
205
+ setIsMuted(false);
206
+ });
207
+ newSocket.on('presence:changed', () => {
208
+ console.log('[CLIENT] presence:changed RECEIVED — refreshing users');
209
+ refreshUsersRef.current();
210
+ });
211
+ // Polling fallback in case presence:changed is missed (every 8s while connected)
212
+ const pollInterval = setInterval(() => {
213
+ refreshUsersRef.current();
214
+ }, 8000);
215
+ newSocket.onAny((event, ...args) => {
216
+ console.log(`[CLIENT] socket.onAny — event='${event}' args.length=${args.length}`);
217
+ // Emit custom events for external listeners
218
+ window.dispatchEvent(new CustomEvent('chat:socket-event', { detail: { event, data: args[0] } }));
219
+ });
220
+ socketRef.current = newSocket;
221
+ setSocket(newSocket);
222
+ return () => {
223
+ console.log('[CLIENT] Cleaning up socket — id =', newSocket.id);
224
+ clearInterval(pollInterval);
225
+ newSocket.disconnect();
226
+ socketRef.current = null;
227
+ setSocket(null);
228
+ };
229
+ }, [serverUrl, userId, userName, tenantId, validated, config.ringtoneUrl]);
230
+ const mergeMessages = useCallback((incoming) => {
231
+ setMessages(prev => {
232
+ const seen = new Set(prev.map(m => m._id));
233
+ const merged = [...prev];
234
+ for (const msg of incoming) {
235
+ if (!seen.has(msg._id)) {
236
+ merged.push(msg);
237
+ seen.add(msg._id);
238
+ }
239
+ }
240
+ return merged.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
241
+ });
242
+ }, []);
243
+ const loadRoomMessages = useCallback(async (roomId) => {
244
+ const url = getApiUrl(serverUrl, `rooms/${roomId}/messages`);
245
+ console.log('========================================');
246
+ console.log('[CLIENT] loadRoomMessages — roomId =', roomId);
247
+ console.log('[CLIENT] GET URL =', url);
248
+ console.log('========================================');
249
+ try {
250
+ const response = await fetch(url, {
251
+ headers: {
252
+ 'Content-Type': 'application/json',
253
+ 'x-api-key': config.apiKey,
254
+ },
255
+ });
256
+ console.log(`[CLIENT] loadRoomMessages — response status=${response.status}`);
257
+ if (!response.ok) {
258
+ console.log(`[CLIENT] loadRoomMessages — non-OK response, returning`);
259
+ return;
260
+ }
261
+ const data = await response.json();
262
+ const count = Array.isArray(data.messages) ? data.messages.length : 0;
263
+ console.log(`[CLIENT] loadRoomMessages — count=${count}`);
264
+ if (count > 0) {
265
+ console.log('[CLIENT] loadRoomMessages — first message:', JSON.stringify(data.messages[0]));
266
+ console.log('[CLIENT] loadRoomMessages — first message roomId =', data.messages[0].roomId, 'type =', typeof data.messages[0].roomId);
267
+ }
268
+ if (Array.isArray(data.messages)) {
269
+ mergeMessages(data.messages);
270
+ }
271
+ }
272
+ catch (err) {
273
+ console.warn('[CLIENT] loadRooms — failed:', err);
274
+ }
275
+ }, [serverUrl, mergeMessages]);
276
+ const loadRooms = useCallback(async (forUserId, targetSocket) => {
277
+ const url = `${serverUrl.replace(/\/+$/, '').replace(/\/api$/, '')}/api/rooms?userId=${encodeURIComponent(forUserId)}`;
278
+ console.log('========================================');
279
+ console.log('[CLIENT] loadRooms — URL =', url);
280
+ console.log('========================================');
281
+ try {
282
+ const response = await fetch(url, {
283
+ headers: {
284
+ 'Content-Type': 'application/json',
285
+ 'x-api-key': config.apiKey,
286
+ },
287
+ });
288
+ console.log(`[CLIENT] loadRooms — response status=${response.status}`);
289
+ if (!response.ok) {
290
+ console.log(`[CLIENT] loadRooms — non-OK response, skipping`);
291
+ return;
292
+ }
293
+ const data = await response.json();
294
+ const roomList = Array.isArray(data.rooms) ? data.rooms : [];
295
+ console.log(`[CLIENT] loadRooms — count=${roomList.length}`);
296
+ if (roomList.length > 0) {
297
+ console.log('[CLIENT] loadRooms — first room:', JSON.stringify(roomList[0]).slice(0, 200));
298
+ console.log('[CLIENT] loadRooms — first room _id =', roomList[0]._id, 'type =', typeof roomList[0]._id);
299
+ console.log('[CLIENT] loadRooms — first room id =', roomList[0].id, 'type =', typeof roomList[0].id);
300
+ }
301
+ setRooms(roomList);
302
+ // Join each room channel so we get live messages
303
+ const sock = targetSocket || socketRef.current;
304
+ for (const room of roomList) {
305
+ const roomId = room._id || room.id;
306
+ if (roomId && sock) {
307
+ try {
308
+ sock.emit('room:join', { roomId });
309
+ console.log('[CLIENT] loadRooms — joined room:', roomId);
310
+ }
311
+ catch (e) {
312
+ console.log('[CLIENT] loadRooms — join error:', e);
313
+ }
314
+ }
315
+ }
316
+ }
317
+ catch (err) {
318
+ console.warn('[CLIENT] loadRooms — fetch failed:', err);
319
+ }
320
+ }, [serverUrl, config.apiKey]);
321
+ const refreshUsers = useCallback(async () => {
322
+ const url = getApiUrl(serverUrl, 'users');
323
+ try {
324
+ const response = await fetch(url, {
325
+ headers: {
326
+ 'Content-Type': 'application/json',
327
+ 'x-api-key': config.apiKey,
328
+ },
329
+ });
330
+ if (!response.ok)
331
+ return;
332
+ const data = await response.json();
333
+ const list = Array.isArray(data) ? data : [];
334
+ console.log('[CLIENT] refreshUsers — got', list.length, 'users. Online:', list.filter(u => u.isOnline).map(u => u.name));
335
+ setUsers(list);
336
+ }
337
+ catch (err) {
338
+ console.warn('[CLIENT] refreshUsers — failed:', err);
339
+ }
340
+ }, [serverUrl, config.apiKey]);
341
+ // Keep ref in sync so socket event handlers always use latest closure
342
+ refreshUsersRef.current = refreshUsers;
343
+ const sendMessage = useCallback(async (roomId, content, fileMetadata) => {
344
+ console.log('========================================');
345
+ console.log('[CLIENT] sendMessage — roomId =', roomId, 'content =', content, 'fileMetadata =', fileMetadata);
346
+ console.log('[CLIENT] socket connected =', socket?.connected);
347
+ console.log('========================================');
348
+ if (!socket) {
349
+ console.log('[CLIENT] sendMessage — no socket, returning');
350
+ return;
351
+ }
352
+ try {
353
+ const payload = { roomId, content, ...(fileMetadata || {}) };
354
+ const ack = await socket.emitWithAck('message:send', payload);
355
+ console.log('[CLIENT] sendMessage — ACK received:', JSON.stringify(ack));
356
+ }
357
+ catch (err) {
358
+ console.log('[CLIENT] sendMessage — ACK error:', err);
359
+ }
360
+ }, [socket]);
361
+ const selectRoom = useCallback((roomId) => {
362
+ console.log('========================================');
363
+ console.log('[CLIENT] selectRoom — roomId =', roomId);
364
+ console.log('========================================');
365
+ const room = rooms.find(r => r._id === roomId);
366
+ console.log('[CLIENT] selectRoom — found room:', room ? { _id: room._id, name: room.name } : null);
367
+ setCurrentRoom(room);
368
+ if (room) {
369
+ console.log('[CLIENT] selectRoom — calling loadRoomMessages and emitting room:select');
370
+ loadRoomMessages(room._id);
371
+ // Inform server which room the client selected so server can track active room
372
+ try {
373
+ socketRef.current?.emitWithAck('room:select', { roomId });
374
+ console.log('[CLIENT] selectRoom — emitted room:select');
375
+ }
376
+ catch (e) {
377
+ console.log('[CLIENT] selectRoom — emit error:', e);
378
+ }
379
+ }
380
+ else {
381
+ console.log('[CLIENT] selectRoom — room NOT found in rooms list, only setCurrentRoom(undefined-equivalent)');
382
+ }
383
+ }, [rooms, loadRoomMessages]);
384
+ const createRoom = useCallback(async (name, type, members) => {
385
+ if (type === 'direct' && members.length === 2) {
386
+ const response = await fetch(getApiUrl(serverUrl, 'rooms/direct'), {
387
+ method: 'POST',
388
+ headers: {
389
+ 'Content-Type': 'application/json',
390
+ 'x-api-key': config.apiKey,
391
+ },
392
+ body: JSON.stringify({ userId, members, name, apiKey: config.apiKey }),
393
+ });
394
+ if (!response.ok) {
395
+ throw new Error(`Failed to open direct room: ${response.status}`);
396
+ }
397
+ const payload = await response.json();
398
+ console.log('createRoom response:', JSON.stringify(payload).slice(0, 500));
399
+ const room = payload.room;
400
+ if (room) {
401
+ console.log('createRoom: room._id=', room._id, 'typeRoomId=', typeof room._id, 'messages count=', Array.isArray(payload.messages) ? payload.messages.length : 'n/a');
402
+ // Log the first message to debug
403
+ if (Array.isArray(payload.messages) && payload.messages.length > 0) {
404
+ console.log('createRoom firstmsg:', JSON.stringify(payload.messages[0]));
405
+ console.log('createRoom firstmsg.roomId=', payload.messages[0].roomId, 'type=', typeof payload.messages[0].roomId);
406
+ }
407
+ setRooms(prev => {
408
+ if (prev.some(r => r._id === room._id))
409
+ return prev;
410
+ return [...prev, room];
411
+ });
412
+ if (Array.isArray(payload.messages)) {
413
+ mergeMessages(payload.messages);
414
+ }
415
+ setCurrentRoom(room);
416
+ }
417
+ return room;
418
+ }
419
+ if (!socket)
420
+ return {};
421
+ const room = await socket.emitWithAck('room:create', { name, type, members });
422
+ setRooms(prev => [...prev, room]);
423
+ return room;
424
+ }, [socket, serverUrl, userId, mergeMessages, config.apiKey]);
425
+ const ensureRtcManager = useCallback(() => {
426
+ if (rtcManagerRef.current || !socket)
427
+ return;
428
+ const manager = new WebRTCManager({ iceServers: config.iceServers }, {
429
+ sendSignal: () => { },
430
+ onStateChange: (s) => setCallState(s),
431
+ onRemoteStream: (s) => setRemoteStream(s),
432
+ onError: (e) => setCallError(e?.message || 'WebRTC error')
433
+ });
434
+ rtcManagerRef.current = manager;
435
+ if (detachSignalingRef.current)
436
+ detachSignalingRef.current();
437
+ // Use socketRef.current to get the live socket, not the stale closure value
438
+ const liveSocket = socketRef.current || socket;
439
+ if (liveSocket) {
440
+ detachSignalingRef.current = attachSignaling(liveSocket, manager);
441
+ }
442
+ }, [socket, config.iceServers]);
443
+ const startCall = useCallback(async (roomId, participantId) => {
444
+ stopRingtone();
445
+ if (!socket)
446
+ return;
447
+ // Cleanup existing manager FIRST
448
+ if (rtcManagerRef.current) {
449
+ rtcManagerRef.current.close();
450
+ rtcManagerRef.current = null;
451
+ }
452
+ if (detachSignalingRef.current) {
453
+ detachSignalingRef.current();
454
+ detachSignalingRef.current = null;
455
+ }
456
+ ensureRtcManager();
457
+ setCallError(undefined);
458
+ setRemoteStream(null);
459
+ setCallState('requesting-media');
460
+ // Acquire mic + create PC + buffer offer (no signal sent yet)
461
+ try {
462
+ await rtcManagerRef.current.startAsCaller(roomId);
463
+ }
464
+ catch (err) {
465
+ setCallError(err?.message || 'Failed to access microphone');
466
+ setCallState('failed');
467
+ return;
468
+ }
469
+ // Tell the server about the call. Only after server confirms does
470
+ // the callee exist in the DB, so any signals routed before now are dropped.
471
+ try {
472
+ const call = await socket.emitWithAck('call:initiate', { roomId, participantId });
473
+ setActiveCall(call);
474
+ // Server confirmed — flush buffered offer + ICE candidates to callee
475
+ await rtcManagerRef.current.sendOffer();
476
+ }
477
+ catch (err) {
478
+ // The server may reject with "user is busy on another call" — surface it
479
+ // as a transient banner the user can see even when no chat is open.
480
+ const msg = err?.message || 'Failed to initiate call';
481
+ setCallError(msg);
482
+ setCallState('failed');
483
+ setBusyBanner(msg);
484
+ if (rtcManagerRef.current) {
485
+ rtcManagerRef.current.close();
486
+ rtcManagerRef.current = null;
487
+ }
488
+ if (detachSignalingRef.current) {
489
+ detachSignalingRef.current();
490
+ detachSignalingRef.current = null;
491
+ }
492
+ }
493
+ }, [socket, ensureRtcManager]);
494
+ const endCall = useCallback(async () => {
495
+ stopRingtone();
496
+ if (rtcManagerRef.current) {
497
+ rtcManagerRef.current.close();
498
+ rtcManagerRef.current = null;
499
+ }
500
+ if (detachSignalingRef.current) {
501
+ detachSignalingRef.current();
502
+ detachSignalingRef.current = null;
503
+ }
504
+ setRemoteStream(null);
505
+ setCallState('closed');
506
+ if (!socket || !activeCall) {
507
+ setActiveCall(undefined);
508
+ setIsMuted(false);
509
+ return;
510
+ }
511
+ try {
512
+ await socket.emitWithAck('call:end', { callId: activeCall._id });
513
+ }
514
+ catch { /* ignore */ }
515
+ setActiveCall(undefined);
516
+ setIsMuted(false);
517
+ }, [socket, activeCall]);
518
+ const clearBusyBanner = useCallback(() => {
519
+ setBusyBanner(undefined);
520
+ }, []);
521
+ const acceptCall = useCallback(async () => {
522
+ stopRingtone();
523
+ if (!socket || !activeCall)
524
+ return;
525
+ const updated = await socket.emitWithAck('call:accept', { callId: activeCall._id });
526
+ setActiveCall(updated);
527
+ if (!rtcManagerRef.current) {
528
+ ensureRtcManager();
529
+ }
530
+ setCallError(undefined);
531
+ setRemoteStream(null);
532
+ // Mark the manager as accepted so handleOffer will process the buffered offer
533
+ // (request mic, open PC, send answer). This also flips the callee gate.
534
+ rtcManagerRef.current?.markAsAccepted();
535
+ // Process the buffered offer now (mic permission is requested inside handleOffer
536
+ // via acquireLocalStream, which uses this user gesture to unlock the prompt).
537
+ try {
538
+ await rtcManagerRef.current?.processPendingOffer();
539
+ }
540
+ catch (err) {
541
+ setCallError(err?.message || 'Failed to process call');
542
+ }
543
+ }, [socket, activeCall, ensureRtcManager]);
544
+ const rejectCall = useCallback(async () => {
545
+ stopRingtone();
546
+ if (!socket || !activeCall) {
547
+ setActiveCall(undefined);
548
+ setIsMuted(false);
549
+ return;
550
+ }
551
+ try {
552
+ await socket.emitWithAck('call:reject', { callId: activeCall._id });
553
+ }
554
+ catch { /* ignore */ }
555
+ if (rtcManagerRef.current) {
556
+ rtcManagerRef.current.close();
557
+ rtcManagerRef.current = null;
558
+ }
559
+ if (detachSignalingRef.current) {
560
+ detachSignalingRef.current();
561
+ detachSignalingRef.current = null;
562
+ }
563
+ setActiveCall(undefined);
564
+ setIsMuted(false);
565
+ setRemoteStream(null);
566
+ setCallState('closed');
567
+ }, [socket, activeCall]);
568
+ const mute = useCallback(() => {
569
+ setIsMuted(true);
570
+ rtcManagerRef.current?.setMuted(true);
571
+ }, []);
572
+ const unmute = useCallback(() => {
573
+ setIsMuted(false);
574
+ rtcManagerRef.current?.setMuted(false);
575
+ }, []);
576
+ // Typing indicator — debounced emit / stop
577
+ const emitTypingTimerRef = useRef(null);
578
+ const stopTypingTimerRef = useRef(null);
579
+ const emitTyping = useCallback((roomId, typing) => {
580
+ const sock = socketRef.current || socket;
581
+ if (!sock?.connected || !roomId)
582
+ return;
583
+ sock
584
+ .emitWithAck('message:typing', {
585
+ userId,
586
+ roomId,
587
+ typing,
588
+ })
589
+ .catch(() => { });
590
+ }, [socket, userId]);
591
+ const debouncedEmitTyping = useCallback((roomId, typing) => {
592
+ if (emitTypingTimerRef.current) {
593
+ clearTimeout(emitTypingTimerRef.current);
594
+ }
595
+ emitTypingTimerRef.current = setTimeout(() => emitTyping(roomId, typing), 300);
596
+ }, [emitTyping]);
597
+ const stopTypingSoon = useCallback((roomId) => {
598
+ if (stopTypingTimerRef.current) {
599
+ clearTimeout(stopTypingTimerRef.current);
600
+ }
601
+ stopTypingTimerRef.current = setTimeout(() => {
602
+ emitTyping(roomId, false);
603
+ }, 2000);
604
+ }, [emitTyping]);
605
+ const value = {
606
+ userId,
607
+ userName,
608
+ messages,
609
+ rooms,
610
+ users,
611
+ currentRoom,
612
+ activeCall,
613
+ typingUsers,
614
+ connected,
615
+ loading,
616
+ error,
617
+ sendMessage,
618
+ selectRoom,
619
+ createRoom,
620
+ startCall,
621
+ endCall,
622
+ acceptCall,
623
+ rejectCall,
624
+ mute,
625
+ unmute,
626
+ isMuted,
627
+ socket,
628
+ refreshUsers,
629
+ callState,
630
+ callError,
631
+ remoteStream,
632
+ busyBanner,
633
+ clearBusyBanner,
634
+ emitTyping,
635
+ debouncedEmitTyping,
636
+ stopTypingSoon,
637
+ loadRoomMessages,
638
+ };
639
+ return (_jsx(ChatContext.Provider, { value: value, children: children }));
640
+ }
641
+ export function useChatContext() {
642
+ const context = useContext(ChatContext);
643
+ if (!context) {
644
+ throw new Error('useChatContext must be used within ChatProvider');
645
+ }
646
+ return context;
647
+ }
@@ -0,0 +1,6 @@
1
+ export { useChat } from './useChat';
2
+ export { useRooms } from './useRooms';
3
+ export { useMessages } from './useMessages';
4
+ export { useCall } from './useCall';
5
+ export { useSocket } from './useSocket';
6
+ export { usePresence } from './usePresence';
@@ -0,0 +1,6 @@
1
+ export { useChat } from './useChat';
2
+ export { useRooms } from './useRooms';
3
+ export { useMessages } from './useMessages';
4
+ export { useCall } from './useCall';
5
+ export { useSocket } from './useSocket';
6
+ export { usePresence } from './usePresence';
@@ -0,0 +1,15 @@
1
+ export declare function useCall(): {
2
+ activeCall: import("@enfin/chat-shared").AudioCall;
3
+ callState: import("../call/WebRTCManager").WebRTCManagerState;
4
+ callError: string;
5
+ remoteStream: MediaStream;
6
+ busyBanner: string;
7
+ clearBusyBanner: () => void;
8
+ startCall: (roomId: string, participantId: string) => Promise<void>;
9
+ endCall: () => Promise<void>;
10
+ acceptCall: () => Promise<void>;
11
+ rejectCall: () => Promise<void>;
12
+ mute: () => void;
13
+ unmute: () => void;
14
+ isMuted: boolean;
15
+ };