@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.
- package/README.md +224 -0
- package/dist/assets/music/i_phone_message.mp3 +0 -0
- package/dist/call/WebRTCManager.d.ts +54 -0
- package/dist/call/WebRTCManager.js +274 -0
- package/dist/call/signaling.d.ts +3 -0
- package/dist/call/signaling.js +24 -0
- package/dist/call/types.d.ts +25 -0
- package/dist/call/types.js +13 -0
- package/dist/components/Chat.d.ts +14 -0
- package/dist/components/Chat.js +452 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +2 -0
- package/dist/context/ChatContext.d.ts +59 -0
- package/dist/context/ChatContext.js +647 -0
- package/dist/esm/call/WebRTCManager.d.ts +54 -0
- package/dist/esm/call/WebRTCManager.js +274 -0
- package/dist/esm/call/signaling.d.ts +3 -0
- package/dist/esm/call/signaling.js +24 -0
- package/dist/esm/call/types.d.ts +25 -0
- package/dist/esm/call/types.js +13 -0
- package/dist/esm/components/Chat.d.ts +14 -0
- package/dist/esm/components/Chat.js +452 -0
- package/dist/esm/components/index.d.ts +3 -0
- package/dist/esm/components/index.js +2 -0
- package/dist/esm/context/ChatContext.d.ts +59 -0
- package/dist/esm/context/ChatContext.js +647 -0
- package/dist/esm/hooks/index.d.ts +6 -0
- package/dist/esm/hooks/index.js +6 -0
- package/dist/esm/hooks/useCall.d.ts +15 -0
- package/dist/esm/hooks/useCall.js +38 -0
- package/dist/esm/hooks/useChat.d.ts +23 -0
- package/dist/esm/hooks/useChat.js +40 -0
- package/dist/esm/hooks/useMessages.d.ts +17 -0
- package/dist/esm/hooks/useMessages.js +38 -0
- package/dist/esm/hooks/usePresence.d.ts +4 -0
- package/dist/esm/hooks/usePresence.js +12 -0
- package/dist/esm/hooks/useRooms.d.ts +9 -0
- package/dist/esm/hooks/useRooms.js +19 -0
- package/dist/esm/hooks/useSocket.d.ts +7 -0
- package/dist/esm/hooks/useSocket.js +92 -0
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/utils/index.d.ts +2 -0
- package/dist/esm/utils/index.js +2 -0
- package/dist/esm/utils/ringtone.d.ts +2 -0
- package/dist/esm/utils/ringtone.js +39 -0
- package/dist/esm/utils/testUtils.d.ts +102 -0
- package/dist/esm/utils/testUtils.js +153 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/useCall.d.ts +15 -0
- package/dist/hooks/useCall.js +38 -0
- package/dist/hooks/useChat.d.ts +23 -0
- package/dist/hooks/useChat.js +40 -0
- package/dist/hooks/useMessages.d.ts +17 -0
- package/dist/hooks/useMessages.js +38 -0
- package/dist/hooks/usePresence.d.ts +4 -0
- package/dist/hooks/usePresence.js +12 -0
- package/dist/hooks/useRooms.d.ts +9 -0
- package/dist/hooks/useRooms.js +19 -0
- package/dist/hooks/useSocket.d.ts +7 -0
- package/dist/hooks/useSocket.js +92 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/public/music/i_phone_message.mp3 +0 -0
- package/dist/style.css +1226 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/ringtone.d.ts +2 -0
- package/dist/utils/ringtone.js +39 -0
- package/dist/utils/testUtils.d.ts +102 -0
- package/dist/utils/testUtils.js +153 -0
- 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,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
|
+
};
|