@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.
- package/.storybook/main.ts +27 -0
- package/.storybook/preview.tsx +39 -0
- package/CHANGELOG.md +32 -0
- package/README.md +526 -0
- package/biome.json +3 -0
- package/package.json +61 -0
- package/postcss.config.js +5 -0
- package/src/components/Chat.stories.tsx +54 -0
- package/src/components/Chat.tsx +194 -0
- package/src/components/ChatHeader.tsx +93 -0
- package/src/components/ChatInput.tsx +363 -0
- package/src/components/ChatWidget.tsx +234 -0
- package/src/components/MessageItem.stories.tsx +232 -0
- package/src/components/MessageItem.tsx +143 -0
- package/src/components/MessageList.tsx +189 -0
- package/src/components/PreChatForm.tsx +202 -0
- package/src/components/TypingIndicator.tsx +31 -0
- package/src/hooks/useFileUpload.ts +134 -0
- package/src/hooks/useMessages.ts +165 -0
- package/src/hooks/useTypingIndicator.ts +33 -0
- package/src/hooks/useWebSocket.ts +209 -0
- package/src/index.tsx +46 -0
- package/src/types.ts +145 -0
- package/src/utils/api.ts +43 -0
- package/tsconfig.json +5 -0
- package/tsup.config.ts +12 -0
|
@@ -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
|
+
}
|