@xcelsior/ui-chat 1.0.1

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.
@@ -0,0 +1,363 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Button, TextArea } from '@xcelsior/design-system';
4
+ import Picker from '@emoji-mart/react';
5
+ import type { IChatConfig } from '../types';
6
+ import type { UseFileUploadReturn } from '../hooks/useFileUpload';
7
+
8
+ interface ChatInputProps {
9
+ onSend: (message: string) => void;
10
+ onTyping?: (isTyping: boolean) => void;
11
+ config: IChatConfig;
12
+ fileUpload: UseFileUploadReturn;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export function ChatInput({
17
+ onSend,
18
+ onTyping,
19
+ config,
20
+ fileUpload,
21
+ disabled = false,
22
+ }: ChatInputProps) {
23
+ const [message, setMessage] = useState('');
24
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
25
+ const [emojiData, setEmojiData] = useState<any>();
26
+ const [emojiPickerPosition, setEmojiPickerPosition] = useState<{
27
+ top: number;
28
+ left: number;
29
+ } | null>(null);
30
+ const textAreaRef = useRef<HTMLTextAreaElement>(null);
31
+ const emojiPickerRef = useRef<HTMLDivElement>(null);
32
+ const emojiButtonRef = useRef<HTMLButtonElement>(null);
33
+ const fileInputRef = useRef<HTMLInputElement>(null);
34
+ const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
35
+ const startTypingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
36
+ const isTypingRef = useRef<boolean>(false);
37
+
38
+ const enableEmoji = config.enableEmoji ?? true;
39
+ const enableFileUpload = config.enableFileUpload ?? true;
40
+
41
+ // Load emoji data
42
+ useEffect(() => {
43
+ if (!enableEmoji) return;
44
+ (async () => {
45
+ try {
46
+ const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data');
47
+ setEmojiData(await response.json());
48
+ } catch (error) {
49
+ console.error('Failed to load emoji data:', error);
50
+ }
51
+ })();
52
+ }, [enableEmoji]);
53
+
54
+ // Handle click outside emoji picker
55
+ useEffect(() => {
56
+ const handleClickOutside = (event: MouseEvent) => {
57
+ if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
58
+ setShowEmojiPicker(false);
59
+ }
60
+ };
61
+
62
+ if (showEmojiPicker) {
63
+ document.addEventListener('mousedown', handleClickOutside);
64
+ }
65
+
66
+ return () => {
67
+ document.removeEventListener('mousedown', handleClickOutside);
68
+ };
69
+ }, [showEmojiPicker]);
70
+
71
+ // Cleanup typing timeouts on unmount
72
+ useEffect(() => {
73
+ return () => {
74
+ if (typingTimeoutRef.current) {
75
+ clearTimeout(typingTimeoutRef.current);
76
+ }
77
+ if (startTypingTimeoutRef.current) {
78
+ clearTimeout(startTypingTimeoutRef.current);
79
+ }
80
+ };
81
+ }, []);
82
+
83
+ // Update emoji picker position on window resize/scroll
84
+ useEffect(() => {
85
+ if (!showEmojiPicker) return;
86
+
87
+ const updatePosition = () => {
88
+ if (emojiButtonRef.current) {
89
+ const rect = emojiButtonRef.current.getBoundingClientRect();
90
+ setEmojiPickerPosition({
91
+ top: rect.top - 370,
92
+ left: rect.left - 300,
93
+ });
94
+ }
95
+ };
96
+
97
+ window.addEventListener('resize', updatePosition);
98
+ window.addEventListener('scroll', updatePosition, true);
99
+
100
+ return () => {
101
+ window.removeEventListener('resize', updatePosition);
102
+ window.removeEventListener('scroll', updatePosition, true);
103
+ };
104
+ }, [showEmojiPicker]);
105
+
106
+ const handleTyping = (value: string) => {
107
+ setMessage(value);
108
+
109
+ // Send typing indicator with debouncing
110
+ if (onTyping && config.enableTypingIndicator !== false) {
111
+ // Clear any pending "start typing" timeout
112
+ if (startTypingTimeoutRef.current) {
113
+ clearTimeout(startTypingTimeoutRef.current);
114
+ }
115
+
116
+ // Clear any pending "stop typing" timeout
117
+ if (typingTimeoutRef.current) {
118
+ clearTimeout(typingTimeoutRef.current);
119
+ }
120
+
121
+ // If not already typing, debounce the start typing event
122
+ if (!isTypingRef.current) {
123
+ startTypingTimeoutRef.current = setTimeout(() => {
124
+ onTyping(true);
125
+ isTypingRef.current = true;
126
+ }, 300); // 300ms debounce for start typing
127
+ }
128
+
129
+ // Set timeout to stop typing indicator after inactivity
130
+ typingTimeoutRef.current = setTimeout(() => {
131
+ if (isTypingRef.current) {
132
+ onTyping(false);
133
+ isTypingRef.current = false;
134
+ }
135
+ }, 1500); // 1.5s of inactivity to stop typing
136
+ }
137
+ };
138
+
139
+ const handleSend = () => {
140
+ const trimmedMessage = message.trim();
141
+ if (!trimmedMessage || disabled) return;
142
+
143
+ onSend(trimmedMessage);
144
+ setMessage('');
145
+ setShowEmojiPicker(false);
146
+
147
+ // Stop typing indicator
148
+ if (onTyping) {
149
+ // Clear all typing-related timeouts
150
+ if (typingTimeoutRef.current) {
151
+ clearTimeout(typingTimeoutRef.current);
152
+ }
153
+ if (startTypingTimeoutRef.current) {
154
+ clearTimeout(startTypingTimeoutRef.current);
155
+ }
156
+ // Send stop typing event if currently typing
157
+ if (isTypingRef.current) {
158
+ onTyping(false);
159
+ isTypingRef.current = false;
160
+ }
161
+ }
162
+
163
+ // Focus back on textarea
164
+ textAreaRef.current?.focus();
165
+ };
166
+
167
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
168
+ if (e.key === 'Enter' && !e.shiftKey) {
169
+ e.preventDefault();
170
+ handleSend();
171
+ }
172
+ };
173
+
174
+ const insertAtCursor = (text: string) => {
175
+ const textarea = textAreaRef.current;
176
+ if (!textarea) {
177
+ setMessage((prev) => prev + text);
178
+ return;
179
+ }
180
+
181
+ const start = textarea.selectionStart;
182
+ const end = textarea.selectionEnd;
183
+ const newValue = message.slice(0, start) + text + message.slice(end);
184
+ setMessage(newValue);
185
+
186
+ // Set cursor position after inserted text
187
+ setTimeout(() => {
188
+ const newCursorPos = start + text.length;
189
+ textarea.setSelectionRange(newCursorPos, newCursorPos);
190
+ textarea.focus();
191
+ }, 0);
192
+ };
193
+
194
+ const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
195
+ const files = event.target.files;
196
+ if (!files || files.length === 0) return;
197
+
198
+ const file = files[0];
199
+ try {
200
+ config.toast?.info('Uploading file...');
201
+ const uploadedFile = await fileUpload.uploadFile(file);
202
+ if (uploadedFile?.markdown) {
203
+ insertAtCursor(`\n${uploadedFile.markdown}\n`);
204
+ config.toast?.success('File uploaded successfully');
205
+ }
206
+ } catch (error: any) {
207
+ console.error('File upload failed:', error);
208
+ config.toast?.error(error.message);
209
+ } finally {
210
+ // Clear the file input
211
+ if (fileInputRef.current) {
212
+ fileInputRef.current.value = '';
213
+ }
214
+ }
215
+ };
216
+
217
+ return (
218
+ <div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3">
219
+ <div className="relative flex-1">
220
+ <div className="relative">
221
+ <TextArea
222
+ ref={textAreaRef}
223
+ value={message}
224
+ onChange={(e) => handleTyping(e.target.value)}
225
+ onKeyDown={handleKeyDown}
226
+ placeholder="Type a message..."
227
+ rows={1}
228
+ className="resize-none pr-24 pl-4 py-3 rounded-full bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm leading-5 placeholder-gray-500 dark:placeholder-gray-400"
229
+ disabled={disabled}
230
+ />
231
+
232
+ {/* Actions inside the input on the right */}
233
+ <div className="absolute right-12 top-1/2 -translate-y-1/2 flex items-center gap-1">
234
+ {enableEmoji && (
235
+ <div className="relative">
236
+ <button
237
+ ref={emojiButtonRef}
238
+ type="button"
239
+ onClick={() => {
240
+ if (!showEmojiPicker && emojiButtonRef.current) {
241
+ const rect =
242
+ emojiButtonRef.current.getBoundingClientRect();
243
+ setEmojiPickerPosition({
244
+ top: rect.top - 450,
245
+ left: rect.left - 290,
246
+ });
247
+ }
248
+ setShowEmojiPicker((v) => !v);
249
+ }}
250
+ className="p-1.5 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
251
+ disabled={disabled}
252
+ aria-label="Add emoji"
253
+ >
254
+ <span className="text-lg">😊</span>
255
+ </button>
256
+ </div>
257
+ )}
258
+
259
+ {enableFileUpload && fileUpload.canUpload && (
260
+ <>
261
+ <button
262
+ type="button"
263
+ onClick={() => fileInputRef.current?.click()}
264
+ className="p-1.5 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
265
+ disabled={disabled || fileUpload.isUploading}
266
+ aria-label="Attach file"
267
+ >
268
+ {fileUpload.isUploading ? (
269
+ <span className="text-lg animate-spin">⏳</span>
270
+ ) : (
271
+ <span className="text-lg">📎</span>
272
+ )}
273
+ </button>
274
+ <input
275
+ ref={fileInputRef}
276
+ type="file"
277
+ accept="image/*,application/pdf,.doc,.docx"
278
+ className="hidden"
279
+ onChange={handleFileSelect}
280
+ />
281
+ </>
282
+ )}
283
+ </div>
284
+
285
+ {/* Send button positioned inside the input at far right */}
286
+ <div className="absolute right-2 top-1/2 -translate-y-1/2">
287
+ <Button
288
+ onClick={handleSend}
289
+ disabled={!message.trim() || disabled}
290
+ variant="primary"
291
+ size="sm"
292
+ className="h-9 w-9 p-0 rounded-full flex items-center justify-center shadow-sm"
293
+ >
294
+ <span className="flex items-center justify-center">
295
+ <svg
296
+ className="w-4 h-4 rotate-90"
297
+ fill="none"
298
+ viewBox="0 0 24 24"
299
+ stroke="currentColor"
300
+ aria-hidden="true"
301
+ >
302
+ <title>Send icon</title>
303
+ <path
304
+ strokeLinecap="round"
305
+ strokeLinejoin="round"
306
+ strokeWidth={2}
307
+ d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
308
+ />
309
+ </svg>
310
+ </span>
311
+ </Button>
312
+ </div>
313
+ </div>
314
+ </div>
315
+
316
+ {fileUpload.isUploading && (
317
+ <div className="mt-2">
318
+ <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
319
+ <div
320
+ className="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
321
+ style={{ width: `${fileUpload.uploadProgress}%` }}
322
+ />
323
+ </div>
324
+ <p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
325
+ Uploading... {fileUpload.uploadProgress}%
326
+ </p>
327
+ </div>
328
+ )}
329
+
330
+ {/* Emoji picker portal */}
331
+ {showEmojiPicker &&
332
+ emojiData &&
333
+ emojiPickerPosition &&
334
+ typeof document !== 'undefined' &&
335
+ createPortal(
336
+ <div
337
+ ref={emojiPickerRef}
338
+ className="fixed rounded-lg border bg-white dark:bg-gray-800 dark:border-gray-700 shadow-xl"
339
+ style={{
340
+ top: `${emojiPickerPosition.top}px`,
341
+ left: `${emojiPickerPosition.left}px`,
342
+ zIndex: 9999,
343
+ }}
344
+ >
345
+ <Picker
346
+ data={emojiData}
347
+ onEmojiSelect={(emoji: any) => {
348
+ insertAtCursor(emoji.native || emoji.shortcodes || '');
349
+ setShowEmojiPicker(false);
350
+ }}
351
+ previewPosition="none"
352
+ skinTonePosition="none"
353
+ navPosition="bottom"
354
+ perLine={8}
355
+ searchPosition="sticky"
356
+ theme="auto"
357
+ />
358
+ </div>,
359
+ document.body
360
+ )}
361
+ </div>
362
+ );
363
+ }
@@ -0,0 +1,234 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { useWebSocket } from '../hooks/useWebSocket';
3
+ import { useMessages } from '../hooks/useMessages';
4
+ import { useFileUpload } from '../hooks/useFileUpload';
5
+ import { useTypingIndicator } from '../hooks/useTypingIndicator';
6
+ import { ChatHeader } from './ChatHeader';
7
+ import { MessageList } from './MessageList';
8
+ import { ChatInput } from './ChatInput';
9
+ import type { IChatConfig, IMessage } from '../types';
10
+
11
+ export interface ChatWidgetProps {
12
+ config: IChatConfig;
13
+ className?: string;
14
+ /**
15
+ * Variant of the chat widget:
16
+ * - 'popover': Fixed positioned floating widget (default)
17
+ * - 'fullPage': Full page layout that fills the container
18
+ */
19
+ variant?: 'popover' | 'fullPage';
20
+ /**
21
+ * External WebSocket connection (for agents with global connection)
22
+ */
23
+ externalWebSocket?: WebSocket | null;
24
+ }
25
+
26
+ export function ChatWidget({
27
+ config,
28
+ className = '',
29
+ variant = 'popover',
30
+ externalWebSocket,
31
+ }: ChatWidgetProps) {
32
+ const [isMinimized, setIsMinimized] = useState(false);
33
+ const [isClosed, setIsClosed] = useState(false);
34
+
35
+ const isFullPage = variant === 'fullPage';
36
+
37
+ // Initialize WebSocket connection (or use external one)
38
+ const websocket = useWebSocket(config, externalWebSocket);
39
+
40
+ // Initialize messages
41
+ const { messages, addMessage, isLoading, loadMore, hasMore, isLoadingMore } = useMessages(
42
+ websocket,
43
+ config
44
+ );
45
+
46
+ // Initialize file upload
47
+ const fileUpload = useFileUpload(config.apiKey, config.fileUpload);
48
+
49
+ // Initialize typing indicator
50
+ const { isTyping, typingUsers } = useTypingIndicator(websocket);
51
+
52
+ // Handle sending messages
53
+ const handleSendMessage = useCallback(
54
+ (content: string) => {
55
+ if (!websocket.isConnected) {
56
+ config.toast?.error('Not connected to chat server');
57
+ return;
58
+ }
59
+
60
+ // Create optimistic message
61
+ const optimisticMessage: IMessage = {
62
+ id: `temp-${Date.now()}`,
63
+ conversationId: config.conversationId || '',
64
+ senderId: config.currentUser.email,
65
+ senderType: config.currentUser.type,
66
+ content,
67
+ messageType: 'text',
68
+ createdAt: new Date().toISOString(),
69
+ status: 'sent',
70
+ };
71
+
72
+ // Add to local state immediately (optimistic update)
73
+ addMessage(optimisticMessage);
74
+
75
+ // Send via WebSocket
76
+ websocket.sendMessage('sendMessage', {
77
+ conversationId: config.conversationId,
78
+ content,
79
+ messageType: 'text',
80
+ });
81
+
82
+ // Call callback
83
+ config.onMessageSent?.(optimisticMessage);
84
+ },
85
+ [websocket, config, addMessage]
86
+ );
87
+
88
+ // Handle typing indicator
89
+ const handleTyping = useCallback(
90
+ (isTyping: boolean) => {
91
+ if (!websocket.isConnected || config.enableTypingIndicator === false) {
92
+ return;
93
+ }
94
+
95
+ websocket.sendMessage('typing', {
96
+ conversationId: config.conversationId,
97
+ isTyping,
98
+ });
99
+ },
100
+ [websocket, config]
101
+ );
102
+
103
+ // Handle errors
104
+ useEffect(() => {
105
+ if (websocket.error) {
106
+ config.toast?.error(websocket.error.message || 'An error occurred');
107
+ }
108
+ }, [websocket.error, config]);
109
+
110
+ // For fullPage variant, ignore minimize/close state
111
+ if (!isFullPage) {
112
+ if (isClosed) {
113
+ return null;
114
+ }
115
+
116
+ // Minimized view (floating button) - only for popover
117
+ if (isMinimized) {
118
+ return (
119
+ <div className={`fixed bottom-4 right-4 z-50 ${className}`}>
120
+ <button
121
+ type="button"
122
+ onClick={() => setIsMinimized(false)}
123
+ className="h-14 w-14 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg hover:shadow-xl transition-all flex items-center justify-center relative"
124
+ aria-label="Open chat"
125
+ >
126
+ <span className="text-2xl">💬</span>
127
+ {messages.some(
128
+ (msg) =>
129
+ msg.senderId !== config.currentUser.email && msg.status !== 'read'
130
+ ) && (
131
+ <span className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center">
132
+ !
133
+ </span>
134
+ )}
135
+ </button>
136
+ </div>
137
+ );
138
+ }
139
+ }
140
+
141
+ // Container styles based on variant
142
+ const containerClasses = isFullPage
143
+ ? `flex flex-col bg-white dark:bg-gray-900 h-full ${className}`
144
+ : `fixed bottom-4 right-4 z-50 flex flex-col bg-white dark:bg-gray-900 rounded-lg shadow-2xl overflow-hidden ${className}`;
145
+
146
+ const containerStyle = isFullPage
147
+ ? undefined
148
+ : {
149
+ width: '400px',
150
+ height: '600px',
151
+ maxHeight: 'calc(100vh - 2rem)',
152
+ };
153
+
154
+ return (
155
+ <div className={containerClasses} style={containerStyle}>
156
+ {!isFullPage && (
157
+ <ChatHeader
158
+ agent={
159
+ config.currentUser.type === 'customer'
160
+ ? {
161
+ email: 'contact@xcelsior.co',
162
+ name: 'Support Agent',
163
+ type: 'agent',
164
+ status: websocket.isConnected ? 'online' : 'offline',
165
+ }
166
+ : undefined
167
+ }
168
+ onMinimize={() => setIsMinimized(true)}
169
+ onClose={() => setIsClosed(true)}
170
+ />
171
+ )}
172
+
173
+ {/* Connection Status */}
174
+ {!websocket.isConnected && (
175
+ <div className="bg-yellow-50 dark:bg-yellow-900/30 border-b border-yellow-200 dark:border-yellow-800 px-4 py-2">
176
+ <div className="flex items-center gap-2">
177
+ <div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
178
+ <span className="text-sm text-yellow-800 dark:text-yellow-200">
179
+ Reconnecting...
180
+ </span>
181
+ <button
182
+ type="button"
183
+ onClick={websocket.reconnect}
184
+ className="ml-auto text-xs text-yellow-700 dark:text-yellow-300 hover:underline"
185
+ >
186
+ Retry
187
+ </button>
188
+ </div>
189
+ </div>
190
+ )}
191
+
192
+ {/* Messages */}
193
+ {isLoading ? (
194
+ <div className="flex-1 flex items-center justify-center">
195
+ <div className="text-center">
196
+ <div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
197
+ <p className="text-sm text-gray-600 dark:text-gray-400">
198
+ Loading messages...
199
+ </p>
200
+ </div>
201
+ </div>
202
+ ) : (
203
+ <MessageList
204
+ messages={messages}
205
+ currentUser={config.currentUser}
206
+ isTyping={isTyping}
207
+ typingUser={typingUsers[0]}
208
+ autoScroll={true}
209
+ onLoadMore={loadMore}
210
+ hasMore={hasMore}
211
+ isLoadingMore={isLoadingMore}
212
+ />
213
+ )}
214
+
215
+ {/* Input */}
216
+ <ChatInput
217
+ onSend={handleSendMessage}
218
+ onTyping={handleTyping}
219
+ config={config}
220
+ fileUpload={fileUpload}
221
+ disabled={!websocket.isConnected}
222
+ />
223
+
224
+ {/* Powered by footer - only for popover */}
225
+ {!isFullPage && (
226
+ <div className="bg-gray-50 dark:bg-gray-950 px-4 py-2 text-center border-t border-gray-200 dark:border-gray-700">
227
+ <p className="text-xs text-gray-500 dark:text-gray-400">
228
+ Powered by <span className="font-semibold">Xcelsior Chat</span>
229
+ </p>
230
+ </div>
231
+ )}
232
+ </div>
233
+ );
234
+ }