@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,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,3 @@
1
+ export { Chat } from './Chat';
2
+ export type { ChatProps } from './Chat';
3
+ export { default } from './Chat';
@@ -0,0 +1,2 @@
1
+ export { Chat } from './Chat';
2
+ export { default } from './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 {};