@aslaluroba/help-center-react 3.0.21 → 3.2.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/dist/components/ui/image-attachment.d.ts +9 -0
- package/dist/components/ui/image-preview-dialog.d.ts +10 -0
- package/dist/components/ui/index.d.ts +4 -0
- package/dist/core/api.d.ts +3 -1
- package/dist/index.css +1 -1
- package/dist/index.esm.js +5332 -843
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +5332 -843
- package/dist/index.js.map +1 -1
- package/dist/lib/types.d.ts +23 -0
- package/dist/services.esm.js +492 -361
- package/dist/services.esm.js.map +1 -1
- package/dist/services.js +492 -361
- package/dist/services.js.map +1 -1
- package/dist/ui/chatbot-popup/chat-window-screen/footer.d.ts +3 -2
- package/dist/ui/chatbot-popup/chat-window-screen/index.d.ts +3 -2
- package/dist/ui/help-center.d.ts +1 -1
- package/dist/ui/help-popup.d.ts +3 -2
- package/package.json +1 -1
- package/src/.DS_Store +0 -0
- package/src/assets/icons/paperclip.svg +3 -0
- package/src/assets/icons/x.svg +4 -0
- package/src/components/ui/image-attachment.tsx +107 -0
- package/src/components/ui/image-preview-dialog.tsx +354 -0
- package/src/components/ui/index.ts +4 -0
- package/src/core/AblyService.ts +6 -3
- package/src/core/ApiService.ts +0 -2
- package/src/core/api.ts +106 -58
- package/src/lib/types.ts +110 -84
- package/src/ui/chatbot-popup/chat-window-screen/footer.tsx +259 -22
- package/src/ui/chatbot-popup/chat-window-screen/index.tsx +206 -101
- package/src/ui/help-center.tsx +88 -92
- package/src/ui/help-popup.tsx +11 -9
- package/src/ui/powered-by.tsx +10 -9
|
@@ -1,37 +1,274 @@
|
|
|
1
|
+
import React, { useRef, useState, useCallback } from 'react';
|
|
2
|
+
import axios from 'axios';
|
|
1
3
|
import { Button } from '@/components';
|
|
4
|
+
import { ImagePreviewDialog } from '@/components/ui';
|
|
2
5
|
import EnvelopeIcon from './../../../assets/icons/envelope.svg';
|
|
6
|
+
import PaperclipIcon from './../../../assets/icons/paperclip.svg';
|
|
7
|
+
import XIcon from './../../../assets/icons/x.svg';
|
|
3
8
|
import { useLocalTranslation } from '../../../useLocalTranslation';
|
|
9
|
+
import { presignUpload } from '@/core/api';
|
|
10
|
+
|
|
11
|
+
interface SelectedFileDto {
|
|
12
|
+
file: File;
|
|
13
|
+
previewUrl: string;
|
|
14
|
+
uploading: boolean;
|
|
15
|
+
uploadedId: string | null;
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
4
18
|
|
|
5
19
|
interface ChatWindowFooterProps {
|
|
6
20
|
inputMessage: string;
|
|
7
21
|
setInputMessage: (e: string) => void;
|
|
8
|
-
|
|
9
|
-
handleSendMessage: () => void;
|
|
22
|
+
handleSendMessage: (attachmentIds: string[]) => void;
|
|
10
23
|
isLoading: boolean;
|
|
24
|
+
onEnsureSession: () => Promise<string>;
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
|
|
14
|
-
const { t, dir } = useLocalTranslation();
|
|
15
|
-
|
|
28
|
+
const { t, dir, i18n } = useLocalTranslation();
|
|
29
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const [selectedFiles, setSelectedFiles] = useState<SelectedFileDto[]>([]);
|
|
31
|
+
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
const handleAttachClick = useCallback(() => {
|
|
34
|
+
fileInputRef.current?.click();
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
38
|
+
const files = Array.from(e.target.files || []);
|
|
39
|
+
|
|
40
|
+
// Validate that all files are images
|
|
41
|
+
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
|
42
|
+
|
|
43
|
+
// Only image files are allowed
|
|
44
|
+
|
|
45
|
+
// Create preview URLs and add to selected files
|
|
46
|
+
const newFiles: SelectedFileDto[] = imageFiles.map((file) => ({
|
|
47
|
+
file,
|
|
48
|
+
previewUrl: URL.createObjectURL(file),
|
|
49
|
+
uploading: false,
|
|
50
|
+
uploadedId: null,
|
|
51
|
+
error: null,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
setSelectedFiles((prev) => [...prev, ...newFiles]);
|
|
55
|
+
|
|
56
|
+
// Clear the input
|
|
57
|
+
if (fileInputRef.current) {
|
|
58
|
+
fileInputRef.current.value = '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Start uploading immediately
|
|
62
|
+
await handleUploadFiles(newFiles);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const handleUploadFiles = useCallback(
|
|
66
|
+
async (filesToUpload: SelectedFileDto[]) => {
|
|
67
|
+
// Get session ID
|
|
68
|
+
let sessionId: string;
|
|
69
|
+
try {
|
|
70
|
+
sessionId = await props.onEnsureSession();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Mark all files as error
|
|
73
|
+
setSelectedFiles((prev) =>
|
|
74
|
+
prev.map((f) =>
|
|
75
|
+
filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
|
|
76
|
+
? { ...f, error: 'Failed to initialize session', uploading: false }
|
|
77
|
+
: f
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Upload each file
|
|
84
|
+
for (const fileDto of filesToUpload) {
|
|
85
|
+
try {
|
|
86
|
+
// Mark as uploading
|
|
87
|
+
setSelectedFiles((prev) =>
|
|
88
|
+
prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Get presigned URL
|
|
92
|
+
const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
|
|
93
|
+
|
|
94
|
+
// Upload file to presigned URL using axios
|
|
95
|
+
// Important: Content-Type must match the file type (e.g., 'image/png'), not 'multipart/form-data'
|
|
96
|
+
const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': fileDto.file.type,
|
|
99
|
+
},
|
|
100
|
+
onUploadProgress: () => {
|
|
101
|
+
// Upload progress tracking (silent)
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
|
|
106
|
+
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Update with uploaded ID
|
|
110
|
+
setSelectedFiles((prev) =>
|
|
111
|
+
prev.map((f) =>
|
|
112
|
+
f.previewUrl === fileDto.previewUrl
|
|
113
|
+
? { ...f, uploading: false, uploadedId: presignResponse.id, error: null }
|
|
114
|
+
: f
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
setSelectedFiles((prev) =>
|
|
119
|
+
prev.map((f) =>
|
|
120
|
+
f.previewUrl === fileDto.previewUrl
|
|
121
|
+
? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
|
|
122
|
+
: f
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[props.onEnsureSession, i18n.language]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const handleRemoveFile = useCallback((previewUrl: string) => {
|
|
132
|
+
setSelectedFiles((prev) => {
|
|
133
|
+
const fileToRemove = prev.find((f) => f.previewUrl === previewUrl);
|
|
134
|
+
if (fileToRemove) {
|
|
135
|
+
URL.revokeObjectURL(fileToRemove.previewUrl);
|
|
136
|
+
}
|
|
137
|
+
return prev.filter((f) => f.previewUrl !== previewUrl);
|
|
138
|
+
});
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const handleSendMessageWithAttachments = useCallback(() => {
|
|
142
|
+
// Only allow sending if all files have finished uploading (either successfully or with error)
|
|
143
|
+
const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
|
|
144
|
+
if (hasUploadingFiles) {
|
|
145
|
+
return; // Prevent sending if any files are still uploading
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Get all successfully uploaded file IDs
|
|
149
|
+
const attachmentIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
|
|
150
|
+
|
|
151
|
+
// Call the original send message with attachment IDs
|
|
152
|
+
props.handleSendMessage(attachmentIds);
|
|
153
|
+
|
|
154
|
+
// Clear selected files and revoke URLs
|
|
155
|
+
selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
|
|
156
|
+
setSelectedFiles([]);
|
|
157
|
+
}, [selectedFiles, props]);
|
|
158
|
+
|
|
159
|
+
// Check if any files are currently uploading
|
|
160
|
+
const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
|
|
161
|
+
|
|
162
|
+
// Check if there are files that haven't finished (no uploadedId, no error, not uploading)
|
|
163
|
+
// This shouldn't happen in normal flow, but we check for safety
|
|
164
|
+
const hasPendingFiles = selectedFiles.some((f) => !f.uploading && f.uploadedId === null && f.error === null);
|
|
165
|
+
|
|
166
|
+
// Check if all files have errors (no successful uploads)
|
|
167
|
+
const hasSuccessfulUploads = selectedFiles.some((f) => f.uploadedId !== null);
|
|
168
|
+
const allFilesHaveErrors =
|
|
169
|
+
selectedFiles.length > 0 && !hasSuccessfulUploads && !hasUploadingFiles && !hasPendingFiles;
|
|
170
|
+
|
|
171
|
+
const isSendDisabled =
|
|
172
|
+
props.isLoading || props.inputMessage.trim() === '' || hasUploadingFiles || hasPendingFiles || allFilesHaveErrors;
|
|
173
|
+
|
|
174
|
+
const handleKeyDown = useCallback(
|
|
175
|
+
(e: React.KeyboardEvent) => {
|
|
176
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
if (!isSendDisabled) {
|
|
179
|
+
handleSendMessageWithAttachments();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[isSendDisabled, handleSendMessageWithAttachments]
|
|
184
|
+
);
|
|
185
|
+
|
|
16
186
|
return (
|
|
17
|
-
<footer className='babylai-flex babylai-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
187
|
+
<footer className='babylai-flex babylai-flex-col babylai-gap-2 babylai-mx-4 md:babylai-mx-6'>
|
|
188
|
+
{selectedFiles.length > 0 && (
|
|
189
|
+
<div className='babylai-flex babylai-gap-2 babylai-flex-wrap babylai-p-2 babylai-bg-white dark:!babylai-bg-storm-dust-900 babylai-rounded-lg'>
|
|
190
|
+
{selectedFiles.map((file) => (
|
|
191
|
+
<div key={file.previewUrl} className='babylai-relative babylai-group'>
|
|
192
|
+
<img
|
|
193
|
+
src={file.previewUrl}
|
|
194
|
+
alt='Preview'
|
|
195
|
+
className='babylai-w-16 babylai-h-16 babylai-object-cover babylai-rounded-lg babylai-border babylai-border-black-white-200 babylai-cursor-pointer hover:babylai-opacity-80 babylai-transition-opacity'
|
|
196
|
+
onClick={() => setPreviewImage(file.previewUrl)}
|
|
197
|
+
role='button'
|
|
198
|
+
aria-label='Click to preview image'
|
|
199
|
+
/>
|
|
200
|
+
{file.uploading && (
|
|
201
|
+
<div className='babylai-absolute babylai-inset-0 babylai-flex babylai-items-center babylai-justify-center babylai-bg-black babylai-bg-opacity-50 babylai-rounded-lg'>
|
|
202
|
+
<div className='babylai-animate-spin babylai-rounded-full babylai-h-6 babylai-w-6 babylai-border-2 babylai-border-white babylai-border-t-transparent'></div>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
{file.error && (
|
|
206
|
+
<div className='babylai-absolute babylai-inset-0 babylai-flex babylai-items-center babylai-justify-center babylai-bg-red-500 babylai-bg-opacity-70 babylai-rounded-lg'>
|
|
207
|
+
<span className='babylai-text-white babylai-text-xs'>Error</span>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
<button
|
|
211
|
+
onClick={() => handleRemoveFile(file.previewUrl)}
|
|
212
|
+
className='babylai-absolute -babylai-top-2 -babylai-right-2 babylai-bg-red-500 babylai-text-white babylai-rounded-full babylai-w-5 babylai-h-5 babylai-flex babylai-items-center babylai-justify-center babylai-opacity-0 group-hover:babylai-opacity-100 babylai-transition-opacity'
|
|
213
|
+
type='button'
|
|
214
|
+
aria-label='Remove image'
|
|
215
|
+
>
|
|
216
|
+
<XIcon className='babylai-w-3 babylai-h-3' />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
<div className='babylai-flex babylai-items-center babylai-gap-2 babylai-relative babylai-rounded-full babylai-bg-white dark:!babylai-bg-storm-dust-900 babylai-py-1 babylai-px-2 md:babylai-py-3 md:babylai-px-4'>
|
|
224
|
+
<input
|
|
225
|
+
type='file'
|
|
226
|
+
ref={fileInputRef}
|
|
227
|
+
onChange={handleFileSelect}
|
|
228
|
+
accept='image/*'
|
|
229
|
+
multiple
|
|
230
|
+
className='babylai-hidden'
|
|
231
|
+
/>
|
|
232
|
+
<Button
|
|
233
|
+
variant='ghost'
|
|
234
|
+
size='icon'
|
|
235
|
+
onClick={handleAttachClick}
|
|
236
|
+
disabled={props.isLoading}
|
|
237
|
+
className='babylai-rounded-full babylai-w-8 babylai-h-8 babylai-text-black-white-500 hover:babylai-text-primary-500 hover:babylai-bg-transparent'
|
|
238
|
+
type='button'
|
|
239
|
+
>
|
|
240
|
+
<PaperclipIcon className='babylai-w-4 babylai-h-4' />
|
|
241
|
+
</Button>
|
|
242
|
+
<input
|
|
243
|
+
type='text'
|
|
244
|
+
value={props.inputMessage}
|
|
245
|
+
onChange={(e) => props.setInputMessage(e.target.value)}
|
|
246
|
+
onKeyDown={handleKeyDown}
|
|
247
|
+
placeholder={t('homeSdk.placeholder')}
|
|
248
|
+
className='babylai-flex-1 babylai-py-2 babylai-px-2 babylai-bg-transparent babylai-outline-none babylai-text-sm dark:babylai-text-white babylai-border-none'
|
|
249
|
+
/>
|
|
250
|
+
<Button
|
|
251
|
+
variant='default'
|
|
252
|
+
size='icon'
|
|
253
|
+
onClick={handleSendMessageWithAttachments}
|
|
254
|
+
disabled={isSendDisabled}
|
|
255
|
+
className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 disabled:babylai-opacity-50'
|
|
256
|
+
type='button'
|
|
257
|
+
>
|
|
258
|
+
<EnvelopeIcon className={`babylai-w-4 babylai-h-4 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`} />
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Image Preview Dialog */}
|
|
263
|
+
{previewImage && (
|
|
264
|
+
<ImagePreviewDialog
|
|
265
|
+
imageUrls={[previewImage]}
|
|
266
|
+
initialIndex={0}
|
|
267
|
+
isOpen={!!previewImage}
|
|
268
|
+
onClose={() => setPreviewImage(null)}
|
|
269
|
+
alt='Image preview'
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
35
272
|
</footer>
|
|
36
273
|
);
|
|
37
274
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import AgentResponse from '@/components/ui/agent-response/agent-response';
|
|
2
|
+
import { ImageAttachment, ImagePreviewDialog } from '@/components/ui';
|
|
2
3
|
import { Message } from '@/lib/types';
|
|
3
4
|
import ChatWindowFooter from '@/ui/chatbot-popup/chat-window-screen/footer';
|
|
4
5
|
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|
@@ -6,9 +7,12 @@ import LoadingGif from './../../../assets/animatedLogo.gif';
|
|
|
6
7
|
import Seperator from './../../../assets/icons/seperator.svg';
|
|
7
8
|
import LogoIcon from './../../../assets/logo.svg';
|
|
8
9
|
import HumanIcon from './../../../assets/icons/user.svg';
|
|
10
|
+
import { presignDownload } from '@/core/api';
|
|
11
|
+
import { useLocalTranslation } from '../../../useLocalTranslation';
|
|
9
12
|
|
|
10
13
|
interface ChatWindowProps {
|
|
11
|
-
onSendMessage: (message: string) => void;
|
|
14
|
+
onSendMessage: (message: string, attachmentIds: string[]) => void;
|
|
15
|
+
onEnsureSession: () => Promise<string>;
|
|
12
16
|
messages: Message[];
|
|
13
17
|
assistantStatus: string;
|
|
14
18
|
needsAgent: boolean;
|
|
@@ -23,17 +27,28 @@ const MessageComponent = React.memo(
|
|
|
23
27
|
messages,
|
|
24
28
|
firstHumanAgentIndex,
|
|
25
29
|
onType,
|
|
30
|
+
onImageClick,
|
|
26
31
|
}: {
|
|
27
32
|
message: Message;
|
|
28
33
|
index: number;
|
|
29
34
|
messages: Message[];
|
|
30
35
|
firstHumanAgentIndex: number;
|
|
31
36
|
onType: () => void;
|
|
37
|
+
onImageClick: (attachmentIds: string[], clickedIndex: number) => void;
|
|
32
38
|
}) => {
|
|
33
39
|
const isFirstInSequence = index === 0 || messages[index - 1].senderType !== message.senderType;
|
|
34
40
|
const isFirstHumanAgentMessage = index === firstHumanAgentIndex && message.senderType === 2;
|
|
35
41
|
const textDirection = message.senderType === 1 ? 'babylai-justify-end' : 'babylai-justify-start';
|
|
36
42
|
|
|
43
|
+
const handleImageClick = useCallback(
|
|
44
|
+
(clickedIndex: number) => {
|
|
45
|
+
if (message.attachmentIds && message.attachmentIds.length > 0) {
|
|
46
|
+
onImageClick(message.attachmentIds, clickedIndex);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[message.attachmentIds, onImageClick]
|
|
50
|
+
);
|
|
51
|
+
|
|
37
52
|
return (
|
|
38
53
|
<div key={message.id}>
|
|
39
54
|
{isFirstHumanAgentMessage && (
|
|
@@ -59,12 +74,26 @@ const MessageComponent = React.memo(
|
|
|
59
74
|
)}
|
|
60
75
|
{!isFirstInSequence && <div className='babylai-flex-shrink-0 babylai-me-3 babylai-w-8'></div>}
|
|
61
76
|
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
<div className='babylai-flex babylai-flex-col babylai-gap-2'>
|
|
78
|
+
{message.attachmentIds && message.attachmentIds.length > 0 && (
|
|
79
|
+
<div className='babylai-flex babylai-flex-row babylai-flex-wrap babylai-gap-2 babylai-max-w-full'>
|
|
80
|
+
{message.attachmentIds.map((attachmentId, imgIndex) => (
|
|
81
|
+
<ImageAttachment
|
|
82
|
+
key={attachmentId}
|
|
83
|
+
fileId={attachmentId}
|
|
84
|
+
enablePreview={false}
|
|
85
|
+
onClick={() => handleImageClick(imgIndex)}
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
<AgentResponse
|
|
91
|
+
messageContent={message.messageContent}
|
|
92
|
+
senderType={message.senderType}
|
|
93
|
+
messageId={message.id}
|
|
94
|
+
onType={onType}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
68
97
|
</div>
|
|
69
98
|
</div>
|
|
70
99
|
);
|
|
@@ -93,111 +122,187 @@ const TypingIndicator = React.memo(({ firstHumanAgentIndex }: { firstHumanAgentI
|
|
|
93
122
|
|
|
94
123
|
TypingIndicator.displayName = 'TypingIndicator';
|
|
95
124
|
|
|
96
|
-
export const ChatWindow = React.memo(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// Only scroll when new messages are added or status changes
|
|
117
|
-
useEffect(() => {
|
|
118
|
-
if (messages.length !== lastMessageCountRef.current || assistantStatus === 'typing') {
|
|
119
|
-
lastMessageCountRef.current = messages.length;
|
|
120
|
-
scrollToBottom();
|
|
121
|
-
}
|
|
122
|
-
}, [messages.length, assistantStatus, scrollToBottom]);
|
|
123
|
-
|
|
124
|
-
// Cleanup timeout on unmount
|
|
125
|
-
useEffect(() => {
|
|
126
|
-
return () => {
|
|
125
|
+
export const ChatWindow = React.memo(
|
|
126
|
+
({ onSendMessage, onEnsureSession, messages, assistantStatus = 'loading', needsAgent }: ChatWindowProps) => {
|
|
127
|
+
const { i18n } = useLocalTranslation();
|
|
128
|
+
const [inputMessage, setInputMessage] = useState('');
|
|
129
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
131
|
+
const lastMessageCountRef = useRef(messages.length);
|
|
132
|
+
const [galleryState, setGalleryState] = useState<{
|
|
133
|
+
isOpen: boolean;
|
|
134
|
+
imageUrls: string[];
|
|
135
|
+
initialIndex: number;
|
|
136
|
+
}>({
|
|
137
|
+
isOpen: false,
|
|
138
|
+
imageUrls: [],
|
|
139
|
+
initialIndex: 0,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Debounced scroll to bottom function
|
|
143
|
+
const scrollToBottom = useCallback(() => {
|
|
127
144
|
if (scrollTimeoutRef.current) {
|
|
128
145
|
clearTimeout(scrollTimeoutRef.current);
|
|
129
146
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
|
|
148
|
+
scrollTimeoutRef.current = setTimeout(() => {
|
|
149
|
+
messagesEndRef.current?.scrollIntoView({
|
|
150
|
+
behavior: 'smooth',
|
|
151
|
+
block: 'end',
|
|
152
|
+
});
|
|
153
|
+
}, 100);
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
// Only scroll when new messages are added or status changes
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (messages.length !== lastMessageCountRef.current || assistantStatus === 'typing') {
|
|
159
|
+
lastMessageCountRef.current = messages.length;
|
|
160
|
+
scrollToBottom();
|
|
161
|
+
}
|
|
162
|
+
}, [messages.length, assistantStatus, scrollToBottom]);
|
|
163
|
+
|
|
164
|
+
// Cleanup timeout on unmount
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
return () => {
|
|
167
|
+
if (scrollTimeoutRef.current) {
|
|
168
|
+
clearTimeout(scrollTimeoutRef.current);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const handleSendMessage = useCallback(
|
|
174
|
+
(attachmentIds: string[]) => {
|
|
175
|
+
if (inputMessage.trim()) {
|
|
176
|
+
onSendMessage(inputMessage, attachmentIds);
|
|
146
177
|
setInputMessage('');
|
|
147
178
|
}
|
|
179
|
+
},
|
|
180
|
+
[inputMessage, onSendMessage]
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Memoize the first human agent index calculation
|
|
184
|
+
const firstHumanAgentIndex = useMemo(() => {
|
|
185
|
+
return messages.findIndex((message) => message.senderType === 2);
|
|
186
|
+
}, [messages]);
|
|
187
|
+
|
|
188
|
+
// Handle image gallery opening
|
|
189
|
+
const handleImageClick = useCallback(
|
|
190
|
+
async (attachmentIds: string[], clickedIndex: number) => {
|
|
191
|
+
if (!attachmentIds || attachmentIds.length === 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
// Fetch all image URLs with comprehensive error handling
|
|
197
|
+
const imageUrlPromises = attachmentIds.map((fileId) => {
|
|
198
|
+
if (!fileId || typeof fileId !== 'string') {
|
|
199
|
+
return Promise.resolve(null);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return presignDownload(fileId, i18n.language)
|
|
203
|
+
.then((response) => {
|
|
204
|
+
if (!response || !response.downloadUrl) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return response.downloadUrl;
|
|
208
|
+
})
|
|
209
|
+
.catch(() => {
|
|
210
|
+
// Return null for failed downloads so we can filter them out
|
|
211
|
+
return null;
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const imageUrls = (await Promise.all(imageUrlPromises)).filter(
|
|
216
|
+
(url): url is string => url !== null && url.length > 0
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (imageUrls.length === 0) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Adjust the initial index if some images failed to load
|
|
224
|
+
let adjustedIndex = Math.max(0, Math.min(clickedIndex, imageUrls.length - 1));
|
|
225
|
+
|
|
226
|
+
setGalleryState({
|
|
227
|
+
isOpen: true,
|
|
228
|
+
imageUrls,
|
|
229
|
+
initialIndex: adjustedIndex,
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// Handle unexpected errors silently
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
[i18n.language]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const handleCloseGallery = useCallback(() => {
|
|
239
|
+
setGalleryState({
|
|
240
|
+
isOpen: false,
|
|
241
|
+
imageUrls: [],
|
|
242
|
+
initialIndex: 0,
|
|
243
|
+
});
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
// Memoize loading state check
|
|
247
|
+
// When a human agent has joined, don't disable based on assistantStatus
|
|
248
|
+
const hasHumanAgent = useMemo(() => {
|
|
249
|
+
return needsAgent || messages.some((msg) => msg.senderType === 2);
|
|
250
|
+
}, [needsAgent, messages]);
|
|
251
|
+
|
|
252
|
+
const isLoading = useMemo(() => {
|
|
253
|
+
// If human agent has joined, don't disable file uploads based on assistantStatus
|
|
254
|
+
if (hasHumanAgent) {
|
|
255
|
+
return false;
|
|
148
256
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
257
|
+
return assistantStatus === 'typing' || assistantStatus === 'loading' || assistantStatus === 'error';
|
|
258
|
+
}, [assistantStatus, hasHumanAgent]);
|
|
259
|
+
|
|
260
|
+
// Memoize the message list to prevent unnecessary re-renders
|
|
261
|
+
const messagesListWithGallery = useMemo(() => {
|
|
262
|
+
return messages.map((message, index) => (
|
|
263
|
+
<MessageComponent
|
|
264
|
+
key={`${message.id}-${index}`}
|
|
265
|
+
message={message}
|
|
266
|
+
index={index}
|
|
267
|
+
messages={messages}
|
|
268
|
+
firstHumanAgentIndex={firstHumanAgentIndex}
|
|
269
|
+
onType={scrollToBottom}
|
|
270
|
+
onImageClick={handleImageClick}
|
|
271
|
+
/>
|
|
272
|
+
));
|
|
273
|
+
}, [messages, firstHumanAgentIndex, scrollToBottom, handleImageClick]);
|
|
152
274
|
|
|
153
|
-
// Memoize the first human agent index calculation
|
|
154
|
-
const firstHumanAgentIndex = useMemo(() => {
|
|
155
|
-
return messages.findIndex((message) => message.senderType === 2);
|
|
156
|
-
}, [messages]);
|
|
157
|
-
|
|
158
|
-
// Memoize the message list to prevent unnecessary re-renders
|
|
159
|
-
const messagesList = useMemo(() => {
|
|
160
|
-
return messages.map((message, index) => (
|
|
161
|
-
<MessageComponent
|
|
162
|
-
key={`${message.id}-${index}`}
|
|
163
|
-
message={message}
|
|
164
|
-
index={index}
|
|
165
|
-
messages={messages}
|
|
166
|
-
firstHumanAgentIndex={firstHumanAgentIndex}
|
|
167
|
-
onType={scrollToBottom}
|
|
168
|
-
/>
|
|
169
|
-
));
|
|
170
|
-
}, [messages, firstHumanAgentIndex, scrollToBottom]);
|
|
171
|
-
|
|
172
|
-
// Memoize loading state check
|
|
173
|
-
const isLoading = useMemo(() => {
|
|
174
275
|
return (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
inputMessage.trim() === ''
|
|
179
|
-
);
|
|
180
|
-
}, [assistantStatus, inputMessage]);
|
|
276
|
+
<>
|
|
277
|
+
<div className='babylai-flex-1 babylai-overflow-y-auto babylai-p-4 babylai-h-full'>
|
|
278
|
+
{messagesListWithGallery}
|
|
181
279
|
|
|
182
|
-
|
|
183
|
-
<>
|
|
184
|
-
<div className='babylai-flex-1 babylai-overflow-y-auto babylai-p-4 babylai-h-full'>
|
|
185
|
-
{messagesList}
|
|
280
|
+
{assistantStatus === 'typing' && <TypingIndicator firstHumanAgentIndex={firstHumanAgentIndex} />}
|
|
186
281
|
|
|
187
|
-
|
|
282
|
+
<div ref={messagesEndRef} />
|
|
283
|
+
</div>
|
|
188
284
|
|
|
189
|
-
<
|
|
190
|
-
|
|
285
|
+
<ChatWindowFooter
|
|
286
|
+
inputMessage={inputMessage}
|
|
287
|
+
handleSendMessage={handleSendMessage}
|
|
288
|
+
setInputMessage={setInputMessage}
|
|
289
|
+
isLoading={isLoading}
|
|
290
|
+
onEnsureSession={onEnsureSession}
|
|
291
|
+
/>
|
|
191
292
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
293
|
+
{/* Gallery Preview Dialog */}
|
|
294
|
+
{galleryState.isOpen && galleryState.imageUrls.length > 0 && (
|
|
295
|
+
<ImagePreviewDialog
|
|
296
|
+
imageUrls={galleryState.imageUrls}
|
|
297
|
+
initialIndex={galleryState.initialIndex}
|
|
298
|
+
isOpen={galleryState.isOpen}
|
|
299
|
+
onClose={handleCloseGallery}
|
|
300
|
+
alt='Image gallery preview'
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
</>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
);
|
|
202
307
|
|
|
203
308
|
ChatWindow.displayName = 'ChatWindow';
|