@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,452 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
3
|
+
import { useChatContext } from '../context/ChatContext';
|
|
4
|
+
import { useCall } from '../hooks/useCall';
|
|
5
|
+
/**
|
|
6
|
+
* Default Chat Component
|
|
7
|
+
*
|
|
8
|
+
* Two-pane layout:
|
|
9
|
+
* - Top: app header with current user (avatar, name, switch button)
|
|
10
|
+
* - Left: list of registered users (click to select a peer)
|
|
11
|
+
* - Right: messages + input area for the selected peer
|
|
12
|
+
*/
|
|
13
|
+
export function Chat({ theme = 'light' }) {
|
|
14
|
+
const { userId, userName, messages, rooms, users, currentRoom, sendMessage, selectRoom, createRoom, typingUsers, connected, loading, error, refreshUsers, callState, callError, remoteStream, busyBanner, clearBusyBanner, socket, } = useChatContext();
|
|
15
|
+
const { activeCall, startCall, endCall, acceptCall, isMuted, mute, unmute } = useCall();
|
|
16
|
+
const [inputValue, setInputValue] = useState('');
|
|
17
|
+
const [selectedUserId, setSelectedUserId] = useState(null);
|
|
18
|
+
const messagesEndRef = useRef(null);
|
|
19
|
+
const fileInputRef = useRef(null);
|
|
20
|
+
const emojiPickerRef = useRef(null);
|
|
21
|
+
const emojiButtonRef = useRef(null);
|
|
22
|
+
const inputRef = useRef(null);
|
|
23
|
+
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
|
24
|
+
const [selectedFile, setSelectedFile] = useState(null);
|
|
25
|
+
const [uploading, setUploading] = useState(false);
|
|
26
|
+
// Typing detection with debounced emit
|
|
27
|
+
const emitTypingTimerRef = useRef(null);
|
|
28
|
+
const stopTypingTimerRef = useRef(null);
|
|
29
|
+
const emitTyping = useCallback((typing) => {
|
|
30
|
+
if (!socket?.connected || !currentRoom)
|
|
31
|
+
return;
|
|
32
|
+
socket
|
|
33
|
+
.emitWithAck('message:typing', {
|
|
34
|
+
userId,
|
|
35
|
+
roomId: currentRoom._id,
|
|
36
|
+
typing,
|
|
37
|
+
})
|
|
38
|
+
.catch(() => { });
|
|
39
|
+
}, [socket, currentRoom, userId]);
|
|
40
|
+
const debouncedEmitTyping = useCallback((typing) => {
|
|
41
|
+
if (emitTypingTimerRef.current) {
|
|
42
|
+
clearTimeout(emitTypingTimerRef.current);
|
|
43
|
+
}
|
|
44
|
+
emitTypingTimerRef.current = setTimeout(() => emitTyping(typing), 300);
|
|
45
|
+
}, [emitTyping]);
|
|
46
|
+
const emitStopTypingAfterDelay = useCallback(() => {
|
|
47
|
+
if (stopTypingTimerRef.current) {
|
|
48
|
+
clearTimeout(stopTypingTimerRef.current);
|
|
49
|
+
}
|
|
50
|
+
stopTypingTimerRef.current = setTimeout(() => {
|
|
51
|
+
emitTyping(false);
|
|
52
|
+
}, 2000);
|
|
53
|
+
}, [emitTyping]);
|
|
54
|
+
// Cleanup timers on unmount
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
return () => {
|
|
57
|
+
if (emitTypingTimerRef.current) {
|
|
58
|
+
clearTimeout(emitTypingTimerRef.current);
|
|
59
|
+
}
|
|
60
|
+
if (stopTypingTimerRef.current) {
|
|
61
|
+
clearTimeout(stopTypingTimerRef.current);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
// Stop typing when switching rooms
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
emitTyping(false);
|
|
68
|
+
if (stopTypingTimerRef.current) {
|
|
69
|
+
clearTimeout(stopTypingTimerRef.current);
|
|
70
|
+
}
|
|
71
|
+
}, [currentRoom?._id, emitTyping]);
|
|
72
|
+
// Filter out the current user from the users list
|
|
73
|
+
const otherUsers = useMemo(() => users.filter(u => u.userId !== userId), [users, userId]);
|
|
74
|
+
// Track which peer the currentRoom represents
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!currentRoom) {
|
|
77
|
+
setSelectedUserId(null);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const other = currentRoom.members.find(m => m !== userId);
|
|
81
|
+
if (other)
|
|
82
|
+
setSelectedUserId(other);
|
|
83
|
+
}, [currentRoom, userId]);
|
|
84
|
+
// Scroll to bottom on new messages
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
87
|
+
}, [messages]);
|
|
88
|
+
// Cleanup object URL when file changes or component unmounts
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
return () => {
|
|
91
|
+
if (selectedFile?.previewUrl) {
|
|
92
|
+
URL.revokeObjectURL(selectedFile.previewUrl);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}, [selectedFile]);
|
|
96
|
+
// Close emoji picker on click outside
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!showEmojiPicker)
|
|
99
|
+
return;
|
|
100
|
+
const handleClickOutside = (e) => {
|
|
101
|
+
const target = e.target;
|
|
102
|
+
// Don't close if clicking inside emoji picker or on the emoji button
|
|
103
|
+
if (emojiPickerRef.current?.contains(target))
|
|
104
|
+
return;
|
|
105
|
+
if (emojiButtonRef.current?.contains(target))
|
|
106
|
+
return;
|
|
107
|
+
setShowEmojiPicker(false);
|
|
108
|
+
};
|
|
109
|
+
document.addEventListener('click', handleClickOutside);
|
|
110
|
+
return () => document.removeEventListener('click', handleClickOutside);
|
|
111
|
+
}, [showEmojiPicker]);
|
|
112
|
+
// Auto-dismiss the busy banner after 4 seconds
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!busyBanner)
|
|
115
|
+
return;
|
|
116
|
+
const t = setTimeout(() => clearBusyBanner(), 4000);
|
|
117
|
+
return () => clearTimeout(t);
|
|
118
|
+
}, [busyBanner, clearBusyBanner]);
|
|
119
|
+
const handleSelectUser = useCallback(async (peerId) => {
|
|
120
|
+
if (!peerId || peerId === userId)
|
|
121
|
+
return;
|
|
122
|
+
setSelectedUserId(peerId);
|
|
123
|
+
// Find existing direct room between current user and peer
|
|
124
|
+
const existing = rooms.find(r => r.type === 'direct' && r.members.length === 2 && r.members.includes(peerId) && r.members.includes(userId));
|
|
125
|
+
if (existing) {
|
|
126
|
+
selectRoom(existing._id);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Otherwise create the direct room
|
|
130
|
+
const room = await createRoom('direct', 'direct', [userId, peerId]);
|
|
131
|
+
if (room?._id) {
|
|
132
|
+
selectRoom(room._id);
|
|
133
|
+
}
|
|
134
|
+
}, [userId, createRoom, selectRoom, rooms]);
|
|
135
|
+
const handleSend = async (e) => {
|
|
136
|
+
if (e)
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
if (!currentRoom)
|
|
139
|
+
return;
|
|
140
|
+
// If a file is selected, upload and send as file message
|
|
141
|
+
if (selectedFile) {
|
|
142
|
+
await handleFileSend();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!inputValue.trim())
|
|
146
|
+
return;
|
|
147
|
+
const content = inputValue.trim();
|
|
148
|
+
setInputValue('');
|
|
149
|
+
emitTyping(false);
|
|
150
|
+
if (emitTypingTimerRef.current) {
|
|
151
|
+
clearTimeout(emitTypingTimerRef.current);
|
|
152
|
+
}
|
|
153
|
+
if (stopTypingTimerRef.current) {
|
|
154
|
+
clearTimeout(stopTypingTimerRef.current);
|
|
155
|
+
}
|
|
156
|
+
await sendMessage(currentRoom._id, content);
|
|
157
|
+
};
|
|
158
|
+
const handleFileSend = async () => {
|
|
159
|
+
if (!selectedFile || !currentRoom)
|
|
160
|
+
return;
|
|
161
|
+
setUploading(true);
|
|
162
|
+
try {
|
|
163
|
+
const formData = new FormData();
|
|
164
|
+
formData.append('file', selectedFile.file);
|
|
165
|
+
formData.append('roomId', currentRoom._id);
|
|
166
|
+
const serverUrl = import.meta.env?.VITE_SERVER_URL || 'http://localhost:3002';
|
|
167
|
+
const apiKey = import.meta.env?.VITE_CHAT_API_KEY || 'chat_dev_key';
|
|
168
|
+
const response = await fetch(`${serverUrl}/api/upload`, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: {
|
|
171
|
+
'x-api-key': apiKey,
|
|
172
|
+
},
|
|
173
|
+
body: formData,
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
throw new Error(`File upload failed: ${response.status}`);
|
|
177
|
+
}
|
|
178
|
+
const data = await response.json();
|
|
179
|
+
console.log('[CLIENT] File upload response:', data);
|
|
180
|
+
// Use the typed text as the message content (caption) if any,
|
|
181
|
+
// otherwise fall back to the filename so something is always sent.
|
|
182
|
+
const caption = inputValue.trim() || selectedFile.file.name;
|
|
183
|
+
// Send the message with file metadata + caption
|
|
184
|
+
await sendMessage(currentRoom._id, caption, {
|
|
185
|
+
fileUrl: data.fileUrl,
|
|
186
|
+
fileType: data.fileType || selectedFile.file.type,
|
|
187
|
+
fileName: data.fileName || selectedFile.file.name,
|
|
188
|
+
});
|
|
189
|
+
// Clear selected file and the typed text
|
|
190
|
+
setInputValue('');
|
|
191
|
+
emitTyping(false);
|
|
192
|
+
if (emitTypingTimerRef.current) {
|
|
193
|
+
clearTimeout(emitTypingTimerRef.current);
|
|
194
|
+
}
|
|
195
|
+
if (stopTypingTimerRef.current) {
|
|
196
|
+
clearTimeout(stopTypingTimerRef.current);
|
|
197
|
+
}
|
|
198
|
+
clearSelectedFile();
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
console.error('[CLIENT] File upload error:', err);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
setUploading(false);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const handleFileSelect = (e) => {
|
|
208
|
+
const file = e.target.files?.[0];
|
|
209
|
+
if (!file)
|
|
210
|
+
return;
|
|
211
|
+
// Revoke any previous preview URL
|
|
212
|
+
if (selectedFile?.previewUrl) {
|
|
213
|
+
URL.revokeObjectURL(selectedFile.previewUrl);
|
|
214
|
+
}
|
|
215
|
+
const previewUrl = URL.createObjectURL(file);
|
|
216
|
+
setSelectedFile({ file, previewUrl });
|
|
217
|
+
// Reset the input value so the same file can be re-selected
|
|
218
|
+
if (fileInputRef.current) {
|
|
219
|
+
fileInputRef.current.value = '';
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const clearSelectedFile = () => {
|
|
223
|
+
if (selectedFile?.previewUrl) {
|
|
224
|
+
URL.revokeObjectURL(selectedFile.previewUrl);
|
|
225
|
+
}
|
|
226
|
+
setSelectedFile(null);
|
|
227
|
+
};
|
|
228
|
+
const handleEmojiSelect = (emoji) => {
|
|
229
|
+
const el = inputRef.current;
|
|
230
|
+
if (!el) {
|
|
231
|
+
setInputValue(prev => prev + emoji);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const start = el.selectionStart ?? inputValue.length;
|
|
235
|
+
const end = el.selectionEnd ?? inputValue.length;
|
|
236
|
+
const next = inputValue.slice(0, start) + emoji + inputValue.slice(end);
|
|
237
|
+
setInputValue(next);
|
|
238
|
+
// Restore caret right after the inserted emoji
|
|
239
|
+
requestAnimationFrame(() => {
|
|
240
|
+
el.focus();
|
|
241
|
+
const pos = start + emoji.length;
|
|
242
|
+
el.setSelectionRange(pos, pos);
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
const handleStartCall = () => {
|
|
246
|
+
if (currentRoom && selectedUserId) {
|
|
247
|
+
startCall(currentRoom._id, selectedUserId);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const handleSwitchUser = () => {
|
|
251
|
+
localStorage.removeItem('userId');
|
|
252
|
+
localStorage.removeItem('userName');
|
|
253
|
+
window.location.reload();
|
|
254
|
+
};
|
|
255
|
+
const emojis = ['😀', '😂', '😍', '👍', '👎', '❤️', '🔥', '💯', '🎉', '😎', '😢', '😡', '🤔', '🙄', '🤷', '👏'];
|
|
256
|
+
// Find the selected peer info for the chat header
|
|
257
|
+
const selectedPeer = otherUsers.find(u => u.userId === selectedUserId);
|
|
258
|
+
// Check if the selected peer is typing
|
|
259
|
+
const isPeerTyping = currentRoom
|
|
260
|
+
? typingUsers.filter(t => t.typingIn?.includes(currentRoom._id) && t.userId !== userId).length > 0
|
|
261
|
+
: false;
|
|
262
|
+
// Check if selected peer is online
|
|
263
|
+
const isPeerOnline = selectedPeer ? selectedPeer.isOnline === true : false;
|
|
264
|
+
if (loading) {
|
|
265
|
+
return (_jsx("div", { className: `chat-container theme-${theme}`, children: _jsxs("div", { className: "chat-loading", children: [_jsx("div", { className: "spinner" }), _jsx("p", { children: "Connecting..." })] }) }));
|
|
266
|
+
}
|
|
267
|
+
if (error) {
|
|
268
|
+
return (_jsx("div", { className: `chat-container theme-${theme}`, children: _jsxs("div", { className: "chat-error", children: [_jsxs("p", { children: ["Connection error: ", error] }), _jsx("button", { onClick: () => window.location.reload(), children: "Retry" })] }) }));
|
|
269
|
+
}
|
|
270
|
+
return (_jsxs("div", { className: `chat-container theme-${theme}`, children: [_jsxs("header", { className: "app-topbar", children: [_jsx("div", { className: "app-brand", children: "Chat App" }), _jsxs("div", { className: "app-user", children: [_jsx("div", { className: "app-user-avatar", children: (userName[0] || 'U').toUpperCase() }), _jsx("div", { className: "app-user-name", children: userName }), _jsx("button", { className: "app-switch-btn", onClick: handleSwitchUser, title: "Switch user", "aria-label": "Switch user", children: "\u21C4" })] })] }), _jsxs("div", { className: "chat-body", children: [_jsxs("aside", { className: "chat-sidebar", children: [_jsxs("div", { className: "chat-sidebar-header", children: [_jsx("span", { children: "Users" }), _jsx("button", { className: "refresh-users-btn", onClick: refreshUsers, title: "Refresh users", "aria-label": "Refresh users", children: "\u27F3" })] }), _jsx("div", { className: "user-list", children: otherUsers.length === 0 ? (_jsx("div", { className: "no-users", children: "No other users yet" })) : (otherUsers.map((u) => (_jsx(UserListItem, { user: u, isActive: selectedUserId === u.userId, onClick: () => handleSelectUser(u.userId) }, u.userId)))) })] }), _jsx("main", { className: "chat-main", children: currentRoom && selectedPeer ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "chat-room-header", children: [_jsxs("div", { className: "chat-room-avatar-wrapper", children: [_jsx("div", { className: "chat-room-avatar", children: (selectedPeer.name[0] || '?').toUpperCase() }), isPeerOnline && _jsx("span", { className: "online-indicator", title: "Online" })] }), _jsxs("div", { className: "chat-room-info", children: [_jsx("div", { className: "chat-room-name", children: selectedPeer.name }), _jsx("div", { className: "chat-room-status", children: isPeerTyping
|
|
271
|
+
? 'typing...'
|
|
272
|
+
: isPeerOnline
|
|
273
|
+
? 'online'
|
|
274
|
+
: 'offline' })] }), activeCall && activeCall.roomId === currentRoom._id && activeCall.status === 'active' ? (_jsx("button", { className: "call-button call-button-active", onClick: endCall, title: "End call", "aria-label": "End call", children: _jsx("span", { className: "call-icon", children: "\uD83D\uDCF5" }) })) : activeCall && activeCall.roomId === currentRoom._id && activeCall.status === 'ringing' && activeCall.initiatorId === userId ? (_jsx("button", { className: "call-button call-button-calling", onClick: endCall, title: "Cancel call", "aria-label": "Cancel call", disabled: true, children: _jsx("span", { className: "call-icon", children: "\uD83D\uDCDE" }) })) : (_jsx("button", { className: "call-button", onClick: handleStartCall, disabled: !connected || !!activeCall, title: "Start call", "aria-label": "Start call", children: _jsx("span", { className: "call-icon", children: "\uD83D\uDCDE" }) }))] }), _jsxs("div", { className: "message-area", children: [_jsxs("div", { className: "messages", children: [messages
|
|
275
|
+
.filter(m => m.roomId === currentRoom._id)
|
|
276
|
+
.map((msg) => (_jsx(MessageBubble, { message: msg }, msg._id))), _jsx("div", { ref: messagesEndRef })] }), typingUsers.filter(t => t.typingIn?.includes(currentRoom._id) && t.userId !== userId).length > 0 && (_jsxs("div", { className: "typing-indicator", children: [_jsx("span", { className: "typing-dot" }), _jsx("span", { className: "typing-dot" }), _jsx("span", { className: "typing-dot" })] }))] }), _jsxs("div", { className: "message-input-container", children: [showEmojiPicker && (_jsx("div", { className: "emoji-picker", ref: emojiPickerRef, onMouseDown: (e) => e.preventDefault(), onClick: (e) => e.stopPropagation(), children: emojis.map((emoji, index) => (_jsx("button", { type: "button", className: "emoji-option", onClick: () => handleEmojiSelect(emoji), children: emoji }, index))) })), selectedFile && (_jsxs("div", { className: "file-preview", children: [selectedFile.file.type.startsWith('image/') ? (_jsx("img", { src: selectedFile.previewUrl, alt: selectedFile.file.name, className: "file-preview-image" })) : (_jsxs("div", { className: "file-preview-doc", children: [_jsx("span", { className: "file-preview-icon", children: "\uD83D\uDCC4" }), _jsx("span", { className: "file-preview-name", children: selectedFile.file.name })] })), _jsx("button", { type: "button", className: "file-preview-remove", onClick: clearSelectedFile, title: "Remove file", children: "\u2715" })] })), _jsxs("form", { onSubmit: handleSend, className: "message-input-form", children: [_jsx("button", { type: "button", ref: emojiButtonRef, className: "input-button", onClick: (e) => {
|
|
277
|
+
e.stopPropagation();
|
|
278
|
+
setShowEmojiPicker((prev) => !prev);
|
|
279
|
+
}, title: "Emoji", children: "\uD83D\uDE0A" }), _jsx("input", { ref: inputRef, type: "text", value: inputValue, onChange: (e) => {
|
|
280
|
+
setInputValue(e.target.value);
|
|
281
|
+
if (e.target.value.length > 0) {
|
|
282
|
+
debouncedEmitTyping(true);
|
|
283
|
+
emitStopTypingAfterDelay();
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
if (stopTypingTimerRef.current) {
|
|
287
|
+
clearTimeout(stopTypingTimerRef.current);
|
|
288
|
+
}
|
|
289
|
+
if (emitTypingTimerRef.current) {
|
|
290
|
+
clearTimeout(emitTypingTimerRef.current);
|
|
291
|
+
}
|
|
292
|
+
emitTyping(false);
|
|
293
|
+
}
|
|
294
|
+
}, placeholder: selectedFile ? 'Add a caption (optional)...' : 'type here', disabled: !connected, className: "message-input-field" }), _jsx("button", { type: "button", className: "input-button", onClick: () => fileInputRef.current?.click(), title: "Attach file", disabled: uploading, children: "\uD83D\uDCCE" }), _jsx("input", { type: "file", ref: fileInputRef, onChange: handleFileSelect, style: { display: 'none' }, accept: "image/*,video/*,application/pdf,.doc,.docx,.txt" }), _jsx("button", { type: "submit", disabled: !connected ||
|
|
295
|
+
uploading ||
|
|
296
|
+
(!inputValue.trim() && !selectedFile), title: "Send", children: uploading ? '⏳' : '➤' })] })] })] })) : (_jsx("div", { className: "no-room-selected", children: _jsx("p", { children: "Select a user to start chatting" }) })) })] }), activeCall && activeCall.status === 'ringing' && activeCall.initiatorId !== userId && (_jsx(IncomingCallPopup, { call: activeCall, onAccept: acceptCall, onReject: endCall })), activeCall && activeCall.status === 'active' && (currentRoom && activeCall.roomId === currentRoom._id ? (_jsx(FullCallView, { call: activeCall, currentUserId: userId, isMuted: isMuted, onMute: mute, onUnmute: unmute, onEnd: endCall, callState: callState, callError: callError, remoteStream: remoteStream, peerName: selectedPeer?.name || 'Unknown', peerInitial: (selectedPeer?.name?.[0] || '?').toUpperCase(), isPeerOnline: isPeerOnline })) : (_jsx(ActiveCallModal, { call: activeCall, currentUserId: userId, isMuted: isMuted, onMute: mute, onUnmute: unmute, onEnd: endCall, callState: callState, callError: callError, remoteStream: remoteStream }))), busyBanner && (_jsxs("div", { className: "busy-banner", role: "alert", children: [_jsx("span", { className: "busy-banner-icon", children: "\u26A0\uFE0F" }), _jsx("span", { className: "busy-banner-text", children: busyBanner }), _jsx("button", { className: "busy-banner-close", onClick: clearBusyBanner, "aria-label": "Dismiss", children: "\u2715" })] }))] }));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* User Item in sidebar — click to open a chat with this user
|
|
300
|
+
*/
|
|
301
|
+
function UserListItem({ user, isActive, onClick, }) {
|
|
302
|
+
const isOnline = user.isOnline === true;
|
|
303
|
+
return (_jsxs("div", { className: `user-item ${isActive ? 'active' : ''}`, onClick: onClick, children: [_jsxs("div", { className: "user-avatar-wrapper", children: [_jsx("div", { className: "user-avatar", children: user.avatar ? (_jsx("img", { src: user.avatar, alt: user.name })) : (_jsx("span", { children: (user.name[0] || '?').toUpperCase() })) }), isOnline && _jsx("span", { className: "online-indicator online-indicator-sm", title: "Online" })] }), _jsxs("div", { className: "user-info", children: [_jsx("div", { className: "user-name", children: user.name }), _jsx("div", { className: "user-status", children: isOnline ? _jsx("span", { className: "status-online", children: "Online" }) : _jsx("span", { className: "status-offline", children: "Offline" }) })] })] }));
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Message bubble component
|
|
307
|
+
*/
|
|
308
|
+
function MessageBubble({ message }) {
|
|
309
|
+
const myId = localStorage.getItem('userId') || '';
|
|
310
|
+
const isSent = message.senderId === myId;
|
|
311
|
+
// Build a full URL for the file when needed
|
|
312
|
+
const serverUrl = import.meta.env?.VITE_SERVER_URL || 'http://localhost:3002';
|
|
313
|
+
const fullFileUrl = message.fileUrl
|
|
314
|
+
? message.fileUrl.startsWith('http')
|
|
315
|
+
? message.fileUrl
|
|
316
|
+
: `${serverUrl}${message.fileUrl}`
|
|
317
|
+
: undefined;
|
|
318
|
+
const isImage = message.fileType?.startsWith('image/') ||
|
|
319
|
+
(message.fileUrl && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(message.fileUrl));
|
|
320
|
+
return (_jsx("div", { className: `message ${isSent ? 'sent' : 'received'}`, children: _jsxs("div", { className: "message-content", children: [!isSent && _jsx("div", { className: "message-sender", children: message.senderName }), _jsxs("div", { className: "message-body", children: [fullFileUrl && (isImage ? (_jsx("a", { href: fullFileUrl, target: "_blank", rel: "noopener noreferrer", className: "image-message", children: _jsx("img", { src: fullFileUrl, alt: message.fileName || 'Image', className: "message-image" }) })) : (_jsx("div", { className: "file-message", children: _jsxs("a", { href: fullFileUrl, target: "_blank", rel: "noopener noreferrer", className: "file-link", children: [_jsx("span", { className: "file-icon", children: "\uD83D\uDCCE" }), message.fileName || 'Attachment'] }) }))), message.content && message.content !== message.fileName && (_jsx("div", { className: "message-text", children: message.content }))] }), _jsx("div", { className: "message-time", children: formatTime(message.createdAt) })] }) }));
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Incoming-call popup — appears at the top of the screen for the callee.
|
|
324
|
+
* Shown globally so it works even when the user is not viewing the chat
|
|
325
|
+
* with the caller.
|
|
326
|
+
*/
|
|
327
|
+
function IncomingCallPopup({ call, onAccept, onReject, }) {
|
|
328
|
+
return (_jsx("div", { className: "incoming-call-popup", role: "dialog", "aria-label": "Incoming call", children: _jsxs("div", { className: "incoming-call-card", children: [_jsx("div", { className: "incoming-call-icon", children: "\uD83D\uDCDE" }), _jsxs("div", { className: "incoming-call-text", children: [_jsx("div", { className: "incoming-call-title", children: "Incoming Call" }), _jsx("div", { className: "incoming-call-subtitle", children: "Tap accept to join the call" })] }), _jsxs("div", { className: "incoming-call-actions", children: [_jsx("button", { onClick: onReject, className: "btn-reject", "aria-label": "Reject", children: "End" }), _jsx("button", { onClick: onAccept, className: "btn-accept", "aria-label": "Accept", children: "Accept" })] })] }) }));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Active-call modal — appears as a small floating card during the call.
|
|
332
|
+
* Includes the audio element that plays the remote stream.
|
|
333
|
+
*/
|
|
334
|
+
function ActiveCallModal({ call, currentUserId, isMuted, onMute, onUnmute, onEnd, callState, callError, remoteStream, }) {
|
|
335
|
+
const isInitiator = call.initiatorId === currentUserId;
|
|
336
|
+
const audioElRef = React.useRef(null);
|
|
337
|
+
const playAttemptsRef = React.useRef(0);
|
|
338
|
+
React.useEffect(() => {
|
|
339
|
+
const el = audioElRef.current;
|
|
340
|
+
if (!el)
|
|
341
|
+
return;
|
|
342
|
+
if (remoteStream && el.srcObject !== remoteStream) {
|
|
343
|
+
el.srcObject = remoteStream;
|
|
344
|
+
el.muted = false;
|
|
345
|
+
el.volume = 1.0;
|
|
346
|
+
const tryPlay = () => {
|
|
347
|
+
el.play().catch((err) => {
|
|
348
|
+
console.warn('[CLIENT] Audio autoplay blocked, will retry on user gesture:', err?.message);
|
|
349
|
+
playAttemptsRef.current += 1;
|
|
350
|
+
if (playAttemptsRef.current < 10) {
|
|
351
|
+
const retry = () => {
|
|
352
|
+
el.play().then(() => {
|
|
353
|
+
document.removeEventListener('click', retry);
|
|
354
|
+
document.removeEventListener('keydown', retry);
|
|
355
|
+
document.removeEventListener('touchstart', retry);
|
|
356
|
+
}).catch(() => { });
|
|
357
|
+
};
|
|
358
|
+
document.addEventListener('click', retry, { once: true });
|
|
359
|
+
document.addEventListener('keydown', retry, { once: true });
|
|
360
|
+
document.addEventListener('touchstart', retry, { once: true });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
tryPlay();
|
|
365
|
+
}
|
|
366
|
+
else if (!remoteStream) {
|
|
367
|
+
el.srcObject = null;
|
|
368
|
+
}
|
|
369
|
+
}, [remoteStream]);
|
|
370
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "active-call-modal", role: "status", "aria-label": "In call", children: _jsxs("div", { className: "active-call-card", children: [_jsxs("div", { className: "active-call-info", children: [_jsx("div", { className: "active-call-icon", children: "\uD83D\uDCDE" }), _jsxs("div", { className: "active-call-text", children: [_jsx("div", { className: "active-call-title", children: callState === 'connected' ? 'In Call' : 'Connecting…' }), callError && _jsxs("div", { className: "active-call-error", children: ["\u26A0\uFE0F ", callError] })] })] }), _jsxs("div", { className: "active-call-actions", children: [_jsx("button", { onClick: isMuted ? onUnmute : onMute, className: isMuted ? 'btn-muted' : 'btn-mute', "aria-label": isMuted ? 'Unmute' : 'Mute', children: isMuted ? '🔇 Unmute' : '🔊 Mute' }), _jsx("button", { onClick: onEnd, className: "btn-end", "aria-label": "End call", children: "End" })] })] }) }), _jsx("audio", { ref: audioElRef, autoPlay: true, playsInline: true, style: { display: 'none' } })] }));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* In-call banner — shown inside the active chat header (replaces the call
|
|
374
|
+
* button) so the user has clear feedback that they are in a call.
|
|
375
|
+
*/
|
|
376
|
+
function InCallBanner({ call, isMuted, onMute, onUnmute, onEnd, }) {
|
|
377
|
+
return (_jsxs("div", { className: "in-call-banner", children: [_jsxs("div", { className: "in-call-banner-status", children: [_jsx("span", { className: "in-call-banner-dot" }), _jsx("span", { children: "In Call" })] }), _jsxs("div", { className: "in-call-banner-actions", children: [_jsx("button", { onClick: isMuted ? onUnmute : onMute, className: isMuted ? 'btn-muted' : 'btn-mute', "aria-label": isMuted ? 'Unmute' : 'Mute', children: isMuted ? '🔇' : '🔊' }), _jsx("button", { onClick: onEnd, className: "btn-end", "aria-label": "End call", children: "End" })] })] }));
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Full call view — WhatsApp-style: takes over the entire message area with a
|
|
381
|
+
* large centered avatar, call duration, and bottom action buttons.
|
|
382
|
+
* Rendered when the active call is in the currently-viewed room.
|
|
383
|
+
*/
|
|
384
|
+
function FullCallView({ call, currentUserId, isMuted, onMute, onUnmute, onEnd, callState, callError, remoteStream, peerName, peerInitial, isPeerOnline, }) {
|
|
385
|
+
const audioElRef = React.useRef(null);
|
|
386
|
+
const playAttemptsRef = React.useRef(0);
|
|
387
|
+
const startedAt = call.startedAt ? new Date(call.startedAt).getTime() : Date.now();
|
|
388
|
+
const [duration, setDuration] = useState('00:00');
|
|
389
|
+
const [speakerOn, setSpeakerOn] = useState(false);
|
|
390
|
+
React.useEffect(() => {
|
|
391
|
+
const tick = () => {
|
|
392
|
+
const secs = Math.floor((Date.now() - startedAt) / 1000);
|
|
393
|
+
const m = String(Math.floor(secs / 60)).padStart(2, '0');
|
|
394
|
+
const s = String(secs % 60).padStart(2, '0');
|
|
395
|
+
setDuration(`${m}:${s}`);
|
|
396
|
+
};
|
|
397
|
+
tick();
|
|
398
|
+
const t = setInterval(tick, 1000);
|
|
399
|
+
return () => clearInterval(t);
|
|
400
|
+
}, [startedAt]);
|
|
401
|
+
React.useEffect(() => {
|
|
402
|
+
const el = audioElRef.current;
|
|
403
|
+
if (!el)
|
|
404
|
+
return;
|
|
405
|
+
if (remoteStream && el.srcObject !== remoteStream) {
|
|
406
|
+
el.srcObject = remoteStream;
|
|
407
|
+
el.muted = false;
|
|
408
|
+
el.volume = 1.0;
|
|
409
|
+
const tryPlay = () => {
|
|
410
|
+
el.play().catch((err) => {
|
|
411
|
+
console.warn('[CLIENT] Audio autoplay blocked, will retry on user gesture:', err?.message);
|
|
412
|
+
playAttemptsRef.current += 1;
|
|
413
|
+
if (playAttemptsRef.current < 10) {
|
|
414
|
+
const retry = () => {
|
|
415
|
+
el.play().then(() => {
|
|
416
|
+
document.removeEventListener('click', retry);
|
|
417
|
+
document.removeEventListener('keydown', retry);
|
|
418
|
+
document.removeEventListener('touchstart', retry);
|
|
419
|
+
}).catch(() => { });
|
|
420
|
+
};
|
|
421
|
+
document.addEventListener('click', retry, { once: true });
|
|
422
|
+
document.addEventListener('keydown', retry, { once: true });
|
|
423
|
+
document.addEventListener('touchstart', retry, { once: true });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
};
|
|
427
|
+
tryPlay();
|
|
428
|
+
}
|
|
429
|
+
else if (!remoteStream) {
|
|
430
|
+
el.srcObject = null;
|
|
431
|
+
}
|
|
432
|
+
}, [remoteStream]);
|
|
433
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "full-call-view", role: "status", "aria-label": "In call", children: [_jsxs("div", { className: "full-call-stage", children: [_jsxs("div", { className: "full-call-avatar", children: [_jsx("span", { children: peerInitial }), isPeerOnline && _jsx("span", { className: "full-call-online-dot", title: "Online" })] }), _jsx("div", { className: "full-call-name", children: peerName }), _jsx("div", { className: "full-call-timer", children: callState === 'connected' ? duration : 'Connecting…' }), callError && _jsxs("div", { className: "full-call-error", children: ["\u26A0\uFE0F ", callError] })] }), _jsxs("div", { className: "full-call-controls", children: [_jsxs("button", { onClick: isMuted ? onUnmute : onMute, className: `full-call-btn ${isMuted ? 'full-call-btn-active' : ''}`, "aria-label": isMuted ? 'Unmute' : 'Mute', title: isMuted ? 'Unmute' : 'Mute', children: [_jsx("span", { className: "full-call-btn-icon", children: isMuted ? '🔇' : '🔊' }), _jsx("span", { className: "full-call-btn-label", children: isMuted ? 'Unmute' : 'Speaker' })] }), _jsxs("button", { onClick: () => setSpeakerOn((v) => !v), className: `full-call-btn ${speakerOn ? 'full-call-btn-active' : ''}`, "aria-label": "Toggle speaker", title: "Toggle speaker", children: [_jsx("span", { className: "full-call-btn-icon", children: "\uD83D\uDCE2" }), _jsx("span", { className: "full-call-btn-label", children: "Audio" })] }), _jsxs("button", { onClick: onEnd, className: "full-call-btn full-call-btn-end", "aria-label": "End call", title: "End call", children: [_jsx("span", { className: "full-call-btn-icon", children: "\uD83D\uDCF5" }), _jsx("span", { className: "full-call-btn-label", children: "End" })] })] })] }), _jsx("audio", { ref: audioElRef, autoPlay: true, playsInline: true, style: { display: 'none' } })] }));
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Format timestamp to relative time
|
|
437
|
+
*/
|
|
438
|
+
function formatTime(date) {
|
|
439
|
+
const d = new Date(date);
|
|
440
|
+
const now = new Date();
|
|
441
|
+
const diff = now.getTime() - d.getTime();
|
|
442
|
+
const minutes = Math.floor(diff / 60000);
|
|
443
|
+
const hours = Math.floor(diff / 3600000);
|
|
444
|
+
if (minutes < 1)
|
|
445
|
+
return 'now';
|
|
446
|
+
if (minutes < 60)
|
|
447
|
+
return `${minutes}m`;
|
|
448
|
+
if (hours < 24)
|
|
449
|
+
return `${hours}h`;
|
|
450
|
+
return d.toLocaleDateString();
|
|
451
|
+
}
|
|
452
|
+
export default Chat;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react';
|
|
2
|
+
import { Message, Room, PresenceStatus, AudioCall, ChatConfig } from '@enfin/chat-shared';
|
|
3
|
+
import { WebRTCManagerState } from '../call/WebRTCManager';
|
|
4
|
+
export interface ChatUser {
|
|
5
|
+
userId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
avatar?: string;
|
|
8
|
+
isOnline?: boolean;
|
|
9
|
+
status?: 'online' | 'offline' | 'away';
|
|
10
|
+
lastSeen?: Date | string;
|
|
11
|
+
}
|
|
12
|
+
interface ChatContextValue {
|
|
13
|
+
userId: string;
|
|
14
|
+
userName: string;
|
|
15
|
+
messages: Message[];
|
|
16
|
+
rooms: Room[];
|
|
17
|
+
users: ChatUser[];
|
|
18
|
+
currentRoom?: Room;
|
|
19
|
+
activeCall?: AudioCall;
|
|
20
|
+
typingUsers: PresenceStatus[];
|
|
21
|
+
connected: boolean;
|
|
22
|
+
loading: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
sendMessage: (roomId: string, content: string, fileMetadata?: {
|
|
25
|
+
fileUrl?: string;
|
|
26
|
+
fileType?: string;
|
|
27
|
+
fileName?: string;
|
|
28
|
+
}) => Promise<void>;
|
|
29
|
+
selectRoom: (roomId: string) => void;
|
|
30
|
+
createRoom: (name: string, type: 'direct' | 'group', members: string[]) => Promise<Room>;
|
|
31
|
+
startCall: (roomId: string, participantId: string) => Promise<void>;
|
|
32
|
+
endCall: () => Promise<void>;
|
|
33
|
+
acceptCall: () => Promise<void>;
|
|
34
|
+
rejectCall: () => Promise<void>;
|
|
35
|
+
mute: () => void;
|
|
36
|
+
unmute: () => void;
|
|
37
|
+
isMuted: boolean;
|
|
38
|
+
socket?: any;
|
|
39
|
+
refreshUsers: () => Promise<void>;
|
|
40
|
+
callState: WebRTCManagerState;
|
|
41
|
+
callError?: string;
|
|
42
|
+
remoteStream: MediaStream | null;
|
|
43
|
+
busyBanner?: string;
|
|
44
|
+
clearBusyBanner: () => void;
|
|
45
|
+
emitTyping: (roomId: string, typing: boolean) => void;
|
|
46
|
+
debouncedEmitTyping: (roomId: string, typing: boolean) => void;
|
|
47
|
+
stopTypingSoon: (roomId: string) => void;
|
|
48
|
+
loadRoomMessages: (roomId: string) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export interface ChatProviderProps {
|
|
51
|
+
config: ChatConfig;
|
|
52
|
+
userId: string;
|
|
53
|
+
userName: string;
|
|
54
|
+
serverUrl: string;
|
|
55
|
+
children: ReactNode;
|
|
56
|
+
}
|
|
57
|
+
export declare function ChatProvider({ config, userId, userName, serverUrl, children }: ChatProviderProps): React.JSX.Element;
|
|
58
|
+
export declare function useChatContext(): ChatContextValue;
|
|
59
|
+
export {};
|